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
@@ -94,6 +94,21 @@ test('persisted per-session launcher settings control framing without affecting
94
94
  assert.notEqual(event.prompt.text, 'Implement a parser');
95
95
  });
96
96
 
97
+ test('explicit framing RPC override wins over persisted launch guide setting for only that session', async () => {
98
+ const f = fixture({}, {
99
+ a: { metadata: { 'carthagent.options': { guide: true } } },
100
+ b: { metadata: { 'carthagent.options': { guide: true } } },
101
+ });
102
+ await registerSessionControls(f.ctx);
103
+ await f.handlers.framing({ sessionID: 'a', enabled: false }, call());
104
+ const a = { sessionID: 'a', prompt: { text: 'Implement a parser' } };
105
+ const b = { sessionID: 'b', prompt: { text: 'Implement a parser' } };
106
+ await f.hooks.prompt(a);
107
+ await f.hooks.prompt(b);
108
+ assert.equal(a.prompt.text, 'Implement a parser');
109
+ assert.notEqual(b.prompt.text, 'Implement a parser');
110
+ });
111
+
97
112
  test('queued controls recheck ownership after a session moves', async () => {
98
113
  const f = fixture();
99
114
  let releaseWrite;
@@ -0,0 +1,71 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ const jiti = (await import('jiti')).createJiti(import.meta.url);
4
+ const { createShipRpcHandlers: createHandlers } = await jiti.import('../plugins/carthagent/src/server/ship-rpc.ts');
5
+ const { CloudClient } = await import('../lib/cloud/client.mjs');
6
+
7
+ test('profile Ship RPC uses resolved native OAuth and loopback account, billing, and revoke endpoints', async () => {
8
+ const requests = [];
9
+ const server = await import('node:http').then(({ createServer }) => createServer(async (request, response) => {
10
+ let body = '';
11
+ for await (const chunk of request) body += chunk;
12
+ requests.push({ method: request.method, url: request.url, authorization: request.headers.authorization, body: body ? JSON.parse(body) : undefined });
13
+ const payload = request.url === '/v1/account' ? { account: { id: 'acct-loopback' }, grants: [], sessions: [] }
14
+ : request.url === '/v1/account/usage?limit=25' ? { data: [{ alias: 'loopback-model', inputTokens: 5, outputTokens: 3 }] }
15
+ : request.url === '/v1/billing/checkout' ? { url: 'https://billing.test/checkout' }
16
+ : request.url === '/v1/billing/portal' ? { url: 'https://billing.test/portal' }
17
+ : {};
18
+ response.writeHead(200, { 'content-type': 'application/json' });
19
+ response.end(JSON.stringify(payload));
20
+ }));
21
+ server.listen(0, '127.0.0.1');
22
+ await new Promise(resolve => server.once('listening', resolve));
23
+ try {
24
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
25
+ const removed = [];
26
+ const ctx = { integration: { connection: {
27
+ active: async id => { assert.equal(id, 'experiential-labs'); return { type: 'credential', method: 'oauth', id: 'cred-profile' }; },
28
+ resolve: async () => ({ type: 'oauth', methodID: 'carthagent-device', access: 'loopback-token' }),
29
+ }, transform: async fn => fn({ remove: id => removed.push(id) }) } };
30
+ const handlers = createHandlers(ctx, { baseUrl, cloudUrl: baseUrl, cloudClient: new CloudClient({ baseUrl }) });
31
+ const signal = new AbortController().signal;
32
+ assert.deepEqual(await handlers.shipAccount({}, { signal }), { account: { account: { id: 'acct-loopback' }, grants: [], sessions: [] } });
33
+ assert.deepEqual(await handlers.shipUsage({ limit: 25 }, { signal }), { usage: { data: [{ alias: 'loopback-model', inputTokens: 5, outputTokens: 3 }] } });
34
+ assert.deepEqual(await handlers.shipBilling({ action: 'checkout' }, { signal }), { url: 'https://billing.test/checkout' });
35
+ assert.deepEqual(await handlers.shipBilling({ action: 'portal' }, { signal }), { url: 'https://billing.test/portal' });
36
+ assert.deepEqual(await handlers.shipLogout({ session: 'session-target' }, { signal }), { revoked: true });
37
+ assert.deepEqual(await handlers.shipLogout({}, { signal }), { revoked: true, credentialID: 'cred-profile' });
38
+ assert.deepEqual(requests.map(item => [item.method, item.url]), [
39
+ ['GET', '/v1/account'], ['GET', '/v1/account/usage?limit=25'], ['POST', '/v1/billing/checkout'], ['POST', '/v1/billing/portal'],
40
+ ['DELETE', '/v1/account/sessions/session-target'], ['POST', '/v1/token/revoke'],
41
+ ]);
42
+ assert.ok(requests.every(item => item.authorization === 'Bearer loopback-token'));
43
+ assert.deepEqual(requests[2].body, { successUrl: `${baseUrl}/account`, cancelUrl: `${baseUrl}/account` });
44
+ assert.deepEqual(requests[3].body, { returnUrl: `${baseUrl}/account` });
45
+ assert.deepEqual(removed, ['cred-profile']);
46
+ } finally {
47
+ server.closeAllConnections();
48
+ await new Promise(resolve => server.close(resolve));
49
+ }
50
+ });
51
+
52
+ test('profile Ship RPC rejects BYOK, absent auth, and pre-aborted operations without network calls', async () => {
53
+ let requests = 0;
54
+ const cloudClient = { async account() { requests++; }, async checkout() { requests++; }, async portal() { requests++; }, async revokeCurrent() { requests++; }, async revokeSession() { requests++; } };
55
+ const rpcCall = signal => ({
56
+ signal,
57
+ error: (code, message, data) => { throw Object.assign(new Error(message), { type: `ship.${code}`, data }); },
58
+ });
59
+ for (const connection of [undefined, { type: 'credential', method: 'key', id: 'key' }]) {
60
+ const handlers = createHandlers({ integration: { connection: {
61
+ active: async () => connection,
62
+ resolve: async () => connection ? { type: 'key', key: 'byok' } : undefined,
63
+ } } }, { cloudClient });
64
+ await assert.rejects(handlers.shipAccount({}, rpcCall(new AbortController().signal)), /active Carthagent Ship OAuth/);
65
+ }
66
+ const handlers = createHandlers({ integration: { connection: { active: async () => undefined, resolve: async () => undefined } } }, { cloudClient });
67
+ const controller = new AbortController();
68
+ controller.abort(new Error('cancelled before request'));
69
+ await assert.rejects(handlers.shipAccount({}, rpcCall(controller.signal)), /cancelled before request/);
70
+ assert.equal(requests, 0);
71
+ });
@@ -0,0 +1,475 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync, spawn } from 'node:child_process';
4
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { createServer } from 'node:http';
8
+ import { once } from 'node:events';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { readUpdateChannel, writeUpdateChannel } from '../lib/update-channel.mjs';
11
+ import { npmUpdateArgv, runNativeUpdate } from '../lib/opencode/update.mjs';
12
+
13
+ const release = { version: '1.2.0', tag: 'v1.2.0', installSpec: 'github:B-icy/Carthagent#v1.2.0', minNode: '22.19.0' };
14
+ const noopIo = { log() {}, error() {} };
15
+
16
+ function tempDir(fn) {
17
+ const dir = mkdtempSync(join(tmpdir(), 'ctg-native-update-'));
18
+ return Promise.resolve().then(() => fn(dir)).finally(() => rmSync(dir, { recursive: true, force: true }));
19
+ }
20
+
21
+ function git(args, cwd) {
22
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
23
+ }
24
+
25
+ test('check-only resolves availability but does not inspect or install anything', async () => {
26
+ let commands = 0;
27
+ const result = await runNativeUpdate({
28
+ args: ['--check'], io: noopIo, root: '/does/not/matter', currentVersion: '1.0.0',
29
+ fetchLatestRelease: async () => ({ release }),
30
+ detectInstallMethod: () => { throw new Error('should not inspect install'); },
31
+ runCommand: async () => { commands++; },
32
+ });
33
+ assert.equal(result.status, 'available');
34
+ assert.equal(commands, 0);
35
+ });
36
+
37
+ test('rejects an invalid canonical tag before invoking an installer', async () => {
38
+ let calls = 0;
39
+ await assert.rejects(runNativeUpdate({
40
+ io: noopIo, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release: { ...release, tag: 'v1.2.0; npm evil' } }),
41
+ detectInstallMethod: () => 'npm', runCommand: async () => { calls++; },
42
+ }), /invalid or mismatched/);
43
+ assert.equal(calls, 0);
44
+ });
45
+
46
+ test('force remains bound to the validated version and command', async () => {
47
+ const calls = [];
48
+ await runNativeUpdate({
49
+ args: ['--force'], io: noopIo, currentVersion: '1.3.0',
50
+ fetchLatestRelease: async () => ({ release }), detectInstallMethod: () => 'npm',
51
+ platform: 'linux',
52
+ runCommand: async (argv, options) => { calls.push({ argv, options }); return { code: 0, output: '' }; },
53
+ });
54
+ assert.deepEqual(calls.map(call => call.argv), [
55
+ ['npm', 'view', `carthagent@${release.version}`, 'version', '--json'],
56
+ ['npm', 'install', '-g', '--install-links=true', `carthagent@${release.version}`],
57
+ ]);
58
+ });
59
+
60
+ test('npm update falls back to the validated GitHub spec when npm view fails', async () => {
61
+ const calls = [];
62
+ await runNativeUpdate({
63
+ io: noopIo, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
64
+ detectInstallMethod: () => 'npm', platform: 'linux',
65
+ runCommand: async (argv, options) => {
66
+ calls.push({ argv, options });
67
+ return argv[1] === 'view' ? { code: 1, output: 'E404' } : { code: 0, output: '' };
68
+ },
69
+ });
70
+ assert.deepEqual(calls.map(call => call.argv), [
71
+ ['npm', 'view', `carthagent@${release.version}`, 'version', '--json'],
72
+ ['npm', 'install', '-g', '--install-links=true', release.installSpec],
73
+ ]);
74
+ assert.equal(calls[0].options.timeoutSeconds, 10);
75
+ });
76
+
77
+ test('clone update does not query the registry', async () => {
78
+ const calls = [];
79
+ await runNativeUpdate({
80
+ io: noopIo, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
81
+ detectInstallMethod: () => 'clone', platform: 'linux',
82
+ runCommand: async argv => {
83
+ calls.push(argv);
84
+ if (argv[1] === 'status') return { code: 0, output: '' };
85
+ if (argv[1] === 'fetch') return { code: 0, output: '' };
86
+ if (argv[1] === 'rev-parse') return { code: 0, output: `${'a'.repeat(40)}\n` };
87
+ if (argv[1] === 'checkout') return { code: 0, output: '' };
88
+ if (argv[0] === 'npm') return { code: 0, output: '' };
89
+ throw new Error(`unexpected command ${argv.join(' ')}`);
90
+ },
91
+ });
92
+ assert.equal(calls.some(argv => argv[0] === 'npm' && argv[1] === 'view'), false);
93
+ });
94
+
95
+ test('clone update refuses dirty changes and preserves the dirty file', () => tempDir(async base => {
96
+ const remote = join(base, 'remote.git');
97
+ const seed = join(base, 'seed');
98
+ const clone = join(base, 'clone');
99
+ mkdirSync(seed);
100
+ git(['init', '--bare', remote], base);
101
+ git(['init', '-b', 'main'], seed);
102
+ git(['config', 'user.email', 'test@example.invalid'], seed);
103
+ git(['config', 'user.name', 'Test'], seed);
104
+ writeFileSync(join(seed, 'tracked.txt'), 'base\n');
105
+ git(['add', 'tracked.txt'], seed);
106
+ git(['commit', '-m', 'base'], seed);
107
+ git(['tag', 'v1.2.0'], seed);
108
+ git(['remote', 'add', 'origin', remote], seed);
109
+ git(['push', 'origin', 'main', '--tags'], seed);
110
+ git(['symbolic-ref', 'HEAD', 'refs/heads/main'], remote);
111
+ git(['clone', remote, clone], base);
112
+ git(['config', 'user.email', 'test@example.invalid'], clone);
113
+ git(['config', 'user.name', 'Test'], clone);
114
+ writeFileSync(join(clone, 'tracked.txt'), 'user modification\n');
115
+ writeFileSync(join(clone, 'untracked-user-data.txt'), 'user data\n');
116
+ let commands = 0;
117
+ await assert.rejects(runNativeUpdate({
118
+ io: noopIo, root: clone, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
119
+ detectInstallMethod: () => 'clone', runCommand: async (...args) => { commands++; return (await import('../lib/opencode/update.mjs')).runUpdateCommand(...args); },
120
+ }), /uncommitted changes/);
121
+ assert.equal(commands, 1);
122
+ assert.equal(readFileSync(join(clone, 'tracked.txt'), 'utf8'), 'user modification\n');
123
+ assert.equal(readFileSync(join(clone, 'untracked-user-data.txt'), 'utf8'), 'user data\n');
124
+ }));
125
+
126
+ test('clone update fetches without force and checks out the exact release tag', () => tempDir(async base => {
127
+ const remote = join(base, 'remote.git');
128
+ const seed = join(base, 'seed');
129
+ const clone = join(base, 'clone');
130
+ mkdirSync(seed);
131
+ git(['init', '--bare', remote], base);
132
+ git(['init', '-b', 'main'], seed);
133
+ git(['config', 'user.email', 'test@example.invalid'], seed);
134
+ git(['config', 'user.name', 'Test'], seed);
135
+ writeFileSync(join(seed, 'tracked.txt'), 'base\n');
136
+ git(['add', 'tracked.txt'], seed);
137
+ git(['commit', '-m', 'base'], seed);
138
+ git(['tag', 'v1.2.0'], seed);
139
+ git(['remote', 'add', 'origin', remote], seed);
140
+ git(['push', 'origin', 'main', '--tags'], seed);
141
+ git(['clone', remote, clone], base);
142
+ const commands = [];
143
+ const result = await runNativeUpdate({
144
+ io: noopIo, root: clone, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
145
+ detectInstallMethod: () => 'clone', runCommand: async (argv, options) => {
146
+ commands.push(argv);
147
+ if (argv[0] === 'git') return (await import('../lib/opencode/update.mjs')).runUpdateCommand(argv, options);
148
+ return { code: 0, output: '' };
149
+ },
150
+ });
151
+ assert.equal(result.status, 'updated');
152
+ assert.deepEqual(commands[0], ['git', 'status', '--porcelain']);
153
+ assert.deepEqual(commands[1].slice(0, 4), ['git', 'fetch', '--no-tags', 'origin']);
154
+ assert.equal(commands[1][4].startsWith('refs/tags/v1.2.0:'), true);
155
+ assert.deepEqual(commands[2].slice(0, 3), ['git', 'rev-parse', '--verify']);
156
+ assert.deepEqual(commands[3].slice(0, 3), ['git', 'checkout', '--detach']);
157
+ assert.equal(git(['describe', '--tags', '--exact-match'], clone), 'v1.2.0');
158
+ assert.equal(git(['status', '--porcelain'], clone), '');
159
+ }));
160
+
161
+ test('clone update uses the origin tag commit when a conflicting local tag exists and preserves that tag', () => tempDir(async base => {
162
+ const remote = join(base, 'remote.git');
163
+ const seed = join(base, 'seed');
164
+ const clone = join(base, 'clone');
165
+ mkdirSync(seed);
166
+ git(['init', '--bare', remote], base);
167
+ git(['init', '-b', 'main'], seed);
168
+ git(['config', 'user.email', 'test@example.invalid'], seed);
169
+ git(['config', 'user.name', 'Test'], seed);
170
+ writeFileSync(join(seed, 'tracked.txt'), 'remote release\n');
171
+ git(['add', 'tracked.txt'], seed);
172
+ git(['commit', '-m', 'release'], seed);
173
+ const remoteCommit = git(['rev-parse', 'HEAD'], seed);
174
+ git(['tag', 'v1.2.0'], seed);
175
+ git(['remote', 'add', 'origin', remote], seed);
176
+ git(['push', 'origin', 'main', '--tags'], seed);
177
+ git(['symbolic-ref', 'HEAD', 'refs/heads/main'], remote);
178
+ git(['clone', remote, clone], base);
179
+ git(['config', 'user.email', 'test@example.invalid'], clone);
180
+ git(['config', 'user.name', 'Test'], clone);
181
+ writeFileSync(join(clone, 'other.txt'), 'local tag target\n');
182
+ git(['add', 'other.txt'], clone);
183
+ git(['commit', '-m', 'local tag target'], clone);
184
+ const localTagCommit = git(['rev-parse', 'HEAD'], clone);
185
+ git(['tag', '-f', 'v1.2.0', localTagCommit], clone);
186
+ const calls = [];
187
+ await runNativeUpdate({
188
+ io: noopIo, root: clone, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
189
+ detectInstallMethod: () => 'clone', runCommand: async (argv, options) => {
190
+ calls.push(argv);
191
+ return argv[0] === 'git' ? (await import('../lib/opencode/update.mjs')).runUpdateCommand(argv, options) : { code: 0, output: '' };
192
+ },
193
+ });
194
+ assert.equal(git(['rev-parse', 'HEAD'], clone), remoteCommit);
195
+ assert.equal(git(['rev-parse', 'refs/tags/v1.2.0^{commit}'], clone), localTagCommit);
196
+ assert.equal(calls.some(argv => argv[0] === 'git' && argv[1] === 'fetch' && argv.includes('--force')), false);
197
+ assert.equal(calls.some(argv => argv[0] === 'git' && argv.some(arg => arg.startsWith('refs/tags/v1.2.0:'))), true);
198
+ }));
199
+
200
+ test('clone update rejects an origin-missing release tag without checking out a local tag', () => tempDir(async base => {
201
+ const remote = join(base, 'remote.git');
202
+ const seed = join(base, 'seed');
203
+ const clone = join(base, 'clone');
204
+ mkdirSync(seed);
205
+ git(['init', '--bare', remote], base);
206
+ git(['init', '-b', 'main'], seed);
207
+ git(['config', 'user.email', 'test@example.invalid'], seed);
208
+ git(['config', 'user.name', 'Test'], seed);
209
+ writeFileSync(join(seed, 'tracked.txt'), 'remote main\n');
210
+ git(['add', 'tracked.txt'], seed);
211
+ git(['commit', '-m', 'main'], seed);
212
+ git(['remote', 'add', 'origin', remote], seed);
213
+ git(['push', 'origin', 'main'], seed);
214
+ git(['symbolic-ref', 'HEAD', 'refs/heads/main'], remote);
215
+ git(['clone', remote, clone], base);
216
+ git(['tag', 'v1.2.0'], clone);
217
+ const head = git(['rev-parse', 'HEAD'], clone);
218
+ const calls = [];
219
+ await assert.rejects(runNativeUpdate({
220
+ io: noopIo, root: clone, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
221
+ detectInstallMethod: () => 'clone', runCommand: async (argv, options) => {
222
+ calls.push(argv);
223
+ return argv[0] === 'git' ? (await import('../lib/opencode/update.mjs')).runUpdateCommand(argv, options) : { code: 0, output: '' };
224
+ },
225
+ }), /git fetch v1\.2\.0 failed/);
226
+ assert.equal(git(['rev-parse', 'HEAD'], clone), head);
227
+ assert.equal(calls.every(argv => argv[0] === 'git'), true);
228
+ }));
229
+
230
+ test('clone update still removes its private ref after fetch cancellation', async () => {
231
+ const controller = new AbortController();
232
+ const calls = [];
233
+ await assert.rejects(runNativeUpdate({
234
+ io: noopIo, root: '/fixture/clone', currentVersion: '1.0.0', signal: controller.signal,
235
+ fetchLatestRelease: async () => ({ release }), detectInstallMethod: () => 'clone',
236
+ runCommand: async (argv, options) => {
237
+ calls.push({ argv, options });
238
+ if (argv[1] === 'fetch') {
239
+ controller.abort(new Error('cancelled during fetch'));
240
+ return { code: 1, output: '', cancelled: true };
241
+ }
242
+ if (argv[1] === 'status') return { code: 0, output: '' };
243
+ if (argv[1] === 'update-ref') return { code: 0, output: '' };
244
+ throw new Error(`unexpected command ${argv.join(' ')}`);
245
+ },
246
+ }), /git fetch v1\.2\.0 failed \(cancelled\)/);
247
+ const cleanup = calls.find(call => call.argv[1] === 'update-ref');
248
+ assert.ok(cleanup, 'best-effort cleanup is attempted after cancelled fetch');
249
+ assert.equal(cleanup.options.signal, undefined, 'cleanup is not suppressed by the aborted lifecycle signal');
250
+
251
+ await assert.rejects(runNativeUpdate({
252
+ io: noopIo, root: '/fixture/clone', currentVersion: '1.0.0',
253
+ fetchLatestRelease: async () => ({ release }), detectInstallMethod: () => 'clone',
254
+ runCommand: async argv => {
255
+ if (argv[1] === 'status') return { code: 0, output: '' };
256
+ if (argv[1] === 'fetch') throw new Error('fetch fixture failure');
257
+ if (argv[1] === 'update-ref') throw new Error('cleanup fixture failure');
258
+ throw new Error(`unexpected command ${argv.join(' ')}`);
259
+ },
260
+ }), /fetch fixture failure/);
261
+ });
262
+
263
+ test('clone update removes a real fetched private ref after cancellation', () => tempDir(async base => {
264
+ const remote = join(base, 'remote.git');
265
+ const seed = join(base, 'seed');
266
+ const clone = join(base, 'clone');
267
+ mkdirSync(seed);
268
+ git(['init', '--bare', remote], base);
269
+ git(['init', '-b', 'main'], seed);
270
+ git(['config', 'user.email', 'test@example.invalid'], seed);
271
+ git(['config', 'user.name', 'Test'], seed);
272
+ writeFileSync(join(seed, 'tracked.txt'), 'release\n');
273
+ git(['add', 'tracked.txt'], seed);
274
+ git(['commit', '-m', 'release'], seed);
275
+ git(['tag', 'v1.2.0'], seed);
276
+ git(['remote', 'add', 'origin', remote], seed);
277
+ git(['push', 'origin', 'main', '--tags'], seed);
278
+ git(['symbolic-ref', 'HEAD', 'refs/heads/main'], remote);
279
+ git(['clone', remote, clone], base);
280
+
281
+ const controller = new AbortController();
282
+ const commands = [];
283
+ await assert.rejects(runNativeUpdate({
284
+ io: noopIo, root: clone, currentVersion: '1.0.0', signal: controller.signal,
285
+ fetchLatestRelease: async () => ({ release }), detectInstallMethod: () => 'clone',
286
+ runCommand: async (argv, options) => {
287
+ commands.push({ argv, options });
288
+ const result = await (await import('../lib/opencode/update.mjs')).runUpdateCommand(argv, options);
289
+ if (argv[1] === 'fetch') {
290
+ assert.equal(result.code, 0, result.output);
291
+ assert.match(argv[4], /^refs\/tags\/v1\.2\.0:refs\/opencode-update\/[a-f0-9-]+$/);
292
+ controller.abort(new Error('cancelled immediately after fetch'));
293
+ return { ...result, code: 1, cancelled: true };
294
+ }
295
+ return result;
296
+ },
297
+ }), /git fetch v1\.2\.0 failed \(cancelled\)/);
298
+
299
+ const fetch = commands.find(command => command.argv[1] === 'fetch');
300
+ const cleanup = commands.find(command => command.argv[1] === 'update-ref');
301
+ assert.ok(fetch);
302
+ assert.ok(cleanup);
303
+ assert.equal(cleanup.options.signal, undefined);
304
+ assert.equal(cleanup.options.timeoutSeconds, 30);
305
+ assert.equal(cleanup.argv[2], '-d');
306
+ assert.equal(cleanup.argv[3], fetch.argv[4].split(':')[1]);
307
+ assert.equal(git(['for-each-ref', '--format=%(refname)', 'refs/opencode-update/'], clone), '');
308
+ }));
309
+
310
+ test('failed update command stops later steps and includes bounded failure output', async () => {
311
+ const calls = [];
312
+ await assert.rejects(runNativeUpdate({
313
+ io: noopIo, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
314
+ detectInstallMethod: () => 'npm', platform: 'linux', runCommand: async argv => {
315
+ calls.push(argv);
316
+ return argv[1] === 'view' ? { code: 0, output: '"1.2.0"' } : { code: 2, output: 'npm failure' };
317
+ },
318
+ }), /npm install .* failed:\nnpm failure/);
319
+ assert.equal(calls.length, 2);
320
+ assert.deepEqual(calls[1], ['npm', 'install', '-g', '--install-links=true', 'carthagent@1.2.0']);
321
+ });
322
+
323
+ test('runUpdateCommand strips runtime download tokens from child env case-insensitively without mutating caller env', async () => {
324
+ const env = {
325
+ ...process.env,
326
+ CTG_RUNTIME_GITHUB_TOKEN: 'fake-uppercase-token',
327
+ ctg_runtime_github_token: 'fake-lowercase-token',
328
+ Ctg_Runtime_Github_Token: 'fake-mixedcase-token',
329
+ CTG_RUNTIME_OTHER: 'preserve-this-value',
330
+ };
331
+ const result = await (await import('../lib/opencode/update.mjs')).runUpdateCommand(
332
+ [process.execPath, '-e', 'process.stdout.write(JSON.stringify({env: process.env.CTG_RUNTIME_OTHER, tokenKeys: Object.keys(process.env).filter(key => key.toUpperCase() === "CTG_RUNTIME_GITHUB_TOKEN")}))'],
333
+ { cwd: process.cwd(), env, timeoutSeconds: 10 },
334
+ );
335
+ assert.equal(result.code, 0);
336
+ assert.deepEqual(JSON.parse(result.output), { env: 'preserve-this-value', tokenKeys: [] });
337
+ assert.equal(env.ctg_runtime_github_token, 'fake-lowercase-token');
338
+ });
339
+
340
+ test('Windows cancellation sanitizes taskkill environment and handles taskkill spawn errors', async () => {
341
+ const { EventEmitter } = await import('node:events');
342
+ const controller = new AbortController();
343
+ const calls = [];
344
+ const env = {
345
+ PATH: process.env.PATH,
346
+ CTG_RUNTIME_GITHUB_TOKEN: 'fake-uppercase-token',
347
+ ctg_runtime_github_token: 'fake-lowercase-token',
348
+ Ctg_Runtime_Github_Token: 'fake-mixedcase-token',
349
+ CTG_RUNTIME_OTHER: 'preserve-this-value',
350
+ };
351
+ const spawnImpl = (command, args, options) => {
352
+ calls.push({ command, args, options });
353
+ const child = new EventEmitter();
354
+ child.stdout = new EventEmitter();
355
+ child.stderr = new EventEmitter();
356
+ child.pid = command === 'taskkill' ? 9999 : 4242;
357
+ child.kill = () => { if (command !== 'taskkill') queueMicrotask(() => child.emit('close', 1)); };
358
+ child.unref = () => {};
359
+ if (command === 'taskkill') queueMicrotask(() => child.emit('error', new Error('taskkill unavailable')));
360
+ return child;
361
+ };
362
+ const pending = (await import('../lib/opencode/update.mjs')).runUpdateCommand(
363
+ ['fixture-updater'], { cwd: process.cwd(), env, signal: controller.signal, platform: 'win32', spawnImpl, timeoutSeconds: 10 },
364
+ );
365
+ controller.abort(new Error('cancel test'));
366
+ const result = await pending;
367
+ await new Promise(resolve => setImmediate(resolve));
368
+ assert.equal(result.cancelled, true);
369
+ assert.deepEqual(calls.map(call => call.command), ['fixture-updater', 'taskkill']);
370
+ assert.equal(calls[0].options.detached, false);
371
+ assert.equal(calls[1].options.env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
372
+ assert.equal(calls[1].options.env.ctg_runtime_github_token, undefined);
373
+ assert.equal(calls[1].options.env.Ctg_Runtime_Github_Token, undefined);
374
+ assert.equal(calls[1].options.env.CTG_RUNTIME_OTHER, 'preserve-this-value');
375
+ assert.equal(env.Ctg_Runtime_Github_Token, 'fake-mixedcase-token');
376
+ });
377
+
378
+ test('zero exit code with timeout or cancellation is failure and prevents later steps', async () => {
379
+ for (const result of [{ code: 0, output: '', timedOut: true }, { code: 0, output: '', cancelled: true }]) {
380
+ const calls = [];
381
+ await assert.rejects(runNativeUpdate({
382
+ io: noopIo, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
383
+ detectInstallMethod: () => 'npm', platform: 'linux', runCommand: async argv => {
384
+ calls.push(argv);
385
+ return argv[1] === 'view' ? { code: 0, output: '"1.2.0"' } : result;
386
+ },
387
+ }), result.timedOut ? /timed out/ : /cancelled/);
388
+ assert.equal(calls.length, 2);
389
+ assert.equal(calls[1][1], 'install');
390
+ }
391
+ });
392
+
393
+ test('an already-aborted lifecycle never checks releases or starts an installer', async () => {
394
+ const controller = new AbortController();
395
+ controller.abort(new Error('plugin disposed'));
396
+ let fetched = false;
397
+ let installed = false;
398
+ await assert.rejects(runNativeUpdate({
399
+ signal: controller.signal,
400
+ fetchLatestRelease: async () => { fetched = true; return { release }; },
401
+ detectInstallMethod: () => 'npm',
402
+ runCommand: async () => { installed = true; return { code: 0, output: '' }; },
403
+ }), /plugin disposed/);
404
+ assert.equal(fetched, false);
405
+ assert.equal(installed, false);
406
+ });
407
+
408
+ test('runUpdateCommand kills the updater process tree when its host lifecycle aborts', async () => {
409
+ const controller = new AbortController();
410
+ const pending = (await import('../lib/opencode/update.mjs')).runUpdateCommand(
411
+ [process.execPath, '-e', 'setTimeout(() => {}, 30000)'],
412
+ { cwd: process.cwd(), timeoutSeconds: 60, signal: controller.signal },
413
+ );
414
+ setTimeout(() => controller.abort(new Error('plugin disposed')), 40).unref?.();
415
+ const result = await pending;
416
+ assert.equal(result.cancelled, true);
417
+ assert.notEqual(result.code, 0);
418
+ });
419
+
420
+ test('a successful code with a timed-out global-install retry remains a failure', async () => {
421
+ const calls = [];
422
+ let attempt = 0;
423
+ await assert.rejects(runNativeUpdate({
424
+ io: noopIo, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
425
+ detectInstallMethod: () => 'npm', cleanStaleInstall: () => ['/temporary/stale'],
426
+ platform: 'linux',
427
+ runCommand: async argv => {
428
+ calls.push(argv);
429
+ if (argv[0] === 'npm' && argv[1] === 'install') return ++attempt === 1
430
+ ? { code: 1, output: 'EEXIST' }
431
+ : { code: 0, output: '', timedOut: true };
432
+ return { code: 0, output: '/global/node_modules' };
433
+ },
434
+ }), /timed out/);
435
+ assert.equal(attempt, 2);
436
+ });
437
+
438
+ test('Windows npm command uses cmd.exe argv without a shell string', () => {
439
+ assert.deepEqual(npmUpdateArgv(['install', '-g', 'github:B-icy/Carthagent#v1.2.0'], 'win32'), [
440
+ 'cmd.exe', '/d', '/s', '/c', 'npm', 'install', '-g', 'github:B-icy/Carthagent#v1.2.0',
441
+ ]);
442
+ });
443
+
444
+ test('runNativeUpdate passes npm operations through the Windows cmd shim', async () => {
445
+ const calls = [];
446
+ await runNativeUpdate({
447
+ io: noopIo, currentVersion: '1.0.0', fetchLatestRelease: async () => ({ release }),
448
+ detectInstallMethod: () => 'npm', platform: 'win32',
449
+ runCommand: async (argv, options) => { calls.push({ argv, options }); return { code: 0, output: '' }; },
450
+ });
451
+ assert.deepEqual(calls.map(call => call.argv), [
452
+ ['cmd.exe', '/d', '/s', '/c', 'npm', 'view', `carthagent@${release.version}`, 'version', '--json'],
453
+ ['cmd.exe', '/d', '/s', '/c', 'npm', 'install', '-g', '--install-links=true', `carthagent@${release.version}`],
454
+ ]);
455
+ });
456
+
457
+
458
+ test('explicit native command previews stable rollback without acquiring a runtime or saving a preference', () => tempDir(async dir => {
459
+ const env = { ...process.env, CARTHAGENT_UPDATE_CHANNEL_FILE: join(dir, 'channel.json'), CARTHAGENT_CODING_AGENT_DIR: join(dir, 'agent'), CTG_OPENCODE_HOME: join(dir, 'native') };
460
+ delete env.CTG_NATIVE;
461
+ writeUpdateChannel('experimental', env);
462
+ const server = createServer((_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ version: '1.0.7', tag: 'v1.0.7' })); });
463
+ server.listen(0, '127.0.0.1');
464
+ await once(server, 'listening');
465
+ try {
466
+ env.CARTHAGENT_CLOUD_URL = `http://127.0.0.1:${server.address().port}`;
467
+ const child = spawn(process.execPath, [fileURLToPath(new URL('../bin/ctg.mjs', import.meta.url)), 'native', 'update', '--channel', 'stable', '--check'], { env, cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] });
468
+ let output = ''; child.stdout.on('data', data => output += data); child.stderr.on('data', data => output += data);
469
+ const [code] = await once(child, 'close');
470
+ assert.equal(code, 0, output);
471
+ assert.match(output, /1\.0\.7.*available/);
472
+ assert.match(output, /--channel stable/);
473
+ assert.equal(readUpdateChannel(env), 'experimental');
474
+ } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
475
+ }));
@@ -6,10 +6,55 @@ import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { createTask, listNativeSessions, listNativeWorktrees, listTasks, reconcileTasks } from '../lib/opencode/workspace.mjs';
8
8
  import { listLegacyArchive, searchLegacyArchive, readLegacyTranscript, exportLegacyTranscript } from '../lib/opencode/archive.mjs';
9
- import { startReview, startPullRequestReview, verifyPullRequestHeads } from '../lib/opencode/review.mjs';
9
+ import { localReviewSnapshot, startReview, startPullRequestReview, verifyPullRequestHeads } from '../lib/opencode/review.mjs';
10
10
 
11
11
  function git(cwd, ...args) { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
12
12
 
13
+ test('local review git children strip every runtime token spelling and preserve unrelated environment', { skip: process.platform === 'win32' }, t => {
14
+ const root = mkdtempSync(join(tmpdir(), 'ctg-review-git-env-'));
15
+ t.after(() => rmSync(root, { recursive: true, force: true }));
16
+ git(root, 'init', '-q');
17
+ git(root, 'config', 'user.email', 'test@example.invalid');
18
+ git(root, 'config', 'user.name', 'Test');
19
+ writeFileSync(join(root, 'source.txt'), 'review me\n');
20
+ git(root, 'add', 'source.txt');
21
+ git(root, 'commit', '-qm', 'fixture');
22
+
23
+ const bin = join(root, 'bin');
24
+ mkdirSync(bin);
25
+ const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
26
+ const capturePath = join(root, 'git-child-env.jsonl');
27
+ const wrapper = join(bin, 'git');
28
+ writeFileSync(wrapper, `#!/usr/bin/env node\nimport { appendFileSync } from 'node:fs';\nimport { spawnSync } from 'node:child_process';\nappendFileSync(process.env.CTG_REVIEW_GIT_CAPTURE, JSON.stringify({ tokenKeys: Object.keys(process.env).filter(key => key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN'), unrelated: process.env.CTG_RUNTIME_OTHER }) + '\\n');\nconst result = spawnSync(${JSON.stringify(realGit)}, process.argv.slice(2), { stdio: 'inherit', env: process.env });\nprocess.exit(result.status ?? 1);\n`);
29
+ chmodSync(wrapper, 0o755);
30
+
31
+ const envKeys = ['PATH', 'CTG_RUNTIME_GITHUB_TOKEN', 'ctg_runtime_github_token', 'Ctg_Runtime_Github_Token', 'CTG_RUNTIME_OTHER', 'CTG_REVIEW_GIT_CAPTURE'];
32
+ const previous = Object.fromEntries(envKeys.map(key => [key, process.env[key]]));
33
+ Object.assign(process.env, {
34
+ PATH: `${bin}${process.platform === 'win32' ? ';' : ':'}${previous.PATH || ''}`,
35
+ CTG_RUNTIME_GITHUB_TOKEN: 'sentinel-uppercase-secret',
36
+ ctg_runtime_github_token: 'sentinel-lowercase-secret',
37
+ Ctg_Runtime_Github_Token: 'sentinel-mixedcase-secret',
38
+ CTG_RUNTIME_OTHER: 'preserve-review-env',
39
+ CTG_REVIEW_GIT_CAPTURE: capturePath,
40
+ });
41
+ try {
42
+ assert.match(localReviewSnapshot(root).head, /^[a-f0-9]{40,64}$/i);
43
+ const childEnvironments = readFileSync(capturePath, 'utf8').trim().split('\n').map(line => JSON.parse(line));
44
+ assert.ok(childEnvironments.length >= 3, 'snapshot runs multiple real git children');
45
+ for (const childEnv of childEnvironments) {
46
+ assert.deepEqual(childEnv.tokenKeys, []);
47
+ assert.equal(childEnv.unrelated, 'preserve-review-env');
48
+ }
49
+ assert.equal(process.env.CTG_RUNTIME_GITHUB_TOKEN, 'sentinel-uppercase-secret', 'credential remains in the parent process');
50
+ } finally {
51
+ for (const [key, value] of Object.entries(previous)) {
52
+ if (value === undefined) delete process.env[key];
53
+ else process.env[key] = value;
54
+ }
55
+ }
56
+ });
57
+
13
58
  test('creates native worktree and explicit independent session; recovery is not blindly replayed', async t => {
14
59
  const root = mkdtempSync(join(tmpdir(), 'ctg-native ')); t.after(() => rmSync(root, { recursive: true, force: true }));
15
60
  const repo = join(root, 'repo'); mkdirSync(repo); git(repo, 'init', '-q'); git(repo, 'config', 'user.email', 'test@example.invalid'); git(repo, 'config', 'user.name', 'Test');
@@ -275,7 +320,11 @@ test('PR review rejects GitHub metadata that does not match fetched real branch
275
320
  process.env.PATH = `${bin}${process.platform === 'win32' ? ';' : ':'}${priorPath ?? ''}`;
276
321
  const ctx = { agent: { async list() { return { data: [{ id: 'build' }] }; } }, session: { async create() { reviewerCreated = true; return { id: 'no' }; } } };
277
322
  try {
278
- await assert.rejects(startPullRequestReview({ ctx, cwd: root, repository: 'owner/fixture', prNumber: 9 }), /Fetched GitHub branch refs do not match/);
323
+ await assert.rejects(startPullRequestReview({ ctx, cwd: root, repository: 'owner/fixture', prNumber: 9 }), error => {
324
+ assert.match(error.message, /Fetched GitHub branch refs do not match/);
325
+ assert.equal(error.exitCode, 2);
326
+ return true;
327
+ });
279
328
  assert.equal(reviewerCreated, false);
280
329
  } finally { process.env.PATH = priorPath; }
281
330
  });