carthagent 1.0.7 → 1.1.0-preview.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/README.md +80 -79
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  4. package/docs/glyph-review/test-isolation-v1.md +26 -0
  5. package/docs/glyph-review/typecheck-base.log +1 -0
  6. package/docs/glyph-review/typecheck-v4.log +1 -0
  7. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  8. package/docs/help-dispatch-review.md +69 -0
  9. package/docs/legacy-cli.md +150 -0
  10. package/docs/live-theme-plugin-api.md +18 -0
  11. package/docs/native-cli.md +102 -0
  12. package/docs/native-compatibility.md +16 -0
  13. package/docs/native-daily-quests.md +132 -0
  14. package/docs/native-glyph-pty.md +59 -0
  15. package/docs/native-manual-testing.md +314 -0
  16. package/docs/native-slash-acceptance-handoff.md +49 -0
  17. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  18. package/docs/native-terminal-performance.md +310 -0
  19. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  20. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  21. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  22. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  23. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  24. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  31. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  32. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  33. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  35. package/docs/native-terminal-semantics.md +89 -0
  36. package/docs/native-upgrade.md +177 -0
  37. package/docs/opencode-composed-source-review.md +89 -0
  38. package/docs/opencode-migration.md +225 -9
  39. package/docs/opencode-owned-runtime.md +241 -0
  40. package/docs/opencode-runtime.md +12 -0
  41. package/docs/opencode-theme-provider-proof/README.md +55 -0
  42. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  43. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  44. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  45. package/docs/owned-host-controls-proof.md +39 -0
  46. package/docs/owned-runtime-release-manifest.md +42 -0
  47. package/docs/owned-visual-clear-proof.md +40 -0
  48. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  49. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  50. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  51. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  52. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  53. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  54. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  57. package/docs/release-notes-native-draft.md +73 -0
  58. package/docs/release-notes-preview.10.md +121 -0
  59. package/docs/release-notes-preview.11.md +76 -0
  60. package/docs/release-notes-preview.12.md +55 -0
  61. package/docs/release-notes-preview.9.md +130 -0
  62. package/evaluate.mjs +28 -1
  63. package/lib/budget.mjs +1 -1
  64. package/lib/codegraph.mjs +1 -1
  65. package/lib/delivery.mjs +3 -2
  66. package/lib/environment.mjs +8 -0
  67. package/lib/guidance.mjs +1 -1
  68. package/lib/opencode/cli-runtime.mjs +1171 -0
  69. package/lib/opencode/cli.mjs +252 -0
  70. package/lib/opencode/credentials.mjs +63 -8
  71. package/lib/opencode/demo.mjs +53 -0
  72. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  73. package/lib/opencode/evaluation.mjs +32 -12
  74. package/lib/opencode/headless.mjs +8 -4
  75. package/lib/opencode/provider-policy.mjs +23 -0
  76. package/lib/opencode/review.mjs +37 -16
  77. package/lib/opencode/runtime-artifact.mjs +335 -0
  78. package/lib/opencode/runtime-github-transport.mjs +121 -0
  79. package/lib/opencode/runtime-release.json +36 -0
  80. package/lib/opencode/runtime.mjs +298 -20
  81. package/lib/opencode/service.mjs +77 -35
  82. package/lib/opencode/session-options.mjs +6 -2
  83. package/lib/opencode/update.mjs +222 -0
  84. package/lib/opencode/workspace.mjs +1 -1
  85. package/lib/planning-advice.mjs +2 -2
  86. package/lib/tui/framing.mjs +1 -80
  87. package/lib/update.mjs +10 -1
  88. package/package.json +33 -7
  89. package/plugins/carthagent/README.md +23 -0
  90. package/plugins/carthagent/src/bootstrap.ts +1 -1
  91. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  92. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  93. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  94. package/plugins/carthagent/src/index.ts +71 -24
  95. package/plugins/carthagent/src/rpc.ts +51 -6
  96. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  97. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  98. package/plugins/carthagent/src/server/daily-source-rpc.ts +197 -0
  99. package/plugins/carthagent/src/server/delivery.ts +217 -106
  100. package/plugins/carthagent/src/server/framing.mjs +80 -0
  101. package/plugins/carthagent/src/server/policy.ts +1 -1
  102. package/plugins/carthagent/src/server/providers.ts +58 -30
  103. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  104. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  105. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  106. package/plugins/carthagent/src/server/ship-guidance.ts +39 -0
  107. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  108. package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
  109. package/plugins/carthagent/src/testing.ts +5 -0
  110. package/plugins/carthagent/src/tui.tsx +492 -0
  111. package/plugins/carthagent/src/ui/client.ts +125 -0
  112. package/plugins/carthagent/src/ui/commands.ts +70 -0
  113. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  114. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  115. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  116. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  117. package/plugins/carthagent/src/ui/daily-quests.tsx +232 -0
  118. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  119. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  120. package/plugins/carthagent/src/ui/native-review.d.mts +31 -0
  121. package/plugins/carthagent/src/ui/native-review.mjs +154 -0
  122. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  123. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  124. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  125. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  126. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  127. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  128. package/plugins/carthagent/src/ui/voyages-navigation.tsx +122 -0
  129. package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
  130. package/plugins/carthagent/src/ui/voyages.tsx +127 -0
  131. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  132. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  133. package/plugins/carthagent/src/voyages-rpc.ts +60 -0
  134. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  135. package/plugins/carthagent/tests/commands.test.ts +107 -0
  136. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  137. package/plugins/carthagent/tests/voyages-navigation.test.tsx +258 -0
  138. package/plugins/carthagent/themes/carthage.json +67 -0
  139. package/plugins/carthagent/tui.ts +26 -1
  140. package/script/ctg/build-owned-runtime.sh +166 -0
  141. package/tests/budget-engine.test.mjs +4 -3
  142. package/tests/cli.test.mjs +52 -96
  143. package/tests/daily-evidence.test.mjs +62 -0
  144. package/tests/delivery.test.mjs +22 -1
  145. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  146. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  147. package/tests/installed-runtime-contract.test.mjs +43 -0
  148. package/tests/native-cli-host-smoke.mjs +457 -0
  149. package/tests/native-daily-editor-focus.test.mjs +213 -0
  150. package/tests/native-daily-editor.test.mjs +43 -0
  151. package/tests/native-daily-quests-layout.test.mjs +53 -0
  152. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  153. package/tests/native-daily-quests-ui.test.mjs +778 -0
  154. package/tests/native-daily-source.test.mjs +426 -0
  155. package/tests/native-demo-ui.test.mjs +48 -0
  156. package/tests/native-evaluation-admission.test.mjs +24 -0
  157. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  158. package/tests/native-fixture-runtime.test.mjs +111 -0
  159. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  160. package/tests/native-glyph-pty.test.mjs +107 -0
  161. package/tests/native-manual-projects.test.mjs +170 -0
  162. package/tests/native-owned-runtime-env.test.mjs +140 -0
  163. package/tests/native-paired-guards.test.mjs +464 -0
  164. package/tests/native-provider-policy.test.mjs +31 -0
  165. package/tests/native-pty-invocation.test.mjs +114 -0
  166. package/tests/native-pty-runtime.test.mjs +45 -0
  167. package/tests/native-resume-session.test.mjs +46 -0
  168. package/tests/native-review-consumer.test.mjs +267 -0
  169. package/tests/native-runtime-identity.test.mjs +214 -0
  170. package/tests/native-ship-parity.test.mjs +526 -0
  171. package/tests/native-ship-plan.test.mjs +75 -0
  172. package/tests/native-ship-rpc.test.mjs +71 -0
  173. package/tests/native-ui-commands.test.mjs +6 -0
  174. package/tests/native-voyages-layout.test.mjs +28 -0
  175. package/tests/native-voyages-navigation.test.mjs +169 -0
  176. package/tests/native-voyages-rpc.test.mjs +395 -0
  177. package/tests/native-voyages-ui.test.mjs +277 -0
  178. package/tests/opencode-atomic-write.test.mjs +55 -0
  179. package/tests/opencode-cli.test.mjs +1312 -0
  180. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  181. package/tests/opencode-providers.test.mjs +245 -18
  182. package/tests/opencode-runtime.test.mjs +546 -8
  183. package/tests/opencode-session-controls.test.mjs +15 -0
  184. package/tests/opencode-ship-rpc.test.mjs +71 -0
  185. package/tests/opencode-update.test.mjs +475 -0
  186. package/tests/opencode-workspace.test.mjs +51 -2
  187. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  188. package/tests/release-workflow.test.mjs +46 -0
  189. package/tests/runtime-artifact.test.mjs +269 -0
  190. package/tests/runtime-development-trust.test.mjs +230 -0
  191. package/tests/runtime-github-transport.test.mjs +132 -0
  192. package/tests/runtime-token-env.test.mjs +23 -0
  193. package/tests/test_native_terminal_probe.py +76 -0
  194. package/tests/test_native_terminal_semantics.py +232 -0
  195. package/tests/update.test.mjs +36 -0
  196. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  197. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  198. package/tests/workspace-live.test.mjs +127 -0
  199. package/tools/benchmark/diagnose-request.mjs +59 -36
  200. package/tools/benchmark/eval-common.mjs +3 -3
  201. package/tools/benchmark/evaluators.test.mjs +4 -4
  202. package/tools/benchmark/guard.test.mjs +35 -2
  203. package/tools/benchmark/guard.ts +20 -7
  204. package/tools/benchmark/planning-startup.test.mjs +30 -34
  205. package/tools/benchmark/runner.mjs +12 -7
  206. package/tools/benchmark/runner.test.mjs +23 -7
  207. package/tools/benchmark/upstream-error.test.mjs +16 -2
  208. package/tools/generate-owned-runtime-release.mjs +217 -0
  209. package/tools/installed-runtime-contract.mjs +16 -0
  210. package/tools/manual-runtime-manifest.mjs +39 -0
  211. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  212. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  213. package/tools/native-fixture.mjs +199 -40
  214. package/tools/native-glyph-helpers.py +127 -0
  215. package/tools/native-glyph-pty.py +144 -0
  216. package/tools/native-manual-projects.mjs +259 -0
  217. package/tools/native-manual.mjs +338 -0
  218. package/tools/native-model-picker-acceptance.py +306 -0
  219. package/tools/native-owned-runtime-env.sh +47 -0
  220. package/tools/native-paired-guards.mjs +282 -0
  221. package/tools/native-paired-manifest.mjs +72 -0
  222. package/tools/native-paired-orchestration.sh +51 -0
  223. package/tools/native-plugin-readiness.mjs +33 -0
  224. package/tools/native-plugin-readiness.test.mjs +53 -0
  225. package/tools/native-pty-invocation.mjs +26 -0
  226. package/tools/native-pty-runtime.mjs +83 -0
  227. package/tools/native-pty-smoke.py +147 -24
  228. package/tools/native-public-repo-shell-result.mjs +13 -0
  229. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  230. package/tools/native-runtime-identity.mjs +201 -0
  231. package/tools/native-slash-acceptance-env.py +38 -0
  232. package/tools/native-terminal-semantics.py +876 -0
  233. package/tools/owned-shell-observer/index.ts +16 -0
  234. package/tools/owned-shell-observer/package.json +1 -0
  235. package/tools/owned-visual-clear-env.py +48 -0
  236. package/tools/owned-visual-clear-model.py +21 -0
  237. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  238. package/tools/prepare-native-manual.mjs +122 -0
  239. package/tools/run-native-terminal-paired.sh +403 -0
  240. package/tools/runtime-token-env.mjs +8 -0
  241. package/tools/seed-native-switch-fixture.mjs +79 -0
  242. package/tools/test-native-controls.mjs +3 -1
  243. package/tools/test-native-daily-quests.mjs +65 -0
  244. package/tools/test-native-default-cli.mjs +106 -0
  245. package/tools/test-native-delivery-host.mjs +100 -18
  246. package/tools/test-native-evaluation.mjs +7 -3
  247. package/tools/test-native-glyph-pty.mjs +94 -0
  248. package/tools/test-native-host.mjs +83 -61
  249. package/tools/test-native-outcomes.mjs +3 -1
  250. package/tools/test-native-package.mjs +206 -38
  251. package/tools/test-native-performance.mjs +26 -0
  252. package/tools/test-native-product.mjs +90 -0
  253. package/tools/test-native-pty.mjs +34 -9
  254. package/tools/test-native-public-repo.mjs +224 -0
  255. package/tools/test-native-refresh-reentry.mjs +106 -0
  256. package/tools/test-native-report.mjs +27 -20
  257. package/tools/test-native-review.mjs +75 -0
  258. package/tools/test-native-runtime.mjs +120 -0
  259. package/tools/test-native-ship-models.mjs +176 -0
  260. package/tools/test-native-ship-walkthrough.mjs +96 -0
  261. package/tools/test-native-slash-commands.mjs +125 -0
  262. package/tools/test-native-slash-commands.py +242 -0
  263. package/tools/test-native-swe-host.mjs +224 -55
  264. package/tools/test-native-tasks.mjs +77 -0
  265. package/tools/test-native-terminal-performance.py +887 -0
  266. package/tools/test-native-terminal-readiness.sh +85 -0
  267. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  268. package/tools/test-native-visual-clear-installed.mjs +48 -0
  269. package/tools/test-owned-host-controls.mjs +238 -0
  270. package/tools/test-owned-opencode-runtime.mjs +35 -0
  271. package/tools/test-owned-visual-clear-env.py +120 -0
  272. package/tools/test-owned-visual-clear.py +373 -0
  273. package/tools/verify-draft-runtime-gate.mjs +138 -0
  274. package/tools/verify-owned-runtime-release.mjs +114 -0
  275. package/tools/verify-release-version.mjs +21 -1
@@ -4,27 +4,44 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { resolve } from 'node:path';
6
6
  import { beginReview, parseFindings } from '../review-state.mjs';
7
+ import { withoutRuntimeGithubToken } from '../environment.mjs';
7
8
 
8
9
  function reviewVerdict(output) {
9
10
  const line = output.trim().split(/\r?\n/).at(-1);
10
11
  const match = /^VERDICT: (APPROVE|CHANGES-REQUESTED)$/.exec(line || '');
11
- if (!match) throw Error('Reviewer output must end with an exact structured VERDICT line');
12
+ if (!match) throw Object.assign(Error('Reviewer output must end with an exact structured VERDICT line'), { exitCode: 2 });
12
13
  return match[1];
13
14
  }
14
15
 
16
+ function reviewContractError(error) {
17
+ if (Number.isInteger(error?.exitCode)) return error;
18
+ if (/^Missing structured REVIEW_JSON|^Invalid REVIEW_JSON|^Invalid or stale structured review identity|^Malformed structured finding|^Verdict contradicts structured findings/.test(String(error?.message || ''))) {
19
+ error.exitCode = 2;
20
+ }
21
+ return error;
22
+ }
23
+
24
+ function beginReviewChecked(...args) {
25
+ try { return beginReview(...args); }
26
+ catch (error) {
27
+ if (/review round limit|already approved|invalid persisted review state|review already running|interrupted review/i.test(String(error?.message || ''))) error.exitCode = 2;
28
+ throw error;
29
+ }
30
+ }
31
+
15
32
  function localSource(root, git) {
16
33
  const diff = git('diff', 'HEAD', '--binary', { trim: false });
17
- const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(path => path && path !== '.harness' && !path.startsWith('.harness/')).sort();
34
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, encoding: 'utf8', env: withoutRuntimeGithubToken() }).split('\0').filter(path => path && path !== '.harness' && !path.startsWith('.harness/')).sort();
18
35
  return `${diff}\0UNTRACKED\0${untracked.map(path => `${path}\0${readFileSync(join(root, path)).toString('base64')}`).join('\0')}`;
19
36
  }
20
37
 
21
38
  /** Return the same source-bound identity used by local reviews without launching a reviewer. */
22
39
  export function localReviewSnapshot(cwd) {
23
40
  const root = resolve(cwd);
24
- const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
41
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: withoutRuntimeGithubToken() }).trim();
25
42
  const git = (...args) => {
26
43
  const options = args.at(-1)?.trim === false ? args.pop() : undefined;
27
- const output = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
44
+ const output = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: withoutRuntimeGithubToken() });
28
45
  return options ? output : output.trim();
29
46
  };
30
47
  return { head, snapshot: createReviewSnapshot({ source: localSource(root, git), head }) };
@@ -72,7 +89,7 @@ async function interruptReviewer(ctx, sessionID) {
72
89
  async function selectReviewerAgent(ctx, agent, signal) {
73
90
  const list = await ctx.agent.list({}, { signal });
74
91
  const agents = Array.isArray(list) ? list : list?.data;
75
- if (!Array.isArray(agents) || !agents.some(item => item.id === agent)) throw Error(`Reviewer agent ${agent} is not registered at this OpenCode location`);
92
+ if (!Array.isArray(agents) || !agents.some(item => item.id === agent)) throw Error(`Reviewer agent ${agent} is not registered at this Carthagent runtime location`);
76
93
  return agent;
77
94
  }
78
95
 
@@ -116,13 +133,13 @@ export async function startReview({ ctx, cwd, identity = 'local-delivery', model
116
133
  const root = resolve(cwd);
117
134
  const git = (...args) => {
118
135
  const options = args.at(-1)?.trim === false ? args.pop() : undefined;
119
- const output = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
136
+ const output = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: withoutRuntimeGithubToken() });
120
137
  return options ? output : output.trim();
121
138
  };
122
139
  const head = git('rev-parse', 'HEAD');
123
140
  const source = localSource(root, git);
124
141
  const snapshot = createReviewSnapshot({ source, head });
125
- const admission = beginReview(root, identity, snapshot, head);
142
+ const admission = beginReviewChecked(root, identity, snapshot, head);
126
143
  let reviewer;
127
144
  try {
128
145
  const prompt = `Review this exact local delivery source at ${root}. This is a read-only local delivery review, not a PR review. Do not modify files, commit, push, or merge.\nHEAD: ${head}\nSnapshot SHA-256: ${snapshot}\n\nExact source diff and untracked source files (base64 records where applicable):\n${source}\n\nReport findings and end with REVIEW_JSON: {"version":1,"snapshot":"${snapshot}","head":"${head}","findings":[]} followed by VERDICT: APPROVE or VERDICT: CHANGES-REQUESTED.`;
@@ -130,10 +147,12 @@ export async function startReview({ ctx, cwd, identity = 'local-delivery', model
130
147
  reviewer = result.reviewer;
131
148
  const output = result.output;
132
149
  const verdict = reviewVerdict(output);
133
- const findings = parseFindings(output, snapshot, head, verdict);
150
+ let findings;
151
+ try { findings = parseFindings(output, snapshot, head, verdict); }
152
+ catch (error) { throw reviewContractError(error); }
134
153
  const currentHead = await git('rev-parse', 'HEAD');
135
154
  const currentSource = localSource(root, git);
136
- if (currentHead !== head || createReviewSnapshot({ source: currentSource, head: currentHead }) !== snapshot) throw Error('Local review source or HEAD moved while review was running');
155
+ if (currentHead !== head || createReviewSnapshot({ source: currentSource, head: currentHead }) !== snapshot) throw Object.assign(Error('Local review source or HEAD moved while review was running'), { exitCode: 2 });
137
156
  const status = verdict === 'APPROVE' ? 'approved' : 'changes-requested';
138
157
  admission.finish(status, { reviewerSessionID: reviewer.id, findings: findings.findings, remote: false });
139
158
  return { type: 'local-delivery', identity, head, snapshot, reviewerSessionID: reviewer.id, verdict, findings };
@@ -144,7 +163,7 @@ export async function startReview({ ctx, cwd, identity = 'local-delivery', model
144
163
  }
145
164
 
146
165
  function commandOutput(command, args, cwd, { trim = true } = {}) {
147
- const result = execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
166
+ const result = execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: withoutRuntimeGithubToken() });
148
167
  const output = Buffer.isBuffer(result) ? result.toString('utf8') : String(result);
149
168
  return trim ? output.trim() : output;
150
169
  }
@@ -171,11 +190,11 @@ function authoritativePR({ cwd, repository, prNumber }) {
171
190
  if (git('check-ref-format', '--branch', headRefName) !== headRefName || git('check-ref-format', '--branch', baseRefName) !== baseRefName) throw Error('GitHub PR returned an invalid branch ref');
172
191
  commandOutput('git', ['fetch', '--no-tags', '--no-write-fetch-head', headUrl, `+refs/heads/${headRefName}:${headRef}`], cwd);
173
192
  commandOutput('git', ['fetch', '--no-tags', '--no-write-fetch-head', baseUrl, `+refs/heads/${baseRefName}:${baseRef}`], cwd);
174
- if (git('rev-parse', headRef) !== remoteHead || git('rev-parse', baseRef) !== baseHead) throw Error('Fetched GitHub branch refs do not match the authoritative pull request commit IDs');
175
- if (localHead !== remoteHead) throw Error('Local HEAD does not match the authoritative GitHub PR head');
193
+ if (git('rev-parse', headRef) !== remoteHead || git('rev-parse', baseRef) !== baseHead) throw Object.assign(Error('Fetched GitHub branch refs do not match the authoritative pull request commit IDs'), { exitCode: 2 });
194
+ if (localHead !== remoteHead) throw Object.assign(Error('Local HEAD does not match the authoritative GitHub PR head'), { exitCode: 2 });
176
195
  const mergeBase = git('merge-base', baseHead, remoteHead);
177
196
  const source = commandOutput('git', ['diff', '--binary', mergeBase, remoteHead, '--'], cwd, { trim: false });
178
- if (git('rev-parse', `${remoteHead}^{commit}`) !== remoteHead || git('rev-parse', `${baseHead}^{commit}`) !== baseHead) throw Error('Fetched Git objects do not match the authoritative GitHub PR commits');
197
+ if (git('rev-parse', `${remoteHead}^{commit}`) !== remoteHead || git('rev-parse', `${baseHead}^{commit}`) !== baseHead) throw Object.assign(Error('Fetched Git objects do not match the authoritative GitHub PR commits'), { exitCode: 2 });
179
198
  return { repository: repository.toLowerCase(), prNumber, localHead, remoteHead, baseHead, headRepository: headRepository.toLowerCase(), source };
180
199
  }
181
200
 
@@ -188,16 +207,18 @@ export async function startPullRequestReview({ ctx, cwd, repository, prNumber, m
188
207
  const identity = `${initial.repository}#${initial.prNumber}`;
189
208
  const head = initial.remoteHead;
190
209
  const snapshot = createReviewSnapshot({ source: initial.source, head });
191
- const admission = beginReview(root, `pr:${identity}`, snapshot, head);
210
+ const admission = beginReviewChecked(root, `pr:${identity}`, snapshot, head);
192
211
  try {
193
212
  const prompt = `Review this exact GitHub pull request source snapshot. This is remote PR review, not local delivery review. Do not modify files, commit, push, or merge.\nRepository: ${identity}\nVerified PR head: ${head}\nSnapshot SHA-256: ${snapshot}\n\n${initial.source}\n\nReturn REVIEW_JSON: {"version":1,"snapshot":"${snapshot}","head":"${head}","findings":[]} followed by VERDICT: APPROVE or VERDICT: CHANGES-REQUESTED.`;
194
213
  const result = await runReviewer({ ctx, root, title: `CTG PR review: ${identity}`, prompt, agent, model, signal, timeoutMs });
195
214
  const reviewer = result.reviewer;
196
215
  const output = result.output;
197
216
  const verdict = reviewVerdict(output);
198
- const findings = parseFindings(output, snapshot, head, verdict);
217
+ let findings;
218
+ try { findings = parseFindings(output, snapshot, head, verdict); }
219
+ catch (error) { throw reviewContractError(error); }
199
220
  const current = authoritativePR({ cwd: root, repository, prNumber });
200
- if (current.repository !== initial.repository || current.headRepository !== initial.headRepository || current.localHead !== initial.localHead || current.remoteHead !== initial.remoteHead || current.baseHead !== initial.baseHead || createReviewSnapshot({ source: current.source, head: current.remoteHead }) !== snapshot) throw Error('Authoritative GitHub PR refs or source moved while review was running');
221
+ if (current.repository !== initial.repository || current.headRepository !== initial.headRepository || current.localHead !== initial.localHead || current.remoteHead !== initial.remoteHead || current.baseHead !== initial.baseHead || createReviewSnapshot({ source: current.source, head: current.remoteHead }) !== snapshot) throw Object.assign(Error('Authoritative GitHub PR refs or source moved while review was running'), { exitCode: 2 });
201
222
  admission.finish(verdict === 'APPROVE' ? 'approved' : 'changes-requested', { reviewerSessionID: reviewer.id, findings: findings.findings, remote: true });
202
223
  return { type: 'pull-request', identity, head, snapshot, reviewerSessionID: reviewer.id, verdict, findings };
203
224
  } catch (error) {
@@ -0,0 +1,335 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { Transform } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { createGunzip } from 'node:zlib';
9
+ import { withFileLock } from './runtime.mjs';
10
+
11
+ const MANIFEST_NAME = 'ctg-runtime.json';
12
+ const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
13
+ const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
14
+ const MAX_ENTRIES = 20_000;
15
+ const DEFAULT_ROOT = path.join(homedir(), '.carthagent', 'opencode', 'runtimes');
16
+
17
+ const TARGETS = Object.freeze({
18
+ 'linux-x64': { platform: 'linux', arch: 'x64', executable: 'bin/opencode' },
19
+ 'linux-arm64': { platform: 'linux', arch: 'arm64', executable: 'bin/opencode' },
20
+ 'darwin-x64': { platform: 'darwin', arch: 'x64', executable: 'bin/opencode' },
21
+ 'darwin-arm64': { platform: 'darwin', arch: 'arm64', executable: 'bin/opencode' },
22
+ 'windows-x64': { platform: 'windows', arch: 'x64', executable: 'bin/opencode.exe' },
23
+ 'windows-arm64': { platform: 'windows', arch: 'arm64', executable: 'bin/opencode.exe' },
24
+ });
25
+
26
+ /**
27
+ * Acquire one immutable CTG release artifact. All expected digests and runtime
28
+ * identity fields must come from the caller's packaged, pinned runtime-release
29
+ * manifest; this function never consults a latest-version pointer or treats a
30
+ * downloaded checksum/manifest as a trust anchor.
31
+ * Returns the installed artifact root containing ctg-runtime.json,
32
+ * SHA256SUMS, and cli-<platform>-<arch>/bin/opencode.
33
+ */
34
+ export async function acquireOwnedRuntime(options = {}) {
35
+ const releaseVersion = validateVersion(options.releaseVersion);
36
+ const target = resolveTarget(options.platform, options.arch);
37
+ const assetName = options.assetName ?? `opencode-${target.key}.tar.gz`;
38
+ if (assetName !== `opencode-${target.key}.tar.gz`) throw new TypeError(`assetName must be opencode-${target.key}.tar.gz`);
39
+ const archiveSha256 = validateSha256(options.expectedArchiveSha256, 'expectedArchiveSha256');
40
+ const expected = {
41
+ platform: `${target.platform}-${target.arch}`,
42
+ sourceRevision: validateRevision(options.expectedSourceRevision),
43
+ patchSha256: validateSha256(options.expectedPatchSha256, 'expectedPatchSha256'),
44
+ lockSha256: validateSha256(options.expectedLockSha256, 'expectedLockSha256'),
45
+ compatibilityVersion: validateText(options.expectedCompatibilityVersion, 'expectedCompatibilityVersion'),
46
+ runtimeVersion: validateText(options.expectedRuntimeVersion, 'expectedRuntimeVersion'),
47
+ channel: validateText(options.expectedChannel, 'expectedChannel'),
48
+ executableSha256: validateSha256(options.expectedExecutableSha256, 'expectedExecutableSha256'),
49
+ };
50
+ const baseUrl = validateBaseUrl(options.baseUrl);
51
+ const root = path.resolve(options.cacheRoot ?? DEFAULT_ROOT);
52
+ const releaseDir = path.join(root, releaseVersion, target.key);
53
+ const lockPath = path.join(root, '.locks', `${releaseVersion}-${target.key}.lock`);
54
+ await mkdir(root, { recursive: true, mode: 0o700 });
55
+
56
+ return withFileLock(lockPath, async () => {
57
+ // Cached checksums cannot be trusted: they live inside the writable cache.
58
+ // Always compare executable bytes to the independently pinned digest.
59
+ const existing = await validateInstalled(releaseDir, expected, target).catch(() => null);
60
+ if (existing) return existing;
61
+
62
+ const stageDir = path.join(root, '.staging', `${releaseVersion}-${target.key}-${randomUUID()}`);
63
+ await mkdir(stageDir, { recursive: true, mode: 0o700 });
64
+ try {
65
+ const archive = path.join(stageDir, assetName);
66
+ await downloadArchive(new URL(encodeURIComponent(assetName), `${baseUrl}/`), archive, options.fetchImpl ?? fetch, options.signal);
67
+ const actualArchiveHash = await hashFile(archive);
68
+ if (actualArchiveHash !== archiveSha256) {
69
+ throw new Error(`CTG Carthagent runtime ${releaseVersion} ${target.key} archive SHA-256 mismatch (expected ${archiveSha256}, got ${actualArchiveHash}).`);
70
+ }
71
+ const contentDir = path.join(stageDir, 'unpacked');
72
+ await mkdir(contentDir, { mode: 0o700 });
73
+ await unpackTarGz(archive, contentDir, target);
74
+ await validateInstalled(contentDir, expected, target);
75
+ await mkdir(path.dirname(releaseDir), { recursive: true, mode: 0o700 });
76
+ const previousDir = `${releaseDir}.replaced-${randomUUID()}`;
77
+ let movedPrevious = false;
78
+ try {
79
+ await rename(releaseDir, previousDir);
80
+ movedPrevious = true;
81
+ } catch (error) {
82
+ if (error.code !== 'ENOENT') throw error;
83
+ }
84
+ try {
85
+ await rename(contentDir, releaseDir);
86
+ } catch (error) {
87
+ if (movedPrevious) await rename(previousDir, releaseDir).catch(() => {});
88
+ throw error;
89
+ }
90
+ if (movedPrevious) await rm(previousDir, { recursive: true, force: true });
91
+ await chmod(releaseDir, 0o700).catch(() => {});
92
+ return releaseDir;
93
+ } catch (error) {
94
+ throw new Error(`Could not acquire CTG-owned Carthagent runtime ${releaseVersion} for ${target.key}: ${error.message}. Check network access or remove the incomplete cache and retry; no system Carthagent runtime fallback was attempted.`, { cause: error });
95
+ } finally {
96
+ await rm(stageDir, { recursive: true, force: true }).catch(() => {});
97
+ }
98
+ }, { timeout: options.lockTimeout ?? 60_000 });
99
+ }
100
+
101
+ function validateVersion(value) {
102
+ if (typeof value !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value)) {
103
+ throw new TypeError('releaseVersion must be an exact semantic version (without a v prefix).');
104
+ }
105
+ return value;
106
+ }
107
+ function validateRevision(value) {
108
+ if (typeof value !== 'string' || !/^[a-f0-9]{40,64}$/i.test(value)) {
109
+ throw new TypeError('expectedSourceRevision must be a pinned 40- to 64-character hexadecimal revision.');
110
+ }
111
+ return value.toLowerCase();
112
+ }
113
+ function validateText(value, name) {
114
+ if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new TypeError(`${name} must be a non-empty pinned string.`);
115
+ return value;
116
+ }
117
+ function validateSha256(value, name) {
118
+ if (typeof value !== 'string' || !/^[a-f0-9]{64}$/i.test(value)) throw new TypeError(`${name} must be a pinned 64-character SHA-256 hex digest.`);
119
+ return value.toLowerCase();
120
+ }
121
+ function resolveTarget(platform = process.platform, arch = process.arch) {
122
+ const key = `${platform}-${arch}`;
123
+ const normalized = ({ 'win32-x64': 'windows-x64', 'win32-arm64': 'windows-arm64' })[key] ?? key;
124
+ const target = TARGETS[normalized];
125
+ if (!target) throw new Error(`Unsupported CTG Carthagent runtime release target ${key}.`);
126
+ return { ...target, key: normalized };
127
+ }
128
+ function validateBaseUrl(value) {
129
+ let url;
130
+ try { url = new URL(value); } catch { throw new TypeError('baseUrl must be an HTTPS release asset base URL.'); }
131
+ if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
132
+ throw new TypeError('baseUrl must use HTTPS (HTTP is allowed only for loopback fixture servers).');
133
+ }
134
+ if (url.username || url.password || url.search || url.hash) throw new TypeError('baseUrl must not contain credentials, a query, or a fragment.');
135
+ return url.toString().replace(/\/+$/, '');
136
+ }
137
+
138
+ async function downloadArchive(url, destination, fetchImpl, signal) {
139
+ const timeout = AbortSignal.timeout(120_000);
140
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
141
+ let response;
142
+ for (let redirects = 0; redirects <= 5; redirects++) {
143
+ response = await fetchImpl(url, { redirect: 'manual', signal: requestSignal });
144
+ if (![301, 302, 303, 307, 308].includes(response.status)) break;
145
+ const location = response.headers.get('location');
146
+ await response.body?.cancel().catch(() => {});
147
+ if (!location || redirects === 5) throw new Error('release asset returned a missing or excessive redirect chain');
148
+ const next = new URL(location, url);
149
+ if (next.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(next.hostname)) {
150
+ throw new Error('release asset redirect attempted an insecure non-loopback URL');
151
+ }
152
+ if (next.username || next.password) throw new Error('release asset redirect included URL credentials');
153
+ url = next;
154
+ }
155
+ if (!response.ok || !response.body) throw new Error(`release asset request failed with HTTP ${response.status}`);
156
+ const length = Number(response.headers.get('content-length'));
157
+ if (Number.isFinite(length) && length > MAX_ARCHIVE_BYTES) throw new Error(`release archive exceeds the ${MAX_ARCHIVE_BYTES}-byte download limit`);
158
+ let bytes = 0;
159
+ const limiter = new Transform({ transform(chunk, _encoding, callback) {
160
+ bytes += chunk.length;
161
+ callback(bytes > MAX_ARCHIVE_BYTES ? new Error(`release archive exceeds the ${MAX_ARCHIVE_BYTES}-byte download limit`) : null, chunk);
162
+ } });
163
+ await pipeline(response.body, limiter, createWriteStream(destination, { flags: 'wx', mode: 0o600 }));
164
+ }
165
+
166
+ async function hashFile(file) {
167
+ const hash = createHash('sha256');
168
+ const stream = createReadStream(file);
169
+ for await (const chunk of stream) hash.update(chunk);
170
+ return hash.digest('hex');
171
+ }
172
+
173
+ async function validateInstalled(directory, expected, target) {
174
+ const manifestPath = path.join(directory, MANIFEST_NAME);
175
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
176
+ const required = {
177
+ owner: 'Carthagent',
178
+ sourceRepository: 'https://github.com/anomalyco/opencode',
179
+ sourceRevision: expected.sourceRevision,
180
+ lockSha256: expected.lockSha256,
181
+ patchSha256: expected.patchSha256,
182
+ compatibilityVersion: expected.compatibilityVersion,
183
+ runtimeVersion: expected.runtimeVersion,
184
+ channel: expected.channel,
185
+ platform: expected.platform,
186
+ webUI: false,
187
+ };
188
+ for (const [key, value] of Object.entries(required)) {
189
+ if (manifest[key] !== value) throw new Error(`inner runtime manifest ${key} mismatch`);
190
+ }
191
+ const executablePath = `cli-${target.key}/${target.executable}`;
192
+ const safePath = safeArchivePath(executablePath);
193
+ const executable = path.join(directory, ...safePath.split('/'));
194
+ const details = await lstat(executable);
195
+ if (!details.isFile() || details.isSymbolicLink()) throw new Error('manifest executable is not a regular file');
196
+ const sums = await readFile(path.join(directory, 'SHA256SUMS'), 'utf8');
197
+ const checksum = sums.split(/\r?\n/).map((line) => line.trim().split(/\s+/))
198
+ .find(([hash, name]) => /^[a-f0-9]{64}$/i.test(hash ?? '') && name?.replace(/^\*?/, '') === executablePath)?.[0];
199
+ if (!checksum) throw new Error(`SHA256SUMS does not list ${executablePath}`);
200
+ const executableHash = validateSha256(checksum, 'SHA256SUMS executable entry');
201
+ if (executableHash !== expected.executableSha256) throw new Error('SHA256SUMS executable hash does not match pinned release metadata');
202
+ if (await hashFile(executable) !== expected.executableSha256) throw new Error('inner runtime executable SHA-256 mismatch');
203
+ // Windows filesystems do not expose POSIX executable bits. The Unix target's
204
+ // USTAR header is checked during extraction on every host; use cached stat
205
+ // mode only when the host filesystem can represent it.
206
+ if (process.platform !== 'win32' && target.platform !== 'windows' && !(details.mode & 0o111)) {
207
+ throw new Error('inner runtime executable is not marked executable');
208
+ }
209
+ return directory;
210
+ }
211
+
212
+ // Deliberately small ustar reader: it accepts only regular files/directories,
213
+ // never invokes a system tar program, and rejects links and unsafe names.
214
+ async function unpackTarGz(archive, destination, target) {
215
+ let expandedBytes = 0;
216
+ let entries = 0;
217
+ let buffered = Buffer.alloc(0);
218
+ let pending = null;
219
+ let ended = false;
220
+ let decompressedBytes = 0;
221
+ const gunzip = createGunzip();
222
+ const input = createReadStream(archive).pipe(gunzip);
223
+ const consume = async (chunk) => {
224
+ decompressedBytes += chunk.length;
225
+ if (decompressedBytes > MAX_EXPANDED_BYTES) throw new Error('decompressed archive exceeds the size limit');
226
+ if (ended) {
227
+ if (chunk.some((byte) => byte !== 0)) throw new Error('tar archive has non-zero data after its end marker');
228
+ return;
229
+ }
230
+ buffered = Buffer.concat([buffered, chunk]);
231
+ while (true) {
232
+ if (pending) {
233
+ const take = Math.min(pending.remaining, buffered.length);
234
+ if (take) {
235
+ await pending.handle.write(buffered.subarray(0, take));
236
+ buffered = buffered.subarray(take);
237
+ pending.remaining -= take;
238
+ }
239
+ if (pending.remaining) return;
240
+ await pending.handle.sync(); await pending.handle.close(); pending = null;
241
+ const padding = (512 - (pendingSize % 512)) % 512;
242
+ if (buffered.length < padding) {
243
+ pendingPadding = padding - buffered.length;
244
+ buffered = Buffer.alloc(0);
245
+ return;
246
+ }
247
+ buffered = buffered.subarray(padding);
248
+ continue;
249
+ }
250
+ if (pendingPadding) {
251
+ const take = Math.min(pendingPadding, buffered.length);
252
+ buffered = buffered.subarray(take); pendingPadding -= take;
253
+ if (pendingPadding) return;
254
+ continue;
255
+ }
256
+ if (buffered.length < 512) return;
257
+ const header = buffered.subarray(0, 512); buffered = buffered.subarray(512);
258
+ if (header.every((byte) => byte === 0)) {
259
+ ended = true;
260
+ if (buffered.some((byte) => byte !== 0)) throw new Error('tar archive has non-zero data after its end marker');
261
+ return;
262
+ }
263
+ validateTarChecksum(header);
264
+ entries++;
265
+ if (entries > MAX_ENTRIES) throw new Error('archive contains too many entries');
266
+ const name = tarString(header, 0, 100);
267
+ const prefix = tarString(header, 345, 155);
268
+ const rawRelative = prefix ? `${prefix}/${name}` : name;
269
+ const type = header[156];
270
+ const size = parseTarOctal(header.subarray(124, 136));
271
+ const mode = parseTarOctal(header.subarray(100, 108)) & 0o777;
272
+ expandedBytes += size;
273
+ if (expandedBytes > MAX_EXPANDED_BYTES) throw new Error('expanded archive exceeds the size limit');
274
+ // GNU tar commonly archives `.` as a top-level directory entry.
275
+ if ((rawRelative === '.' || rawRelative === './') && (type === 53 || type === 0) && size === 0) continue;
276
+ const relative = safeArchivePath(rawRelative);
277
+ const archivePath = relative.replace(/^(?:\.\/)+/, '');
278
+ const output = path.join(destination, ...relative.split('/'));
279
+ if (type === 53 || (type === 0 && relative.endsWith('/'))) {
280
+ if (size) throw new Error(`directory entry ${relative} unexpectedly contains data`);
281
+ await mkdir(output, { recursive: true, mode: 0o700 });
282
+ continue;
283
+ }
284
+ if (type === 120 || type === 103) throw new Error('archive uses a PAX extension; CTG runtime release archives must use USTAR format');
285
+ if (type !== 0 && type !== 48) throw new Error(`archive contains unsupported link or special entry ${relative}`);
286
+ if (target.platform !== 'windows' && archivePath === `cli-${target.key}/${target.executable}` && !(mode & 0o111)) {
287
+ throw new Error('inner runtime executable is not marked executable');
288
+ }
289
+ await mkdir(path.dirname(output), { recursive: true, mode: 0o700 });
290
+ const handle = await open(output, 'wx', mode & 0o111 ? 0o700 : 0o600);
291
+ pendingSize = size;
292
+ pendingPadding = 0;
293
+ pending = { handle, remaining: size, output };
294
+ if (!size) { await handle.close(); pending = null; pendingPadding = 0; }
295
+ }
296
+ };
297
+ let pendingSize = 0;
298
+ let pendingPadding = 0;
299
+ try {
300
+ for await (const chunk of input) await consume(chunk);
301
+ if (pending) { await pending.handle.close().catch(() => {}); throw new Error(`truncated archive file ${pending.output}`); }
302
+ if (pendingPadding || (!ended && buffered.length)) throw new Error('truncated tar archive');
303
+ if (!ended) throw new Error('tar archive has no end marker');
304
+ } catch (error) {
305
+ await pending?.handle.close().catch(() => {});
306
+ throw error;
307
+ } finally { input.destroy(); }
308
+ }
309
+
310
+ function safeArchivePath(value) {
311
+ if (typeof value !== 'string' || !value || value.includes('\0') || value.includes('\\') || value.startsWith('/') || /^[a-zA-Z]:/.test(value)) {
312
+ throw new Error(`unsafe archive path: ${JSON.stringify(value)}`);
313
+ }
314
+ const parts = value.replace(/\/$/, '').split('/');
315
+ if (parts.some((part) => !part || part === '..')) throw new Error(`unsafe archive path: ${JSON.stringify(value)}`);
316
+ return parts.join('/');
317
+ }
318
+ function tarString(header, start, length) {
319
+ const end = header.indexOf(0, start);
320
+ return header.subarray(start, end >= start && end < start + length ? end : start + length).toString('utf8');
321
+ }
322
+ function parseTarOctal(value) {
323
+ const text = value.toString('ascii').replace(/\0.*$/, '').trim();
324
+ if (!text) return 0;
325
+ if (!/^[0-7]+$/.test(text)) throw new Error('archive has invalid tar numeric field');
326
+ const parsed = Number.parseInt(text, 8);
327
+ if (!Number.isSafeInteger(parsed)) throw new Error('archive tar numeric field exceeds supported range');
328
+ return parsed;
329
+ }
330
+ function validateTarChecksum(header) {
331
+ const expected = parseTarOctal(header.subarray(148, 156));
332
+ let actual = 0;
333
+ for (let index = 0; index < 512; index++) actual += index >= 148 && index < 156 ? 32 : header[index];
334
+ if (actual !== expected) throw new Error('archive tar header checksum is invalid');
335
+ }
@@ -0,0 +1,121 @@
1
+ const API_ROOT = 'https://api.github.com/repos/B-icy/Carthagent';
2
+ const API_VERSION = '2022-11-28';
3
+ const ASSET_NAMES = new Set([
4
+ 'opencode-linux-x64.tar.gz',
5
+ 'opencode-linux-arm64.tar.gz',
6
+ 'opencode-darwin-x64.tar.gz',
7
+ 'opencode-darwin-arm64.tar.gz',
8
+ 'opencode-windows-x64.tar.gz',
9
+ ]);
10
+ const MAX_RELEASE_PAGES = 20;
11
+ const MAX_ASSET_PAGES = 20;
12
+
13
+ /**
14
+ * Add opt-in authenticated access to one exact Carthagent release asset.
15
+ * The credential is used only for fixed api.github.com GET requests. Redirects
16
+ * are left to the ordinary downloader, which calls this wrapper again without
17
+ * credentials for every non-canonical URL.
18
+ */
19
+ export function createRuntimeGithubFetch({ token, fetchImpl = globalThis.fetch } = {}) {
20
+ if (typeof token !== 'string' || !token.trim()) throw new TypeError('A non-empty GitHub token is required.');
21
+ if (typeof fetchImpl !== 'function') throw new TypeError('GitHub runtime transport requires fetch support.');
22
+
23
+ return async (input, init = {}) => {
24
+ const requested = new URL(input);
25
+ const match = requested.href.match(/^https:\/\/github\.com\/B-icy\/Carthagent\/releases\/download\/v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\/([^/?#]+)$/);
26
+ if (!match || requested.search || requested.hash) return fetchImpl(input, init);
27
+
28
+ const [, version, encodedName] = match;
29
+ let assetName;
30
+ try { assetName = decodeURIComponent(encodedName); }
31
+ catch { throw new Error('Invalid encoded Carthagent runtime asset URL.'); }
32
+ // Non-runtime assets use the ordinary URL transport. Only this strict
33
+ // allowlist is eligible for credentials or draft lookup.
34
+ if (!ASSET_NAMES.has(assetName) || encodeURIComponent(assetName) !== encodedName) return fetchImpl(input, init);
35
+
36
+ const release = await findRelease({ version, token, fetchImpl, signal: init.signal });
37
+ const asset = await findAsset({ release, assetName, token, fetchImpl, signal: init.signal });
38
+ const response = await apiGet(`${API_ROOT}/releases/assets/${asset.id}`, token, fetchImpl, init.signal, 'application/octet-stream');
39
+ // GitHub may stream the bytes or redirect to a signed CDN URL. Keep the
40
+ // redirect manual so the downloader re-requests it without this token.
41
+ return response;
42
+ };
43
+ }
44
+
45
+ async function findRelease({ version, token, fetchImpl, signal }) {
46
+ const expectedTag = `v${version}`;
47
+ const matches = [];
48
+ for (let page = 1; page <= MAX_RELEASE_PAGES; page++) {
49
+ const result = await apiJson(`${API_ROOT}/releases?per_page=100&page=${page}`, token, fetchImpl, signal);
50
+ if (!Array.isArray(result)) throw new Error('GitHub returned invalid release-list metadata.');
51
+ matches.push(...result.filter((item) => item?.tag_name === expectedTag));
52
+ if (result.length < 100) {
53
+ if (matches.length !== 1) throw new Error(matches.length ? 'GitHub returned duplicate exact-version releases.' : 'The exact Carthagent runtime release was not found.');
54
+ const release = matches[0];
55
+ if (!Number.isSafeInteger(release.id) || release.id <= 0) throw new Error('GitHub returned invalid exact-version release metadata.');
56
+ return release;
57
+ }
58
+ }
59
+ throw new Error('GitHub release pagination exceeded its safety limit.');
60
+ }
61
+
62
+ async function findAsset({ release, assetName, token, fetchImpl, signal }) {
63
+ const matches = [];
64
+ for (let page = 1; page <= MAX_ASSET_PAGES; page++) {
65
+ const url = `${API_ROOT}/releases/${release.id}/assets?per_page=100&page=${page}`;
66
+ const result = await apiJson(url, token, fetchImpl, signal);
67
+ if (!Array.isArray(result)) throw new Error('GitHub returned invalid release-asset metadata.');
68
+ matches.push(...result.filter((item) => item?.name === assetName));
69
+ if (result.length < 100) {
70
+ if (matches.length !== 1) throw new Error(matches.length ? `GitHub returned duplicate ${assetName} assets.` : `The exact Carthagent runtime asset ${assetName} was not found.`);
71
+ const asset = matches[0];
72
+ const canonicalAssetUrl = `${API_ROOT}/releases/assets/${asset.id}`;
73
+ if (!Number.isSafeInteger(asset.id) || asset.id <= 0
74
+ || asset.state !== 'uploaded'
75
+ || !Number.isSafeInteger(asset.size) || asset.size <= 0
76
+ || asset.url !== canonicalAssetUrl) {
77
+ throw new Error(`GitHub returned invalid metadata for ${assetName}.`);
78
+ }
79
+ return asset;
80
+ }
81
+ }
82
+ throw new Error('GitHub release-asset pagination exceeded its safety limit.');
83
+ }
84
+
85
+ async function apiJson(url, token, fetchImpl, signal) {
86
+ const response = await apiGet(url, token, fetchImpl, signal, 'application/vnd.github+json');
87
+ if (response.status >= 300 && response.status < 400) {
88
+ await response.body?.cancel().catch(() => {});
89
+ throw new Error('GitHub API unexpectedly redirected a metadata request.');
90
+ }
91
+ if (!response.ok) throw new Error(`GitHub API metadata request failed with HTTP ${response.status}.`);
92
+ try { return await response.json(); }
93
+ catch { throw new Error('GitHub API returned invalid JSON metadata.'); }
94
+ }
95
+
96
+ async function apiGet(url, token, fetchImpl, signal, accept) {
97
+ let parsed;
98
+ try { parsed = new URL(url); }
99
+ catch { throw new Error('Invalid GitHub API request URL.'); }
100
+ if (parsed.origin !== 'https://api.github.com' || !parsed.pathname.startsWith('/repos/B-icy/Carthagent/')) {
101
+ throw new Error('Runtime transport refused a noncanonical GitHub API URL.');
102
+ }
103
+ let response;
104
+ try {
105
+ response = await fetchImpl(parsed, {
106
+ method: 'GET',
107
+ redirect: 'manual',
108
+ ...(signal ? { signal } : {}),
109
+ headers: {
110
+ accept,
111
+ authorization: `Bearer ${token}`,
112
+ 'x-github-api-version': API_VERSION,
113
+ },
114
+ });
115
+ } catch {
116
+ // Do not propagate fetch errors or causes: an injected adapter may include
117
+ // request headers in its diagnostic text.
118
+ throw new Error('GitHub API request failed before a response was received.');
119
+ }
120
+ return response;
121
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "version": 1,
3
+ "releaseVersion": "1.1.0-preview.12",
4
+ "compatibilityVersion": "2.0.32",
5
+ "runtimeVersion": "0.0.0-ctg.2.0.32",
6
+ "sourceRevision": "843a6c514e606b436a7fe713422ace0a93ad3d61",
7
+ "patchSha256": "1f2309bb3a31dfb8e8a0827a6a1144a1f76a005abff58f6f48857639ec02478a",
8
+ "lockSha256": "9d78379cd264584a495440a217b9d2ecab524c9a56f7f8b4b5bed3926daa330d",
9
+ "assets": {
10
+ "linux-x64": {
11
+ "name": "opencode-linux-x64.tar.gz",
12
+ "archiveSha256": "d4c202e0825fc77321ccd23d91a7baede4951a4eb4cee28f258c171e72f03852",
13
+ "executableSha256": "24bcfff03d32db163e0d40c4d0b212eede300a71a3dc26aa23810b9088d52280"
14
+ },
15
+ "linux-arm64": {
16
+ "name": "opencode-linux-arm64.tar.gz",
17
+ "archiveSha256": "c3a1e68a6fd7db5909d2d38639c5b1e8df7a5de64fc40f6cc758211a280ea058",
18
+ "executableSha256": "b73708103d62de570e629a0bf9c2bce9b1a4db0a87a79b1c86c2d2ebe998db7b"
19
+ },
20
+ "darwin-x64": {
21
+ "name": "opencode-darwin-x64.tar.gz",
22
+ "archiveSha256": "d733d269bd06a652c858b62b24e5b78a629400fc687d7dc71a999bb03bfab1d5",
23
+ "executableSha256": "f2bb0e772744bc04b96368d32cc35582a6a1bca0be795897704c9395e5059f37"
24
+ },
25
+ "darwin-arm64": {
26
+ "name": "opencode-darwin-arm64.tar.gz",
27
+ "archiveSha256": "e0a0ac4c7f5b4d14eab8844428d717a9a596c61694b318d0e065080de5a42fda",
28
+ "executableSha256": "9bf83ef1fd9e1af8028a23941a05195f8256847deae271c2b016014d5363605b"
29
+ },
30
+ "windows-x64": {
31
+ "name": "opencode-windows-x64.tar.gz",
32
+ "archiveSha256": "0318a74ba0b67ee5119b4289c0b78124c620676c8770e48c24d347513e96e2aa",
33
+ "executableSha256": "b057ce293492cff6dabf3939d12072798f53d8b4cf7ab944fd97ef0bb0879bb9"
34
+ }
35
+ }
36
+ }