carthagent 1.0.7 → 1.1.0-preview.13

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 (289) hide show
  1. package/README.md +140 -83
  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 +336 -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-combined-v5.patch +13965 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  58. package/docs/release-notes-native-draft.md +73 -0
  59. package/docs/release-notes-preview.10.md +121 -0
  60. package/docs/release-notes-preview.11.md +76 -0
  61. package/docs/release-notes-preview.12.md +55 -0
  62. package/docs/release-notes-preview.13.md +68 -0
  63. package/docs/release-notes-preview.9.md +130 -0
  64. package/docs/source-preview.md +120 -0
  65. package/evaluate.mjs +28 -1
  66. package/lib/budget.mjs +1 -1
  67. package/lib/codegraph.mjs +1 -1
  68. package/lib/delivery-review.mjs +11 -1
  69. package/lib/delivery.mjs +3 -2
  70. package/lib/environment.mjs +8 -0
  71. package/lib/guidance.mjs +1 -1
  72. package/lib/opencode/cli-runtime.mjs +1171 -0
  73. package/lib/opencode/cli.mjs +252 -0
  74. package/lib/opencode/credentials.mjs +63 -8
  75. package/lib/opencode/demo.mjs +53 -0
  76. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  77. package/lib/opencode/evaluation.mjs +32 -12
  78. package/lib/opencode/headless.mjs +8 -4
  79. package/lib/opencode/provider-policy.mjs +23 -0
  80. package/lib/opencode/review.mjs +37 -16
  81. package/lib/opencode/runtime-artifact.mjs +335 -0
  82. package/lib/opencode/runtime-github-transport.mjs +121 -0
  83. package/lib/opencode/runtime-release.json +36 -0
  84. package/lib/opencode/runtime.mjs +299 -20
  85. package/lib/opencode/service.mjs +77 -35
  86. package/lib/opencode/session-options.mjs +6 -2
  87. package/lib/opencode/update.mjs +222 -0
  88. package/lib/opencode/workspace.mjs +1 -1
  89. package/lib/planning-advice.mjs +2 -2
  90. package/lib/providers/experiential.mjs +2 -2
  91. package/lib/tui/framing.mjs +1 -80
  92. package/lib/update.mjs +10 -1
  93. package/package.json +33 -7
  94. package/plugins/carthagent/README.md +23 -0
  95. package/plugins/carthagent/src/bootstrap.ts +1 -1
  96. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  97. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  98. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  99. package/plugins/carthagent/src/index.ts +71 -24
  100. package/plugins/carthagent/src/rpc.ts +51 -6
  101. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  102. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  103. package/plugins/carthagent/src/server/daily-source-rpc.ts +211 -0
  104. package/plugins/carthagent/src/server/delivery.ts +227 -112
  105. package/plugins/carthagent/src/server/framing.mjs +80 -0
  106. package/plugins/carthagent/src/server/policy.ts +1 -1
  107. package/plugins/carthagent/src/server/providers.ts +59 -31
  108. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  109. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  110. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  111. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  112. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  113. package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
  114. package/plugins/carthagent/src/testing.ts +5 -0
  115. package/plugins/carthagent/src/tui.tsx +496 -0
  116. package/plugins/carthagent/src/ui/client.ts +125 -0
  117. package/plugins/carthagent/src/ui/commands.ts +70 -0
  118. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  119. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  120. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  121. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  122. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  123. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  124. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  125. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  126. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  127. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  128. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  129. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  130. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  131. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  132. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  133. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  134. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  135. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  136. package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
  137. package/plugins/carthagent/src/ui/voyages.tsx +129 -0
  138. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  139. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  140. package/plugins/carthagent/src/voyages-rpc.ts +60 -0
  141. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  142. package/plugins/carthagent/tests/commands.test.ts +107 -0
  143. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  144. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  145. package/plugins/carthagent/themes/carthage.json +67 -0
  146. package/plugins/carthagent/tui.ts +26 -1
  147. package/script/ctg/build-owned-runtime.sh +166 -0
  148. package/tests/budget-engine.test.mjs +4 -3
  149. package/tests/cli.test.mjs +52 -96
  150. package/tests/daily-evidence.test.mjs +62 -0
  151. package/tests/delivery-review.test.mjs +11 -0
  152. package/tests/delivery.test.mjs +22 -1
  153. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  154. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  155. package/tests/installed-runtime-contract.test.mjs +43 -0
  156. package/tests/native-cli-host-smoke.mjs +457 -0
  157. package/tests/native-daily-editor-focus.test.mjs +213 -0
  158. package/tests/native-daily-editor.test.mjs +43 -0
  159. package/tests/native-daily-quests-layout.test.mjs +53 -0
  160. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  161. package/tests/native-daily-quests-ui.test.mjs +778 -0
  162. package/tests/native-daily-source.test.mjs +451 -0
  163. package/tests/native-demo-ui.test.mjs +48 -0
  164. package/tests/native-evaluation-admission.test.mjs +24 -0
  165. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  166. package/tests/native-fixture-runtime.test.mjs +111 -0
  167. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  168. package/tests/native-glyph-pty.test.mjs +107 -0
  169. package/tests/native-manual-projects.test.mjs +170 -0
  170. package/tests/native-model-command.test.mjs +67 -0
  171. package/tests/native-owned-runtime-env.test.mjs +140 -0
  172. package/tests/native-paired-guards.test.mjs +464 -0
  173. package/tests/native-provider-policy.test.mjs +31 -0
  174. package/tests/native-pty-invocation.test.mjs +114 -0
  175. package/tests/native-pty-runtime.test.mjs +45 -0
  176. package/tests/native-resume-session.test.mjs +46 -0
  177. package/tests/native-review-consumer.test.mjs +286 -0
  178. package/tests/native-runtime-identity.test.mjs +214 -0
  179. package/tests/native-select-mouse.test.mjs +23 -0
  180. package/tests/native-ship-parity.test.mjs +526 -0
  181. package/tests/native-ship-plan.test.mjs +85 -0
  182. package/tests/native-ship-rpc.test.mjs +71 -0
  183. package/tests/native-ui-commands.test.mjs +6 -0
  184. package/tests/native-voyages-layout.test.mjs +28 -0
  185. package/tests/native-voyages-navigation.test.mjs +169 -0
  186. package/tests/native-voyages-rpc.test.mjs +395 -0
  187. package/tests/native-voyages-ui.test.mjs +277 -0
  188. package/tests/opencode-atomic-write.test.mjs +55 -0
  189. package/tests/opencode-cli.test.mjs +1312 -0
  190. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  191. package/tests/opencode-delivery.test.mjs +16 -0
  192. package/tests/opencode-providers.test.mjs +245 -18
  193. package/tests/opencode-runtime.test.mjs +546 -8
  194. package/tests/opencode-session-controls.test.mjs +15 -0
  195. package/tests/opencode-ship-rpc.test.mjs +71 -0
  196. package/tests/opencode-update.test.mjs +475 -0
  197. package/tests/opencode-workspace.test.mjs +51 -2
  198. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  199. package/tests/release-workflow.test.mjs +46 -0
  200. package/tests/runtime-artifact.test.mjs +269 -0
  201. package/tests/runtime-development-trust.test.mjs +230 -0
  202. package/tests/runtime-github-transport.test.mjs +132 -0
  203. package/tests/runtime-token-env.test.mjs +23 -0
  204. package/tests/server.test.mjs +3 -1
  205. package/tests/ship-telemetry.test.mjs +1 -1
  206. package/tests/source-preview.test.mjs +253 -0
  207. package/tests/test_native_terminal_probe.py +76 -0
  208. package/tests/test_native_terminal_semantics.py +232 -0
  209. package/tests/update.test.mjs +42 -5
  210. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  211. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  212. package/tests/workspace-live.test.mjs +127 -0
  213. package/tools/benchmark/diagnose-request.mjs +59 -36
  214. package/tools/benchmark/eval-common.mjs +3 -3
  215. package/tools/benchmark/evaluators.test.mjs +4 -4
  216. package/tools/benchmark/guard.test.mjs +35 -2
  217. package/tools/benchmark/guard.ts +20 -7
  218. package/tools/benchmark/planning-startup.test.mjs +30 -34
  219. package/tools/benchmark/runner.mjs +12 -7
  220. package/tools/benchmark/runner.test.mjs +23 -7
  221. package/tools/benchmark/upstream-error.test.mjs +16 -2
  222. package/tools/generate-owned-runtime-release.mjs +217 -0
  223. package/tools/installed-runtime-contract.mjs +16 -0
  224. package/tools/manual-runtime-manifest.mjs +39 -0
  225. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  226. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  227. package/tools/native-fixture.mjs +199 -40
  228. package/tools/native-glyph-helpers.py +127 -0
  229. package/tools/native-glyph-pty.py +144 -0
  230. package/tools/native-manual-projects.mjs +259 -0
  231. package/tools/native-manual.mjs +338 -0
  232. package/tools/native-model-picker-acceptance.py +306 -0
  233. package/tools/native-owned-runtime-env.sh +47 -0
  234. package/tools/native-paired-guards.mjs +282 -0
  235. package/tools/native-paired-manifest.mjs +72 -0
  236. package/tools/native-paired-orchestration.sh +51 -0
  237. package/tools/native-plugin-readiness.mjs +33 -0
  238. package/tools/native-plugin-readiness.test.mjs +53 -0
  239. package/tools/native-pty-invocation.mjs +26 -0
  240. package/tools/native-pty-runtime.mjs +83 -0
  241. package/tools/native-pty-smoke.py +147 -24
  242. package/tools/native-public-repo-shell-result.mjs +13 -0
  243. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  244. package/tools/native-runtime-identity.mjs +201 -0
  245. package/tools/native-slash-acceptance-env.py +38 -0
  246. package/tools/native-terminal-semantics.py +876 -0
  247. package/tools/owned-shell-observer/index.ts +16 -0
  248. package/tools/owned-shell-observer/package.json +1 -0
  249. package/tools/owned-visual-clear-env.py +48 -0
  250. package/tools/owned-visual-clear-model.py +21 -0
  251. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  252. package/tools/prepare-native-manual.mjs +122 -0
  253. package/tools/run-native-terminal-paired.sh +403 -0
  254. package/tools/runtime-token-env.mjs +8 -0
  255. package/tools/seed-native-switch-fixture.mjs +79 -0
  256. package/tools/test-native-controls.mjs +3 -1
  257. package/tools/test-native-daily-quests.mjs +65 -0
  258. package/tools/test-native-default-cli.mjs +106 -0
  259. package/tools/test-native-delivery-host.mjs +102 -20
  260. package/tools/test-native-evaluation.mjs +7 -3
  261. package/tools/test-native-glyph-pty.mjs +94 -0
  262. package/tools/test-native-host.mjs +87 -63
  263. package/tools/test-native-outcomes.mjs +3 -1
  264. package/tools/test-native-package.mjs +206 -38
  265. package/tools/test-native-performance.mjs +26 -0
  266. package/tools/test-native-product.mjs +90 -0
  267. package/tools/test-native-pty.mjs +34 -9
  268. package/tools/test-native-public-repo.mjs +224 -0
  269. package/tools/test-native-refresh-reentry.mjs +106 -0
  270. package/tools/test-native-report.mjs +27 -20
  271. package/tools/test-native-review.mjs +75 -0
  272. package/tools/test-native-runtime.mjs +120 -0
  273. package/tools/test-native-ship-models.mjs +176 -0
  274. package/tools/test-native-ship-walkthrough.mjs +96 -0
  275. package/tools/test-native-slash-commands.mjs +125 -0
  276. package/tools/test-native-slash-commands.py +242 -0
  277. package/tools/test-native-swe-host.mjs +224 -55
  278. package/tools/test-native-tasks.mjs +77 -0
  279. package/tools/test-native-terminal-performance.py +887 -0
  280. package/tools/test-native-terminal-readiness.sh +85 -0
  281. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  282. package/tools/test-native-visual-clear-installed.mjs +48 -0
  283. package/tools/test-owned-host-controls.mjs +238 -0
  284. package/tools/test-owned-opencode-runtime.mjs +35 -0
  285. package/tools/test-owned-visual-clear-env.py +120 -0
  286. package/tools/test-owned-visual-clear.py +373 -0
  287. package/tools/verify-draft-runtime-gate.mjs +138 -0
  288. package/tools/verify-owned-runtime-release.mjs +114 -0
  289. package/tools/verify-release-version.mjs +21 -1
@@ -2,11 +2,12 @@ import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
5
6
  import { getEventListeners } from 'node:events';
6
- import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
7
+ import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
7
8
  import { spawn } from 'node:child_process';
8
9
  import { nativeCommandConfig } from '../lib/opencode/compatibility.mjs';
9
- import { provisionRuntime, resolveOpenCode, runtimeEnvironment, runtimePaths, withFileLock, workspaceEnvironment } from '../lib/opencode/runtime.mjs';
10
+ import { CTG_RUNTIME_LOCK_SHA256, CTG_RUNTIME_PATCH_SHA256, CTG_RUNTIME_SOURCE, CTG_RUNTIME_VERSION, OPENCODE_VERSION, provisionRuntime, readRuntimeReleaseManifest, resolveOpenCode, resolveOwnedOpenCode, resolvePathCommand, runtimeEnvironment, runtimeIdentity, runtimePaths, withFileLock, workspaceEnvironment, processAlive } from '../lib/opencode/runtime.mjs';
10
11
  import { cancelSession, createSession, ensureService, getSession, launchTui, promptSession, serviceRequest, sessionMessages, setSessionEnvironment, stopService } from '../lib/opencode/service.mjs';
11
12
  import { runHeadless } from '../lib/opencode/headless.mjs';
12
13
 
@@ -25,7 +26,7 @@ function pendingUntilAbort(signal) {
25
26
  test('CTG runtime paths and env are isolated from host OpenCode roots', () => {
26
27
  const root = path.join(os.tmpdir(), 'ctg-test-profile');
27
28
  const paths = runtimePaths({ root });
28
- const env = runtimeEnvironment({ root, env: { ANTHROPIC_API_KEY: 'test' } });
29
+ const env = runtimeEnvironment({ root, env: { ANTHROPIC_API_KEY: 'test', CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token' } });
29
30
  assert.equal(env.XDG_CONFIG_HOME, paths.config);
30
31
  assert.equal(env.XDG_DATA_HOME, paths.data);
31
32
  assert.equal(env.XDG_STATE_HOME, paths.state);
@@ -35,9 +36,11 @@ test('CTG runtime paths and env are isolated from host OpenCode roots', () => {
35
36
  assert.equal(env.LOCALAPPDATA, path.join(paths.home, 'AppData', 'Local'));
36
37
  assert.equal(env.OPENCODE_TEST_HOME, paths.home);
37
38
  assert.equal(env.OPENCODE_CONFIG_DIR, paths.configDir);
38
- assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, 'true');
39
+ assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, undefined);
40
+ assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, undefined);
39
41
  assert.equal(env.OPENCODE_DB, path.join(paths.data, 'opencode.db'));
40
42
  assert.equal(env.ANTHROPIC_API_KEY, 'test');
43
+ assert.equal(env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
41
44
  assert.equal(env.OPENCODE_CUSTOM_CONFIG, undefined);
42
45
  assert.notEqual(paths.serviceFile, path.join(os.homedir(), '.local/state/opencode/service.json'));
43
46
  });
@@ -51,16 +54,32 @@ test('runtime removes inherited OpenCode profile overrides', () => {
51
54
  OPENCODE_CONFIG_PROJECT_DISABLE: 'false',
52
55
  OPENCODE_SERVER_PASSWORD: 'user-service-secret',
53
56
  OPENAI_API_KEY: 'preserved',
57
+ CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token',
54
58
  } });
55
59
  assert.equal(env.OPENCODE_CONFIG_DIR, paths.configDir);
56
60
  assert.equal(env.OPENCODE_CONFIG, paths.configFile);
57
- assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, 'true');
61
+ assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, undefined);
58
62
  assert.equal(env.OPENCODE_SERVER_PASSWORD, undefined);
59
63
  assert.equal(env.OPENAI_API_KEY, 'preserved');
64
+ assert.equal(env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
60
65
  assert.equal(env.APPDATA, paths.appData);
61
66
  assert.equal(env.LOCALAPPDATA, paths.localAppData);
62
67
  });
63
68
 
69
+ test('normal native runtime leaves pinned-host project discovery at its default', () => {
70
+ const env = runtimeEnvironment({ root: path.resolve('/tmp/ctg-normal-discovery') });
71
+ assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, undefined);
72
+ assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE, undefined);
73
+ assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, undefined);
74
+ });
75
+
76
+ test('isolated runtime explicitly disables project and Claude skill discovery', () => {
77
+ const env = runtimeEnvironment({ root: path.resolve('/tmp/ctg-isolate-test'), isolate: true });
78
+ assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, 'true');
79
+ assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE, 'true');
80
+ assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, 'true');
81
+ });
82
+
64
83
  test('runtime overrides inherited Windows profile roots with CTG-owned paths', () => {
65
84
  const root = path.resolve('/tmp/ctg-windows-roots');
66
85
  const paths = runtimePaths({ root });
@@ -71,7 +90,7 @@ test('runtime overrides inherited Windows profile roots with CTG-owned paths', (
71
90
  } });
72
91
  assert.equal(env.APPDATA, paths.appData);
73
92
  assert.equal(env.LOCALAPPDATA, paths.localAppData);
74
- assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, 'true');
93
+ assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, undefined);
75
94
  });
76
95
 
77
96
  test('stale-lock recovery is serialized across independent processes', async () => {
@@ -158,12 +177,84 @@ test('profile provisioning creates private isolated profile and absolute plugin
158
177
  } finally { await rm(root, { recursive: true, force: true }); }
159
178
  });
160
179
 
180
+ test('profile provisioning replaces stale CTG checkout plugins and preserves unrelated settings', async t => {
181
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-plugin-migration-'));
182
+ t.after(() => rm(root, { recursive: true, force: true }));
183
+ const pluginPath = path.join(root, 'installed/plugins/carthagent');
184
+ const paths = await provisionRuntime({ root });
185
+ const unrelated = { package: 'other-plugin', options: { enabled: true } };
186
+ await writeFile(paths.configFile, JSON.stringify({
187
+ model: 'kept/model', providers: { custom: { name: 'Keep me' } },
188
+ plugins: ['before-plugin', '/deleted/pr169/plugins/carthagent', unrelated,
189
+ '/old/checkout/plugins/carthagent/index.ts', '/old/checkout/plugins/carthagent/src/index.ts',
190
+ pathToFileURL(path.join(root, 'deleted/review/plugins/carthagent')).href, 'C:\\old\\checkout\\plugins\\carthagent\\',
191
+ pluginPath, 'after-plugin', '/unrelated/plugins/carthagent-extra'],
192
+ }));
193
+ await provisionRuntime({ root, pluginPath });
194
+ const updated = JSON.parse(await readFile(paths.configFile, 'utf8'));
195
+ assert.deepEqual(updated.plugins, ['before-plugin', pluginPath, unrelated, 'after-plugin', '/unrelated/plugins/carthagent-extra']);
196
+ assert.equal(updated.model, 'kept/model');
197
+ assert.deepEqual(updated.providers, { custom: { name: 'Keep me' } });
198
+ await provisionRuntime({ root, pluginPath });
199
+ assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')), updated, 'repeated launch is idempotent');
200
+ const next = path.join(root, 'next-install/plugins/carthagent');
201
+ await provisionRuntime({ root, pluginPath: next });
202
+ assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')).plugins,
203
+ ['before-plugin', next, unrelated, 'after-plugin', '/unrelated/plugins/carthagent-extra']);
204
+ });
205
+
206
+ test('managed plugin replacement retains object options and prefers the selected installation', async t => {
207
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-plugin-options-'));
208
+ t.after(() => rm(root, { recursive: true, force: true }));
209
+ const pluginPath = path.join(root, 'installed/plugins/carthagent');
210
+ const paths = await provisionRuntime({ root });
211
+ const chosen = { package: pluginPath, options: { maxTools: 42 } };
212
+ await writeFile(paths.configFile, JSON.stringify({ plugins: [
213
+ '/deleted/plugins/carthagent', chosen,
214
+ { package: '/another/plugins/carthagent', options: { maxTools: 10 } },
215
+ '/last/deleted/plugins/carthagent',
216
+ ] }));
217
+ await provisionRuntime({ root, pluginPath });
218
+ assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')).plugins, [chosen]);
219
+ const next = path.join(root, 'next/plugins/carthagent');
220
+ await provisionRuntime({ root, pluginPath: next });
221
+ assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')).plugins, [{ ...chosen, package: next }]);
222
+ await writeFile(paths.configFile, JSON.stringify({ plugins: [chosen, '/last/deleted/plugins/carthagent'] }));
223
+ await provisionRuntime({ root, pluginPath: next });
224
+ assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')).plugins, [{ ...chosen, package: next }],
225
+ 'a trailing string reference must not discard saved plugin options');
226
+ });
227
+
161
228
  test('workspace environment records workspace without leaking CTG XDG overrides', () => {
162
229
  const workspace = path.resolve('/tmp/workspace');
163
- const env = workspaceEnvironment(workspace, { XDG_CONFIG_HOME: '/host/config', OPENAI_API_KEY: 'secret' });
230
+ const env = workspaceEnvironment(workspace, { XDG_CONFIG_HOME: '/host/config', OPENAI_API_KEY: 'secret', CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token' });
164
231
  assert.equal(env.XDG_CONFIG_HOME, '/host/config');
165
232
  assert.equal(env.CTG_WORKSPACE, workspace);
166
233
  assert.equal(env.OPENAI_API_KEY, 'secret');
234
+ assert.equal(env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
235
+ });
236
+
237
+ test('runtime version-check subprocess does not inherit the draft transport credential', async () => {
238
+ const { PassThrough } = await import('node:stream');
239
+ let childEnv;
240
+ const child = new (await import('node:events')).EventEmitter();
241
+ child.stdout = new PassThrough();
242
+ child.stderr = new PassThrough();
243
+ child.kill = () => {};
244
+ const pending = (await import('../lib/opencode/runtime.mjs')).runCapture('fixture-runtime', ['--version'], {
245
+ env: { CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token', PATH: '/bin' },
246
+ spawnImpl: (_command, _args, options) => {
247
+ childEnv = options.env;
248
+ queueMicrotask(() => {
249
+ child.stdout.end('opencode v2.0.32');
250
+ child.stderr.end();
251
+ child.emit('close', 0, null);
252
+ });
253
+ return child;
254
+ },
255
+ });
256
+ assert.equal((await pending).stdout, 'opencode v2.0.32');
257
+ assert.equal(childEnv.CTG_RUNTIME_GITHUB_TOKEN, undefined);
167
258
  });
168
259
 
169
260
  test('native slash command conversion validates and preserves template', () => {
@@ -183,6 +274,162 @@ test('native API requests preserve structured responses and reject status failur
183
274
  } finally { globalThis.fetch = original; }
184
275
  });
185
276
 
277
+ test('default packaged OpenCode resolves through Node package exports when hoisted beside CTG', async () => {
278
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-hoisted-resolution-'));
279
+ const packageRoot = path.join(root, 'node_modules', 'carthagent');
280
+ const dependencyRoot = path.join(root, 'node_modules', '@opencode', 'cli');
281
+ try {
282
+ await mkdir(path.join(packageRoot, 'lib', 'opencode'), { recursive: true });
283
+ await mkdir(path.join(dependencyRoot, 'bin'), { recursive: true });
284
+ const runtime = await readFile(new URL('../lib/opencode/runtime.mjs', import.meta.url), 'utf8');
285
+ const artifactModule = await readFile(new URL('../lib/opencode/runtime-artifact.mjs', import.meta.url), 'utf8');
286
+ const transportModule = await readFile(new URL('../lib/opencode/runtime-github-transport.mjs', import.meta.url), 'utf8');
287
+ await writeFile(path.join(packageRoot, 'lib', 'opencode', 'runtime.mjs'), runtime);
288
+ await writeFile(path.join(packageRoot, 'lib', 'opencode', 'runtime-artifact.mjs'), artifactModule);
289
+ await writeFile(path.join(packageRoot, 'lib', 'opencode', 'runtime-github-transport.mjs'), transportModule);
290
+ await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'carthagent', version: '1.0.0', type: 'module' }));
291
+ await writeFile(path.join(dependencyRoot, 'package.json'), JSON.stringify({ name: '@opencode/cli', bin: { opencode: './bin/opencode.exe' }, version: OPENCODE_VERSION }));
292
+ const binary = path.join(dependencyRoot, 'bin', 'opencode.exe');
293
+ await writeFile(binary, 'hoisted package fixture');
294
+ const { pathToFileURL } = await import('node:url');
295
+ const imported = await import(`${pathToFileURL(path.join(packageRoot, 'lib/opencode/runtime.mjs')).href}?test=${Date.now()}`);
296
+ const spawnImpl = (_command, _args, options) => {
297
+ const child = spawn(process.execPath, ['-e', `process.stdout.write('opencode v${OPENCODE_VERSION}')`], options);
298
+ return child;
299
+ };
300
+ assert.equal(await realpath(await imported.resolveOpenCode({ spawnImpl })), await realpath(binary));
301
+ } finally { await rm(root, { recursive: true, force: true }); }
302
+ });
303
+
304
+ test('packaged release manifest automatically acquires the pinned owned runtime and binds identity', async () => {
305
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-release-manifest-'));
306
+ const artifact = path.join(root, 'cache', `${process.platform}-${process.arch}`);
307
+ const manifestPath = path.join(root, 'runtime-release.json');
308
+ const hostPlatform = process.platform === 'win32' ? 'windows' : process.platform;
309
+ const target = `${hostPlatform}-${process.arch}`;
310
+ const binary = path.join(artifact, `cli-${target}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
311
+ const { createHash } = await import('node:crypto');
312
+ const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
313
+ const binaryBytes = Buffer.from('pinned acquired executable');
314
+ const binaryHash = createHash('sha256').update(binaryBytes).digest('hex');
315
+ const platformKeys = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'];
316
+ const assets = Object.fromEntries(platformKeys.map((key) => [key, {
317
+ name: `opencode-${key}.tar.gz`, archiveSha256: 'a'.repeat(64), executableSha256: key === target ? binaryHash : 'b'.repeat(64),
318
+ }]));
319
+ const release = {
320
+ version: 1, releaseVersion: packageMetadata.version, compatibilityVersion: OPENCODE_VERSION,
321
+ runtimeVersion: CTG_RUNTIME_VERSION, sourceRevision: CTG_RUNTIME_SOURCE,
322
+ patchSha256: CTG_RUNTIME_PATCH_SHA256, lockSha256: CTG_RUNTIME_LOCK_SHA256, assets,
323
+ };
324
+ try {
325
+ await mkdir(path.dirname(binary), { recursive: true });
326
+ await writeFile(binary, binaryBytes, { mode: 0o755 });
327
+ await writeFile(path.join(artifact, 'ctg-runtime.json'), JSON.stringify({
328
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
329
+ sourceRevision: CTG_RUNTIME_SOURCE, lockSha256: CTG_RUNTIME_LOCK_SHA256,
330
+ patchSha256: CTG_RUNTIME_PATCH_SHA256, compatibilityVersion: OPENCODE_VERSION,
331
+ runtimeVersion: CTG_RUNTIME_VERSION, channel: `ctg.${OPENCODE_VERSION}`,
332
+ platform: target, webUI: false,
333
+ }));
334
+ await writeFile(path.join(artifact, 'SHA256SUMS'), `${binaryHash} cli-${target}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
335
+ await writeFile(manifestPath, JSON.stringify(release));
336
+ const calls = [];
337
+ const spawnImpl = (command, args, options) => spawn(process.execPath, ['-e', `process.stdout.write('opencode v${CTG_RUNTIME_VERSION}')`], options);
338
+ const options = {
339
+ runtimeReleaseManifestPath: manifestPath,
340
+ runtimeCacheRoot: path.join(root, 'owned-cache'),
341
+ runtimeReleaseBaseUrl: `https://example.test/releases/v${packageMetadata.version}`,
342
+ acquireOwnedRuntime: async (input) => {
343
+ calls.push(input);
344
+ const currentHash = createHash('sha256').update(await readFile(binary)).digest('hex');
345
+ if (currentHash !== input.expectedExecutableSha256) throw new Error('pinned executable SHA-256 mismatch');
346
+ return artifact;
347
+ },
348
+ spawnImpl,
349
+ };
350
+ const resolved = await resolveOpenCode(options);
351
+ assert.equal(resolved, binary);
352
+ assert.equal(calls.length, 1);
353
+ assert.equal(calls[0].releaseVersion, packageMetadata.version);
354
+ assert.equal(calls[0].expectedArchiveSha256, assets[target].archiveSha256);
355
+ assert.equal(calls[0].expectedExecutableSha256, assets[target].executableSha256);
356
+ assert.equal(calls[0].expectedChannel, `ctg.${OPENCODE_VERSION}`);
357
+ // Production ensureService resolves and identifies with one shared options
358
+ // object; automatic selection identity is kept separate from the explicit
359
+ // developer-preview ownedRuntime selector.
360
+ const identity = await runtimeIdentity(resolved, options);
361
+ assert.equal(identity.kind, 'ctg-owned');
362
+ assert.equal(identity.patchSha256, CTG_RUNTIME_PATCH_SHA256);
363
+ assert.equal(options.ownedRuntime, undefined);
364
+
365
+ // A second resolution with the same options cannot trust the mutable
366
+ // adjacent SHA256SUMS as the release digest; the pinned manifest digest is
367
+ // checked again before returning the cached executable.
368
+ const tamperedBytes = Buffer.from('tampered executable');
369
+ const tamperedHash = createHash('sha256').update(tamperedBytes).digest('hex');
370
+ await writeFile(binary, tamperedBytes, { mode: 0o755 });
371
+ await writeFile(path.join(artifact, 'SHA256SUMS'), `${tamperedHash} cli-${target}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
372
+ await assert.rejects(resolveOpenCode(options), /pinned executable SHA-256 mismatch/);
373
+ assert.equal(calls.length, 2);
374
+ } finally { await rm(root, { recursive: true, force: true }); }
375
+ });
376
+
377
+ test('present but invalid runtime release manifest fails closed instead of selecting package CLI', async () => {
378
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-invalid-release-manifest-'));
379
+ const manifestPath = path.join(root, 'runtime-release.json');
380
+ try {
381
+ await writeFile(manifestPath, JSON.stringify({ version: 1, releaseVersion: '1.2.3', assets: {} }));
382
+ await assert.rejects(resolveOpenCode({ runtimeReleaseManifestPath: manifestPath }), /Invalid or incompatible CTG Carthagent runtime release manifest/);
383
+ await assert.rejects(readRuntimeReleaseManifest(manifestPath), /Invalid or incompatible CTG Carthagent runtime release manifest/);
384
+ } finally { await rm(root, { recursive: true, force: true }); }
385
+ });
386
+
387
+ test('incomplete release platform matrix fails closed without acquisition', async () => {
388
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-unsupported-release-target-'));
389
+ const manifestPath = path.join(root, 'runtime-release.json');
390
+ const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
391
+ const keys = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'];
392
+ const assets = Object.fromEntries(keys.map((key) => [key, {
393
+ name: `opencode-${key}.tar.gz`, archiveSha256: 'a'.repeat(64), executableSha256: 'b'.repeat(64),
394
+ }]));
395
+ delete assets[`${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`];
396
+ try {
397
+ await writeFile(manifestPath, JSON.stringify({
398
+ version: 1, releaseVersion: packageMetadata.version,
399
+ compatibilityVersion: OPENCODE_VERSION, runtimeVersion: CTG_RUNTIME_VERSION,
400
+ sourceRevision: CTG_RUNTIME_SOURCE, patchSha256: CTG_RUNTIME_PATCH_SHA256,
401
+ lockSha256: CTG_RUNTIME_LOCK_SHA256, assets,
402
+ }));
403
+ let acquired = false;
404
+ await assert.rejects(resolveOpenCode({
405
+ runtimeReleaseManifestPath: manifestPath,
406
+ acquireOwnedRuntime: async () => { acquired = true; throw new Error('must not acquire'); },
407
+ }), /Invalid or incompatible CTG Carthagent runtime release manifest/);
408
+ assert.equal(acquired, false);
409
+ } finally { await rm(root, { recursive: true, force: true }); }
410
+ });
411
+
412
+ test('missing runtime release manifest keeps the developer-preview package resolver available', async () => {
413
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-missing-release-manifest-'));
414
+ const packageRoot = path.join(root, 'node_modules', 'carthagent');
415
+ const dependencyRoot = path.join(root, 'node_modules', '@opencode', 'cli');
416
+ try {
417
+ await mkdir(path.join(packageRoot, 'lib', 'opencode'), { recursive: true });
418
+ await mkdir(path.join(dependencyRoot, 'bin'), { recursive: true });
419
+ for (const name of ['runtime.mjs', 'runtime-artifact.mjs', 'runtime-github-transport.mjs']) {
420
+ await writeFile(path.join(packageRoot, 'lib', 'opencode', name), await readFile(new URL(`../lib/opencode/${name}`, import.meta.url), 'utf8'));
421
+ }
422
+ await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'carthagent', version: '1.0.0', type: 'module' }));
423
+ await writeFile(path.join(dependencyRoot, 'package.json'), JSON.stringify({ name: '@opencode/cli', version: OPENCODE_VERSION, bin: { opencode: './bin/opencode' } }));
424
+ const binary = path.join(dependencyRoot, 'bin', 'opencode');
425
+ await writeFile(binary, 'preview fixture');
426
+ const { pathToFileURL } = await import('node:url');
427
+ const imported = await import(`${pathToFileURL(path.join(packageRoot, 'lib/opencode/runtime.mjs')).href}?missing=${Date.now()}`);
428
+ const spawnImpl = (_command, _args, options) => spawn(process.execPath, ['-e', `process.stdout.write('opencode v${OPENCODE_VERSION}')`], options);
429
+ assert.equal(await realpath(await imported.resolveOpenCode({ spawnImpl })), await realpath(binary));
430
+ } finally { await rm(root, { recursive: true, force: true }); }
431
+ });
432
+
186
433
  test('session API helpers use the v2 envelope, prompt payload, and endpoint contracts', async () => {
187
434
  const original = globalThis.fetch;
188
435
  const calls = [];
@@ -227,10 +474,16 @@ test('native TUI attaches to the ensured authenticated service', () => {
227
474
  binary: '/ctg/opencode',
228
475
  paths: { root: '/ctg/profile' },
229
476
  };
230
- launchTui(service, '/work', { sessionID: 'ses_123', spawnImpl: (...args) => (captured = args) });
477
+ launchTui(service, '/work', { sessionID: 'ses_123', theme: 'nebula', glyphs: 'ascii', env: { CTG_RUNTIME_GITHUB_TOKEN: 'fake-token', ctg_runtime_github_token: 'fake-lowercase-token' }, spawnImpl: (...args) => (captured = args) });
231
478
  assert.deepEqual(captured[0], '/ctg/opencode');
232
479
  assert.deepEqual(captured[1], ['--server', 'http://127.0.0.1:4567', '/work', '--session', 'ses_123']);
233
480
  assert.equal(captured[2].env.OPENCODE_PASSWORD, 'ctg-test-password');
481
+ assert.deepEqual(JSON.parse(captured[2].env.OPENCODE_CLI_CONFIG_CONTENT).theme, { name: 'nebula', mode: 'system' });
482
+ assert.equal(captured[2].env.OPENCODE_CONFIG_CONTENT, undefined);
483
+ assert.equal(captured[2].env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
484
+ assert.equal(captured[2].env.ctg_runtime_github_token, undefined);
485
+ assert.equal(captured[2].env.CTG_TUI_GLYPHS, 'ascii');
486
+ assert.equal(captured[2].env.CTG_NATIVE_GLYPHS, undefined);
234
487
  assert.equal(captured[2].cwd, '/work');
235
488
  });
236
489
 
@@ -250,6 +503,198 @@ test('a live registered process is retained when health inspection fails', async
250
503
  } finally { globalThis.fetch = original; await rm(root, { recursive: true, force: true }); }
251
504
  });
252
505
 
506
+ test('runtime identity binds compatibility services to the exact executable path and digest', async () => {
507
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-runtime-identity-'));
508
+ const first = path.join(root, 'first');
509
+ const second = path.join(root, 'second');
510
+ try {
511
+ await writeFile(first, 'same compatibility version');
512
+ await writeFile(second, 'same compatibility version, different executable bytes');
513
+ const firstIdentity = await runtimeIdentity(first, { binary: first });
514
+ const secondIdentity = await runtimeIdentity(second, { binary: second });
515
+ assert.equal(firstIdentity.kind, 'upstream-compatible');
516
+ assert.equal(firstIdentity.version, OPENCODE_VERSION);
517
+ assert.notEqual(firstIdentity.binaryPath, secondIdentity.binaryPath);
518
+ assert.notEqual(firstIdentity.binarySha256, secondIdentity.binarySha256);
519
+ } finally { await rm(root, { recursive: true, force: true }); }
520
+ });
521
+
522
+ test('PATH-selected commands resolve to a concrete executable identity', async () => {
523
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-path-identity-'));
524
+ const bin = path.join(root, 'bin');
525
+ const executable = path.join(bin, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
526
+ try {
527
+ await mkdir(bin, { recursive: true });
528
+ await writeFile(executable, 'PATH resolution fixture', { mode: 0o755 });
529
+ const env = { ...process.env };
530
+ for (const key of Object.keys(env)) if (key.toLowerCase() === 'path') delete env[key];
531
+ env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`;
532
+ const invocations = [];
533
+ const spawnImpl = (command, args, options) => {
534
+ invocations.push({ command, args });
535
+ return spawn(process.execPath, ['-e', `process.stdout.write('opencode v${OPENCODE_VERSION}')`], options);
536
+ };
537
+ const resolved = await resolveOpenCode({ binary: 'opencode', env, spawnImpl });
538
+ const canonicalExecutable = await realpath(executable);
539
+ assert.equal(resolved, canonicalExecutable);
540
+ assert.deepEqual(invocations, [{ command: 'opencode', args: ['--version'] }]);
541
+ const identity = await runtimeIdentity('opencode', { binary: 'opencode', env });
542
+ assert.equal(identity.binaryPath, canonicalExecutable);
543
+ } finally { await rm(root, { recursive: true, force: true }); }
544
+ });
545
+
546
+ test('Windows PATH lookup does not append PATHEXT to an already suffixed executable', async () => {
547
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-win-path-'));
548
+ const bin = path.join(root, 'bin');
549
+ try {
550
+ await mkdir(bin);
551
+ const executable = path.join(bin, 'foo.exe');
552
+ await writeFile(executable, 'fixture');
553
+ const extensionless = path.join(bin, 'foo.COM');
554
+ await writeFile(extensionless, 'fixture');
555
+ assert.equal(resolvePathCommand('foo.exe', { PATH: bin, PATHEXT: '.COM;.EXE;.CMD' }, 'win32'), executable);
556
+ assert.equal(resolvePathCommand('foo', { PATH: bin, PATHEXT: '.COM;.EXE;.CMD' }, 'win32'), extensionless);
557
+ } finally { await rm(root, { recursive: true, force: true }); }
558
+ });
559
+
560
+ test('ensureService rejects normal/owned selector switches without stopping the registered process', async () => {
561
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-runtime-selector-switch-'));
562
+ const artifact = path.join(root, 'owned-artifact');
563
+ const binary = path.join(root, 'upstream-opencode');
564
+ const ownedPlatform = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
565
+ const artifactPlatform = ownedPlatform;
566
+ const ownedBinary = path.join(artifact, `cli-${artifactPlatform}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
567
+ const script = path.join(root, 'fixture-runtime.cjs');
568
+ const originalFetch = globalThis.fetch;
569
+ let registeredProcess;
570
+ try {
571
+ await writeFile(script, `if (process.argv[3] === '--version') console.log(process.argv[2].includes('owned-artifact') ? 'opencode v${CTG_RUNTIME_VERSION}' : 'opencode v2.0.32');\n`);
572
+ await writeFile(binary, 'upstream executable fixture');
573
+ await mkdir(path.dirname(ownedBinary), { recursive: true });
574
+ await writeFile(ownedBinary, 'owned executable fixture');
575
+ const { createHash } = await import('node:crypto');
576
+ const ownedHash = createHash('sha256').update(await readFile(ownedBinary)).digest('hex');
577
+ await writeFile(path.join(artifact, 'SHA256SUMS'), `${ownedHash} cli-${artifactPlatform}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
578
+ await writeFile(path.join(artifact, 'ctg-runtime.json'), JSON.stringify({
579
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
580
+ sourceRevision: CTG_RUNTIME_SOURCE, lockSha256: CTG_RUNTIME_LOCK_SHA256, patchSha256: CTG_RUNTIME_PATCH_SHA256,
581
+ compatibilityVersion: OPENCODE_VERSION, runtimeVersion: CTG_RUNTIME_VERSION, channel: 'ctg.2.0.32', platform: ownedPlatform, webUI: false,
582
+ }));
583
+ const spawnImpl = (command, args, options) => spawn(process.execPath, [script, command, ...args], options);
584
+ const upstream = await runtimeIdentity(binary, { binary });
585
+ const owned = await runtimeIdentity(ownedBinary, { ownedRuntime: artifact });
586
+ const paths = runtimePaths({ root: path.join(root, 'profile') });
587
+ await mkdir(path.dirname(paths.serviceFile), { recursive: true });
588
+ registeredProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
589
+ await new Promise((resolve, reject) => {
590
+ registeredProcess.once('spawn', resolve);
591
+ registeredProcess.once('error', reject);
592
+ });
593
+ const registered = { id: 'existing', pid: registeredProcess.pid, url: 'http://127.0.0.1:4567', password: 'fixture', version: upstream.version, runtimeIdentity: upstream };
594
+ await writeFile(paths.serviceFile, JSON.stringify(registered));
595
+ globalThis.fetch = async () => new Response(JSON.stringify({ data: { version: upstream.version, pid: registeredProcess.pid } }));
596
+ for (const selected of [
597
+ { root: paths.root, ownedRuntime: artifact, spawnImpl },
598
+ { root: paths.root, binary: path.join(root, 'same-version-different-binary'), spawnImpl },
599
+ ]) {
600
+ if (selected.binary) await writeFile(selected.binary, 'different upstream executable fixture');
601
+ await assert.rejects(ensureService(selected), /already running runtime .* selected .* active service was left running/);
602
+ assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), registered);
603
+ }
604
+ const ownedRecord = { ...registered, version: owned.version, runtimeIdentity: owned };
605
+ await writeFile(paths.serviceFile, JSON.stringify(ownedRecord));
606
+ globalThis.fetch = async () => new Response(JSON.stringify({ data: { version: owned.version, pid: registeredProcess.pid, capabilities: { shellOrigin: true } } }));
607
+ await assert.rejects(ensureService({ root: paths.root, ownedRuntime: artifact, binary, spawnImpl }), /already running runtime .* selected .* active service was left running/);
608
+ assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), ownedRecord);
609
+ await writeFile(paths.serviceFile, JSON.stringify(ownedRecord));
610
+ globalThis.fetch = async () => new Response(JSON.stringify({ data: { version: owned.version, pid: registeredProcess.pid, capabilities: { shellOrigin: true } } }));
611
+ await assert.rejects(ensureService({ root: paths.root, binary, spawnImpl }), /already running runtime .* selected .* active service was left running/);
612
+ assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), ownedRecord);
613
+
614
+ // A selector mismatch must leave the registered process alive.
615
+ assert.equal(registeredProcess.exitCode, null);
616
+ assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), ownedRecord);
617
+ } finally {
618
+ globalThis.fetch = originalFetch;
619
+ if (registeredProcess?.exitCode === null) registeredProcess.kill('SIGKILL');
620
+ await rm(root, { recursive: true, force: true });
621
+ }
622
+ });
623
+
624
+ test('owned runtime upgrades restart the authenticated service and preserve the profile', async () => {
625
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-owned-upgrade-'));
626
+ const artifact = path.join(root, 'artifact');
627
+ const target = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
628
+ const executable = `cli-${target}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}`;
629
+ const binary = path.join(artifact, executable);
630
+ const script = path.join(root, 'server.cjs');
631
+ const options = { root: path.join(root, 'profile'), ownedRuntime: artifact,
632
+ spawnImpl: (command, args, opts) => spawn(process.execPath, [script, ...args], opts), startupTimeout: 5000 };
633
+ try {
634
+ await mkdir(path.dirname(binary), { recursive: true });
635
+ await writeFile(script, `
636
+ if (process.argv[2] === '--version') { console.log('opencode v${CTG_RUNTIME_VERSION}'); process.exit(0); }
637
+ const http = require('node:http');
638
+ const server = http.createServer((req, res) => {
639
+ if (req.headers.authorization !== 'Basic ' + Buffer.from('opencode:' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64')) { res.writeHead(401); res.end(); return; }
640
+ res.setHeader('content-type', 'application/json');
641
+ res.end(JSON.stringify({version:'${CTG_RUNTIME_VERSION}',pid:process.pid,capabilities:{shellOrigin:true}}));
642
+ });
643
+ server.listen(0, '127.0.0.1', () => console.log('server listening on http://127.0.0.1:' + server.address().port));
644
+ `);
645
+ await writeFile(path.join(artifact, 'ctg-runtime.json'), JSON.stringify({
646
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
647
+ sourceRevision: CTG_RUNTIME_SOURCE, lockSha256: CTG_RUNTIME_LOCK_SHA256, patchSha256: CTG_RUNTIME_PATCH_SHA256,
648
+ compatibilityVersion: OPENCODE_VERSION, runtimeVersion: CTG_RUNTIME_VERSION, channel: 'ctg.2.0.32', platform: target, webUI: false,
649
+ }));
650
+ const { createHash } = await import('node:crypto');
651
+ const install = async (bytes) => {
652
+ await writeFile(binary, bytes);
653
+ await writeFile(path.join(artifact, 'SHA256SUMS'), `${createHash('sha256').update(bytes).digest('hex')} ${executable}\n`);
654
+ };
655
+ await install('owned runtime revision one');
656
+ const first = await ensureService(options);
657
+ assert.equal((await ensureService(options)).pid, first.pid, 'unchanged runtime reuses the service');
658
+ const marker = path.join(first.paths.data, 'preserved-session');
659
+ await writeFile(marker, 'session and credential data must remain');
660
+ await install('owned runtime revision two');
661
+ const second = await ensureService(options);
662
+ assert.notEqual(second.pid, first.pid, 'new runtime replaces the old service without manual stop');
663
+ assert.equal((await ensureService(options)).pid, second.pid);
664
+ assert.equal(await readFile(marker, 'utf8'), 'session and credential data must remain');
665
+ const staleLauncher = { ...second.info, launcherVersion: '0.0.0-old' };
666
+ await writeFile(second.paths.serviceFile, JSON.stringify(staleLauncher));
667
+ const third = await ensureService(options);
668
+ assert.notEqual(third.pid, second.pid, 'launcher/plugin update restarts even when runtime bytes match');
669
+ assert.equal(await readFile(marker, 'utf8'), 'session and credential data must remain');
670
+ await writeFile(third.paths.serviceFile, JSON.stringify({ ...third.info, password: 'incorrect' }));
671
+ await install('owned runtime revision three');
672
+ await assert.rejects(ensureService(options), /left running|unhealthy|cannot be verified/);
673
+ assert.doesNotThrow(() => process.kill(third.pid, 0), 'unverified process must never be signalled');
674
+ await writeFile(third.paths.serviceFile, JSON.stringify(third.info));
675
+ } finally {
676
+ await stopService(options).catch(() => {});
677
+ await rm(root, { recursive: true, force: true });
678
+ }
679
+ });
680
+
681
+ test('dead mismatched registration is cleaned before a replacement service starts', async () => {
682
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-dead-registration-'));
683
+ const paths = runtimePaths({ root });
684
+ const script = path.join(root, 'server.cjs');
685
+ try {
686
+ await writeFile(script, `if (process.argv[2] === '--version') { console.log('2.0.32'); process.exit(0); }\nconst http=require('node:http');const s=http.createServer((q,r)=>{if(q.url==='/api/info'){r.setHeader('content-type','application/json');r.end(JSON.stringify({version:'2.0.32',pid:process.pid}));return}r.statusCode=404;r.end()});s.listen(Number(process.argv[process.argv.indexOf('--port')+1]),'127.0.0.1',()=>console.log('server listening on http://127.0.0.1:'+s.address().port));`);
687
+ await mkdir(path.dirname(paths.serviceFile), { recursive: true });
688
+ await writeFile(paths.serviceFile, JSON.stringify({ id: 'dead-legacy', pid: 2_000_000_000, url: 'http://127.0.0.1:1', version: '2.0.32' }));
689
+ const spawnImpl = (command, args, options) => spawn(process.execPath, [script, ...args], options);
690
+ const service = await ensureService({ root, binary: process.execPath, spawnImpl, startupTimeout: 5000 });
691
+ assert.equal(service.info.runtimeIdentity.kind, 'upstream-compatible');
692
+ assert.equal(service.info.pid, service.pid);
693
+ assert.notEqual(service.pid, 2_000_000_000);
694
+ await stopService({ root });
695
+ } finally { await rm(root, { recursive: true, force: true }); }
696
+ });
697
+
253
698
  test('ensure and stop serialize on the same lifecycle lock', async () => {
254
699
  const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-lifecycle-race-'));
255
700
  const script = path.join(root, 'fake-opencode.cjs');
@@ -260,6 +705,7 @@ test('ensure and stop serialize on the same lifecycle lock', async () => {
260
705
  const options = { root, binary: process.execPath, spawnImpl, startupTimeout: 5000, stopTimeout: 3000 };
261
706
  const [ensured, stopped] = await Promise.all([ensureService(options), stopService(options)]);
262
707
  assert.equal(typeof stopped, 'boolean');
708
+ assert.ok((await readFile(path.join(runtimePaths({ root }).configDir, 'themes', 'carthage.json'), 'utf8')).includes('opencode.ai/theme.json'), 'production service startup provisions the native Carthage theme before host scan');
263
709
  if (ensured?.pid) {
264
710
  // It either remains the correctly registered live service or stop removed it.
265
711
  const current = await readFile(runtimePaths({ root }).serviceFile, 'utf8').then(JSON.parse).catch(() => undefined);
@@ -274,6 +720,40 @@ test('an explicitly selected unusable OpenCode binary does not fall back', async
274
720
  await assert.rejects(resolveOpenCode({ binary: '/missing/ctg-opencode' }), /ENOENT|not found/i);
275
721
  });
276
722
 
723
+ test('owned runtime resolver requires pinned manifest, native platform, checksum, and detached version', async t => {
724
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-owned-runtime-'));
725
+ t.after(() => rm(root, { recursive: true, force: true }));
726
+ const platform = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
727
+ const artifactPlatform = platform;
728
+ const executable = path.join(root, `cli-${artifactPlatform}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
729
+ await mkdir(path.dirname(executable), { recursive: true });
730
+ const fake = path.join(root, 'fixture-runtime.cjs');
731
+ await writeFile(fake, `if (process.argv[2] === '--version') console.log('opencode v${CTG_RUNTIME_VERSION}'); else process.exit(1);\n`);
732
+ await writeFile(executable, 'fixture');
733
+ const { createHash } = await import('node:crypto');
734
+ const checksum = createHash('sha256').update(await readFile(executable)).digest('hex');
735
+ const manifest = {
736
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
737
+ sourceRevision: CTG_RUNTIME_SOURCE, lockSha256: CTG_RUNTIME_LOCK_SHA256, patchSha256: CTG_RUNTIME_PATCH_SHA256,
738
+ compatibilityVersion: OPENCODE_VERSION, runtimeVersion: CTG_RUNTIME_VERSION,
739
+ channel: 'ctg.2.0.32', platform, webUI: false,
740
+ };
741
+ await writeFile(path.join(root, 'ctg-runtime.json'), `${JSON.stringify(manifest)}\n`);
742
+ await writeFile(path.join(root, 'SHA256SUMS'), `${checksum} cli-${artifactPlatform}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
743
+ const spawnImpl = (command, args, options) => spawn(process.execPath, [fake, ...args], options);
744
+ assert.equal(await resolveOwnedOpenCode(root, { spawnImpl }), executable);
745
+ await writeFile(executable, 'modified', { mode: 0o755 });
746
+ await assert.rejects(resolveOwnedOpenCode(root, { spawnImpl }), /SHA-256 mismatch/);
747
+ await writeFile(executable, 'fixture');
748
+ await writeFile(path.join(root, 'SHA256SUMS'), `${checksum} cli-${artifactPlatform}/bin/not-opencode\n`);
749
+ await assert.rejects(resolveOwnedOpenCode(root, { spawnImpl }), /checksum does not list/);
750
+ await writeFile(path.join(root, 'SHA256SUMS'), `${checksum} cli-${artifactPlatform}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
751
+ await writeFile(path.join(root, 'ctg-runtime.json'), `${JSON.stringify({ ...manifest, sourceRevision: 'unpinned' })}\n`);
752
+ await assert.rejects(resolveOwnedOpenCode(root, { spawnImpl }), /not compatible/);
753
+ await writeFile(path.join(root, 'ctg-runtime.json'), `${JSON.stringify({ ...manifest, platform: 'other-platform' })}\n`);
754
+ await assert.rejects(resolveOwnedOpenCode(root, { spawnImpl }), /does not match this host/);
755
+ });
756
+
277
757
  test('headless runner passes workspace environment and returns assistant content and status', async () => {
278
758
  const original = globalThis.fetch;
279
759
  const calls = [];
@@ -453,3 +933,61 @@ test('headless reports timeout distinctly when no terminal outcome can be observ
453
933
  assert.equal(result.budgetReason, 'timed-out');
454
934
  } finally { globalThis.fetch = original; }
455
935
  });
936
+
937
+ test('accepted timeout does not inherit the previous session outcome and keeps the admitted turn', async () => {
938
+ const original = globalThis.fetch;
939
+ try {
940
+ globalThis.fetch = async (url, init = {}) => {
941
+ const pathname = new URL(url).pathname;
942
+ if (pathname.endsWith('/environment')) return new Response(null, { status: 204 });
943
+ if (pathname.endsWith('/prompt')) return new Response('{"data":{"id":"inbox_new","messageID":"user-new"}}');
944
+ if (pathname.endsWith('/wait')) return pendingUntilAbort(init.signal);
945
+ if (pathname.endsWith('/interrupt')) return new Response(null, { status: 204 });
946
+ if (pathname === '/api/session/ses_previous') return new Response('{"data":{"id":"ses_previous","outcome":"succeeded"}}');
947
+ if (pathname.endsWith('/message')) return new Response(JSON.stringify({ data: [
948
+ { id: 'assistant-old', type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'stale prior result' }], time: { created: 1 } },
949
+ { id: 'user-new', type: 'user', time: { created: 2 } },
950
+ { id: 'assistant-new', type: 'assistant', finish: 'tool-calls', parts: [{ type: 'tool', callID: 'tool-new' }], time: { created: 3 } },
951
+ ] }));
952
+ throw new Error(`Unexpected endpoint ${pathname}`);
953
+ };
954
+ const controller = new AbortController();
955
+ const resultPromise = runHeadless({ url: 'http://127.0.0.1:3333', headers: {} }, '/workspace', 'new turn', { sessionID: 'ses_previous', signal: controller.signal });
956
+ await new Promise(resolve => setTimeout(resolve, 20));
957
+ controller.abort(new Error('test timeout/cancel'));
958
+ const result = await resultPromise;
959
+ assert.equal(result.session.id, 'ses_previous');
960
+ assert.equal(result.outcome, 'interrupted');
961
+ assert.equal(result.terminal, false, 'prior successful session state is not terminal for this admission');
962
+ assert.deepEqual(result.messages.map(message => message.id), ['user-new', 'assistant-new']);
963
+ assert.notEqual(result.result?.text, 'stale prior result', 'previous assistant response is never reused');
964
+ } finally { globalThis.fetch = original; }
965
+ });
966
+
967
+
968
+ test('runtime liveness treats an unreaped Linux zombie as stopped but preserves live processes', { skip: process.platform !== 'linux' }, async t => {
969
+ const { once } = await import('node:events');
970
+ const child = spawn('python3', ['-c', `import os,time
971
+ pid=os.fork()
972
+ if pid == 0: os._exit(0)
973
+ print(pid,flush=True)
974
+ time.sleep(30)`]);
975
+ t.after(() => child.kill());
976
+ let output = '';
977
+ const zombie = await new Promise((resolvePID, reject) => {
978
+ child.stdout.on('data', chunk => { output += chunk; if (output.includes('\n')) resolvePID(Number(output.trim())); });
979
+ child.once('error', reject);
980
+ });
981
+ for (let i = 0; i < 100; i++) {
982
+ const status = await readFile(`/proc/${zombie}/stat`, 'utf8');
983
+ if (/\) Z /.test(status)) break;
984
+ await new Promise(resolveDelay => setTimeout(resolveDelay, 10));
985
+ }
986
+ assert.match(await readFile(`/proc/${zombie}/stat`, 'utf8'), /\) Z /);
987
+ assert.equal(processAlive(process.pid), true);
988
+ assert.equal(processAlive(child.pid), true);
989
+ assert.equal(processAlive(zombie), false);
990
+ const exited = once(child, 'exit');
991
+ child.kill();
992
+ await exited;
993
+ });