carthagent 1.0.7 → 1.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/beta-rewards-e2e-plan.md +48 -0
  4. package/docs/beta-rewards-e2e-results.md +84 -0
  5. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  6. package/docs/glyph-review/test-isolation-v1.md +26 -0
  7. package/docs/glyph-review/typecheck-base.log +1 -0
  8. package/docs/glyph-review/typecheck-v4.log +1 -0
  9. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  10. package/docs/help-dispatch-review.md +69 -0
  11. package/docs/launch-readiness-live-results.md +65 -0
  12. package/docs/legacy-cli.md +150 -0
  13. package/docs/live-theme-plugin-api.md +18 -0
  14. package/docs/native-cli.md +102 -0
  15. package/docs/native-compatibility.md +16 -0
  16. package/docs/native-daily-quests.md +132 -0
  17. package/docs/native-glyph-pty.md +59 -0
  18. package/docs/native-manual-testing.md +336 -0
  19. package/docs/native-slash-acceptance-handoff.md +49 -0
  20. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  21. package/docs/native-terminal-performance.md +310 -0
  22. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  23. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  24. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  35. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  36. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  37. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  38. package/docs/native-terminal-semantics.md +89 -0
  39. package/docs/native-upgrade.md +175 -0
  40. package/docs/opencode-composed-source-review.md +89 -0
  41. package/docs/opencode-migration.md +225 -9
  42. package/docs/opencode-owned-runtime.md +241 -0
  43. package/docs/opencode-runtime.md +12 -0
  44. package/docs/opencode-theme-provider-proof/README.md +55 -0
  45. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  46. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  47. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  48. package/docs/owned-host-controls-proof.md +39 -0
  49. package/docs/owned-runtime-release-manifest.md +42 -0
  50. package/docs/owned-visual-clear-proof.md +40 -0
  51. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  52. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  53. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  54. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  61. package/docs/release-notes-beta.1.md +60 -0
  62. package/docs/release-notes-native-draft.md +73 -0
  63. package/docs/release-notes-preview.10.md +121 -0
  64. package/docs/release-notes-preview.11.md +76 -0
  65. package/docs/release-notes-preview.12.md +55 -0
  66. package/docs/release-notes-preview.13.md +68 -0
  67. package/docs/release-notes-preview.9.md +130 -0
  68. package/docs/source-preview.md +120 -0
  69. package/evaluate.mjs +28 -1
  70. package/lib/budget.mjs +1 -1
  71. package/lib/codegraph.mjs +1 -1
  72. package/lib/delivery-review.mjs +19 -6
  73. package/lib/delivery.mjs +3 -2
  74. package/lib/environment.mjs +8 -0
  75. package/lib/guidance.mjs +1 -1
  76. package/lib/opencode/cli-runtime.mjs +1171 -0
  77. package/lib/opencode/cli.mjs +252 -0
  78. package/lib/opencode/credentials.mjs +63 -8
  79. package/lib/opencode/demo.mjs +53 -0
  80. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  81. package/lib/opencode/evaluation.mjs +32 -12
  82. package/lib/opencode/headless.mjs +22 -5
  83. package/lib/opencode/provider-policy.mjs +23 -0
  84. package/lib/opencode/review.mjs +37 -16
  85. package/lib/opencode/runtime-artifact.mjs +335 -0
  86. package/lib/opencode/runtime-github-transport.mjs +121 -0
  87. package/lib/opencode/runtime-release.json +36 -0
  88. package/lib/opencode/runtime.mjs +299 -20
  89. package/lib/opencode/service.mjs +77 -35
  90. package/lib/opencode/session-options.mjs +6 -2
  91. package/lib/opencode/update.mjs +222 -0
  92. package/lib/opencode/workspace.mjs +1 -1
  93. package/lib/planning-advice.mjs +2 -2
  94. package/lib/providers/experiential.mjs +2 -2
  95. package/lib/tui/framing.mjs +1 -80
  96. package/lib/update.mjs +10 -1
  97. package/package.json +33 -7
  98. package/plugins/carthagent/README.md +23 -0
  99. package/plugins/carthagent/src/bootstrap.ts +1 -1
  100. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  101. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  102. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  103. package/plugins/carthagent/src/index.ts +71 -24
  104. package/plugins/carthagent/src/rpc.ts +51 -6
  105. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  106. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  107. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  108. package/plugins/carthagent/src/server/delivery.ts +227 -112
  109. package/plugins/carthagent/src/server/framing.mjs +80 -0
  110. package/plugins/carthagent/src/server/policy.ts +1 -1
  111. package/plugins/carthagent/src/server/providers.ts +59 -31
  112. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  113. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  114. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  115. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  116. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  117. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  118. package/plugins/carthagent/src/testing.ts +5 -0
  119. package/plugins/carthagent/src/tui.tsx +496 -0
  120. package/plugins/carthagent/src/ui/client.ts +127 -0
  121. package/plugins/carthagent/src/ui/commands.ts +70 -0
  122. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  123. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  124. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  125. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  126. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  127. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  128. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  129. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  130. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  131. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  132. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  133. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  134. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  135. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  136. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  137. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  138. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  139. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  140. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  141. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  142. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  143. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  144. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  145. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  146. package/plugins/carthagent/tests/commands.test.ts +107 -0
  147. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  148. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  149. package/plugins/carthagent/themes/carthage.json +67 -0
  150. package/plugins/carthagent/tui.ts +26 -1
  151. package/script/ctg/build-owned-runtime.sh +166 -0
  152. package/tests/budget-engine.test.mjs +4 -3
  153. package/tests/cli.test.mjs +52 -96
  154. package/tests/daily-evidence.test.mjs +62 -0
  155. package/tests/delivery-review.test.mjs +57 -2
  156. package/tests/delivery.test.mjs +22 -1
  157. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  158. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  159. package/tests/installed-runtime-contract.test.mjs +43 -0
  160. package/tests/native-cli-host-smoke.mjs +457 -0
  161. package/tests/native-daily-editor-focus.test.mjs +213 -0
  162. package/tests/native-daily-editor.test.mjs +43 -0
  163. package/tests/native-daily-quests-layout.test.mjs +55 -0
  164. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  165. package/tests/native-daily-quests-ui.test.mjs +778 -0
  166. package/tests/native-daily-source.test.mjs +525 -0
  167. package/tests/native-demo-ui.test.mjs +48 -0
  168. package/tests/native-evaluation-admission.test.mjs +24 -0
  169. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  170. package/tests/native-fixture-runtime.test.mjs +111 -0
  171. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  172. package/tests/native-glyph-pty.test.mjs +107 -0
  173. package/tests/native-manual-projects.test.mjs +170 -0
  174. package/tests/native-model-command.test.mjs +67 -0
  175. package/tests/native-owned-runtime-env.test.mjs +140 -0
  176. package/tests/native-paired-guards.test.mjs +464 -0
  177. package/tests/native-provider-policy.test.mjs +31 -0
  178. package/tests/native-pty-invocation.test.mjs +114 -0
  179. package/tests/native-pty-runtime.test.mjs +45 -0
  180. package/tests/native-resume-session.test.mjs +46 -0
  181. package/tests/native-review-consumer.test.mjs +286 -0
  182. package/tests/native-runtime-identity.test.mjs +214 -0
  183. package/tests/native-select-mouse.test.mjs +23 -0
  184. package/tests/native-ship-guidance.test.mjs +16 -0
  185. package/tests/native-ship-parity.test.mjs +526 -0
  186. package/tests/native-ship-plan.test.mjs +85 -0
  187. package/tests/native-ship-rpc.test.mjs +71 -0
  188. package/tests/native-ui-commands.test.mjs +6 -0
  189. package/tests/native-voyages-layout.test.mjs +28 -0
  190. package/tests/native-voyages-navigation.test.mjs +169 -0
  191. package/tests/native-voyages-rpc.test.mjs +397 -0
  192. package/tests/native-voyages-ui.test.mjs +307 -0
  193. package/tests/opencode-atomic-write.test.mjs +55 -0
  194. package/tests/opencode-cli.test.mjs +1312 -0
  195. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  196. package/tests/opencode-delivery.test.mjs +16 -0
  197. package/tests/opencode-providers.test.mjs +245 -18
  198. package/tests/opencode-runtime.test.mjs +597 -8
  199. package/tests/opencode-session-controls.test.mjs +15 -0
  200. package/tests/opencode-ship-rpc.test.mjs +71 -0
  201. package/tests/opencode-update.test.mjs +475 -0
  202. package/tests/opencode-workspace.test.mjs +51 -2
  203. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  204. package/tests/release-workflow.test.mjs +46 -0
  205. package/tests/runtime-artifact.test.mjs +269 -0
  206. package/tests/runtime-development-trust.test.mjs +231 -0
  207. package/tests/runtime-github-transport.test.mjs +132 -0
  208. package/tests/runtime-token-env.test.mjs +23 -0
  209. package/tests/server.test.mjs +3 -1
  210. package/tests/ship-telemetry.test.mjs +1 -1
  211. package/tests/source-preview.test.mjs +253 -0
  212. package/tests/test_native_terminal_probe.py +76 -0
  213. package/tests/test_native_terminal_semantics.py +232 -0
  214. package/tests/update.test.mjs +42 -5
  215. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  216. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  217. package/tests/workspace-live.test.mjs +127 -0
  218. package/tools/benchmark/diagnose-request.mjs +59 -36
  219. package/tools/benchmark/eval-common.mjs +3 -3
  220. package/tools/benchmark/evaluators.test.mjs +4 -4
  221. package/tools/benchmark/guard.test.mjs +35 -2
  222. package/tools/benchmark/guard.ts +20 -7
  223. package/tools/benchmark/planning-startup.test.mjs +30 -34
  224. package/tools/benchmark/runner.mjs +12 -7
  225. package/tools/benchmark/runner.test.mjs +23 -7
  226. package/tools/benchmark/upstream-error.test.mjs +16 -2
  227. package/tools/generate-owned-runtime-release.mjs +217 -0
  228. package/tools/installed-runtime-contract.mjs +16 -0
  229. package/tools/manual-runtime-manifest.mjs +39 -0
  230. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  231. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  232. package/tools/native-fixture.mjs +199 -40
  233. package/tools/native-glyph-helpers.py +127 -0
  234. package/tools/native-glyph-pty.py +144 -0
  235. package/tools/native-manual-projects.mjs +259 -0
  236. package/tools/native-manual.mjs +338 -0
  237. package/tools/native-model-picker-acceptance.py +306 -0
  238. package/tools/native-owned-runtime-env.sh +47 -0
  239. package/tools/native-paired-guards.mjs +282 -0
  240. package/tools/native-paired-manifest.mjs +72 -0
  241. package/tools/native-paired-orchestration.sh +51 -0
  242. package/tools/native-plugin-readiness.mjs +33 -0
  243. package/tools/native-plugin-readiness.test.mjs +53 -0
  244. package/tools/native-pty-invocation.mjs +26 -0
  245. package/tools/native-pty-runtime.mjs +83 -0
  246. package/tools/native-pty-smoke.py +147 -24
  247. package/tools/native-public-repo-shell-result.mjs +13 -0
  248. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  249. package/tools/native-runtime-identity.mjs +201 -0
  250. package/tools/native-slash-acceptance-env.py +38 -0
  251. package/tools/native-terminal-semantics.py +876 -0
  252. package/tools/owned-shell-observer/index.ts +16 -0
  253. package/tools/owned-shell-observer/package.json +1 -0
  254. package/tools/owned-visual-clear-env.py +48 -0
  255. package/tools/owned-visual-clear-model.py +21 -0
  256. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  257. package/tools/prepare-native-manual.mjs +122 -0
  258. package/tools/run-native-terminal-paired.sh +403 -0
  259. package/tools/runtime-token-env.mjs +8 -0
  260. package/tools/seed-native-switch-fixture.mjs +79 -0
  261. package/tools/test-native-controls.mjs +3 -1
  262. package/tools/test-native-daily-quests.mjs +65 -0
  263. package/tools/test-native-default-cli.mjs +106 -0
  264. package/tools/test-native-delivery-host.mjs +102 -20
  265. package/tools/test-native-evaluation.mjs +7 -3
  266. package/tools/test-native-glyph-pty.mjs +94 -0
  267. package/tools/test-native-host.mjs +87 -63
  268. package/tools/test-native-outcomes.mjs +3 -1
  269. package/tools/test-native-package.mjs +206 -38
  270. package/tools/test-native-performance.mjs +26 -0
  271. package/tools/test-native-product.mjs +90 -0
  272. package/tools/test-native-pty.mjs +34 -9
  273. package/tools/test-native-public-repo.mjs +224 -0
  274. package/tools/test-native-refresh-reentry.mjs +106 -0
  275. package/tools/test-native-report.mjs +27 -20
  276. package/tools/test-native-review.mjs +75 -0
  277. package/tools/test-native-runtime.mjs +120 -0
  278. package/tools/test-native-ship-models.mjs +176 -0
  279. package/tools/test-native-ship-walkthrough.mjs +96 -0
  280. package/tools/test-native-slash-commands.mjs +125 -0
  281. package/tools/test-native-slash-commands.py +242 -0
  282. package/tools/test-native-swe-host.mjs +224 -55
  283. package/tools/test-native-tasks.mjs +77 -0
  284. package/tools/test-native-terminal-performance.py +887 -0
  285. package/tools/test-native-terminal-readiness.sh +85 -0
  286. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  287. package/tools/test-native-visual-clear-installed.mjs +48 -0
  288. package/tools/test-owned-host-controls.mjs +238 -0
  289. package/tools/test-owned-opencode-runtime.mjs +35 -0
  290. package/tools/test-owned-visual-clear-env.py +120 -0
  291. package/tools/test-owned-visual-clear.py +373 -0
  292. package/tools/verify-draft-runtime-gate.mjs +138 -0
  293. package/tools/verify-owned-runtime-release.mjs +114 -0
  294. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,47 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createJiti } from 'jiti';
7
+
8
+ const jiti = createJiti(import.meta.url);
9
+ const { registerCodegraph } = await jiti.import('../plugins/carthagent/src/server/codegraph.ts');
10
+
11
+ test('native codegraph scan consumes the resolved per-session max-files setting', async t => {
12
+ const root = await mkdtemp(join(tmpdir(), 'ctg-native-codegraph-cap-'));
13
+ t.after(() => rm(root, { recursive: true, force: true }));
14
+ await mkdir(join(root, 'src'));
15
+ await writeFile(join(root, 'src/a.js'), 'export function alpha() {}\n');
16
+ await writeFile(join(root, 'src/b.js'), 'export function beta() {}\n');
17
+ await writeFile(join(root, 'src/c.js'), 'export function gamma() {}\n');
18
+ let registered;
19
+ const sessions = {
20
+ capped: { location: { directory: root }, metadata: { 'carthagent.options': { codegraphMaxFiles: 1 } } },
21
+ expanded: { location: { directory: root }, metadata: { 'carthagent.options': { codegraphMaxFiles: 3 } } },
22
+ default: { location: { directory: root } },
23
+ };
24
+ const ctx = {
25
+ options: {},
26
+ tool: { transform: async callback => {
27
+ callback({ namespace() {}, add(tool) { registered = tool; } });
28
+ return { dispose: async () => {} };
29
+ } },
30
+ session: { get: async ({ sessionID }) => sessions[sessionID] },
31
+ };
32
+ const dispose = await registerCodegraph(ctx, { maxFiles: 2 });
33
+ t.after(dispose);
34
+ const status = async sessionID => JSON.parse((await registered.execute({ op: 'status' }, { sessionID })).content).coverage;
35
+ assert.deepEqual(
36
+ (({ files, discovered, skipped, complete }) => ({ files, discovered, skipped, complete }))(await status('capped')),
37
+ { files: 1, discovered: 3, skipped: 2, complete: false },
38
+ );
39
+ assert.deepEqual(
40
+ (({ files, discovered, skipped, complete }) => ({ files, discovered, skipped, complete }))(await status('expanded')),
41
+ { files: 3, discovered: 3, skipped: 0, complete: true },
42
+ );
43
+ assert.deepEqual(
44
+ (({ files, discovered, skipped, complete }) => ({ files, discovered, skipped, complete }))(await status('default')),
45
+ { files: 2, discovered: 3, skipped: 1, complete: false },
46
+ );
47
+ });
@@ -40,6 +40,22 @@ function host(t) {
40
40
  return { ctx, tools, hooks, commands, stores, directory, session, sessions, interrupts }
41
41
  }
42
42
 
43
+ test('delivery_review coverage schema accepts assertion aliases models emit', async t => {
44
+ const h = host(t)
45
+ await registerDelivery(h.ctx, { workflow: 'standard' })
46
+ const item = h.tools.get('delivery_review').input.properties.coverage.items
47
+ assert.deepEqual(item.required, ['requirement'])
48
+ assert.deepEqual(item.properties.assertions.anyOf.map(s => s.type), ['string', 'array'])
49
+ assert.equal(item.properties.assertion.type, 'string')
50
+ })
51
+
52
+ test('code-mode catalog always lists the delivery_plan entry point', async t => {
53
+ const h = host(t)
54
+ await registerDelivery(h.ctx, { workflow: 'standard' })
55
+ const pinned = [...h.tools.values()].filter(def => def.options?.pinned).map(def => def.name)
56
+ assert.deepEqual(pinned, ['delivery_plan'])
57
+ })
58
+
43
59
  test('OpenCode V2 registration exposes domain tools and returns complete cleanup', async t => {
44
60
  const h = host(t)
45
61
  const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
@@ -1,17 +1,164 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readLegacyCarthagentCredential, refreshSingleFlight, refreshNativeCredential, credentialStorageKey, nativeCredentialFile } from '../lib/opencode/credentials.mjs';
3
+ import { readLegacyCarthagentCredential, refreshSingleFlight, refreshNativeCredential, credentialStorageKey, nativeCredentialFile, nativeCredentialDatabaseFile, readNativeOAuthCredential } from '../lib/opencode/credentials.mjs';
4
4
  import { createSessionIdentity, managedRequestIdentity, shipTelemetryHeaders } from '../plugins/carthagent/src/server/telemetry.ts';
5
5
  import { registerProviders, providerConstants } from '../plugins/carthagent/src/server/providers.ts';
6
6
  import { createDeviceAuthController } from '../plugins/carthagent/src/server/device-auth.ts';
7
7
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
8
8
  import { createHash } from 'node:crypto';
9
9
  import os from 'node:os';
10
- import { join } from 'node:path';
10
+ import { join, resolve } from 'node:path';
11
11
  import { spawn } from 'node:child_process';
12
+ import { DatabaseSync } from 'node:sqlite';
13
+ import { acquireFileLock } from '../lib/opencode/file-lock.mjs';
12
14
 
13
15
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
16
 
17
+ test('provider shares actual legacy auth.json coordination lease and rejects old unbound receipt before HTTP', async t => {
18
+ const directory = await mkdtemp(join(os.tmpdir(), 'ctg-refresh-legacy-path-'));
19
+ let db;
20
+ t.after(async () => {
21
+ // after hooks run FIFO: close SQLite before Windows unlinks its files.
22
+ try { db?.close(); } finally { await rm(directory, { recursive: true, force: true }); }
23
+ });
24
+ const env = { XDG_DATA_HOME: directory, OPENCODE_DB: join(directory, 'native.sqlite') };
25
+ const vaultFile = nativeCredentialDatabaseFile(env, 'ctg.2.0.32');
26
+ const coordinationFile = nativeCredentialFile(env);
27
+ assert.notEqual(vaultFile, coordinationFile);
28
+ db = new DatabaseSync(vaultFile);
29
+ db.exec('CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT)');
30
+ const credential = { type: 'oauth', methodID: 'carthagent-device', access: 'fixture-a', refresh: 'fixture-old', expires: 1 };
31
+ db.prepare('INSERT INTO credential VALUES (?, ?, ?)').run('A', 'experiential-labs', JSON.stringify(credential));
32
+ const digest = createHash('sha256').update(`${credential.methodID}\0${credential.refresh}`).digest('hex').slice(0, 32);
33
+ const lock = `${coordinationFile}.ctg-refresh-${digest}.lock`;
34
+ // This is exactly the legacy helper's path, acquired by its unchanged lock
35
+ // implementation. A new provider must wait on it, not a SQLite sibling.
36
+ const release = await acquireFileLock(lock);
37
+ t.after(release);
38
+ let httpCalls = 0;
39
+ const fake = fakeContext();
40
+ const cleanup = await registerProviders(fake.ctx, { env, fetchImpl: async () => { httpCalls++; throw Error('HTTP must not run'); } });
41
+ t.after(cleanup);
42
+ const result = fake.integrationTransforms[0][0].refresh(credential).then(() => 'unexpected success', error => error);
43
+ await new Promise(resolve => setTimeout(resolve, 100));
44
+ assert.equal(httpCalls, 0, 'new provider must honor the old process lease');
45
+ const receipt = JSON.stringify({ pid: process.pid, owner: 'legacy-owner', from: digest, to: 'legacy-rotation', stage: 'complete', createdAt: Date.now() });
46
+ await writeFile(`${lock}.pending`, receipt, { mode: 0o600 });
47
+ await release();
48
+ const error = await result;
49
+ assert.match(error.message, /Unbound or different native credential refresh receipt/);
50
+ assert.equal(httpCalls, 0, 'old receipt at actual legacy namespace must block rotation');
51
+ assert.equal(await readFile(`${lock}.pending`, 'utf8'), receipt, 'do not discard uncertain legacy receipt');
52
+ await assert.rejects(readFile(`${vaultFile}.ctg-refresh-${digest}.lock.pending`), { code: 'ENOENT' });
53
+ await assert.rejects(readFile(coordinationFile), { code: 'ENOENT' }, 'coordination must not create an alternate credential store');
54
+ });
55
+
56
+ test('native vault paths follow runtime channel, XDG data and explicit database overrides', () => {
57
+ assert.equal(nativeCredentialDatabaseFile({ XDG_DATA_HOME: '/data' }, 'ctg.2.0.32'), resolve('/data', 'opencode', 'opencode-ctg.2.0.32.db'));
58
+ assert.equal(nativeCredentialDatabaseFile({ XDG_DATA_HOME: '/data', OPENCODE_DB: '/private/vault.sqlite' }, 'dev'), resolve('/private/vault.sqlite'));
59
+ assert.equal(nativeCredentialDatabaseFile({ XDG_DATA_HOME: '/data', OPENCODE_DB: 'custom.sqlite' }, 'dev'), resolve('/data', 'opencode', 'custom.sqlite'));
60
+ assert.throws(() => nativeCredentialDatabaseFile({}, undefined), /channel is required/);
61
+ assert.throws(() => nativeCredentialDatabaseFile({ OPENCODE_DB: ':memory:' }, 'dev'), /file-backed/);
62
+ });
63
+
64
+ test('expired native resolution refreshes once without reentering resolve and preserves native persistence', { timeout: 5000 }, async t => {
65
+ const directory = await mkdtemp(join(os.tmpdir(), 'ctg-provider-reentry-'));
66
+ let db;
67
+ t.after(async () => {
68
+ try { db?.close(); } finally { await rm(directory, { recursive: true, force: true }); }
69
+ });
70
+ const credentialFile = join(directory, 'native.sqlite');
71
+ db = new DatabaseSync(credentialFile);
72
+ db.exec('CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT)');
73
+ const expired = { type: 'oauth', methodID: 'carthagent-device', access: 'expired', refresh: 'old-refresh', expires: 1 };
74
+ db.prepare('INSERT INTO credential VALUES (?, ?, ?)').run('cred_fixture', 'experiential-labs', JSON.stringify(expired));
75
+ const connection = { type: 'credential', id: 'cred_fixture', method: 'oauth', label: 'fixture' };
76
+ const fake = fakeContext();
77
+ let resolves = 0;
78
+ let refreshes = 0;
79
+ const cleanup = await registerProviders(fake.ctx, { credentialFile, env: { CARTHAGENT_CLOUD_URL: 'https://cloud.test' },
80
+ fetchImpl: async (url, init) => {
81
+ assert.equal(String(url), 'https://cloud.test/v1/token/refresh');
82
+ assert.ok(init.signal, 'refresh fetch must have a bounded abort signal');
83
+ assert.equal(JSON.parse(init.body).refreshToken, 'old-refresh');
84
+ refreshes++;
85
+ return Response.json({ accessToken: 'rotated-access', refreshToken: 'rotated-refresh', expiresIn: 3600 });
86
+ } });
87
+ t.after(cleanup);
88
+ const method = fake.integrationTransforms[0][0];
89
+ fake.ctx.integration.connection.active = async id => id === 'experiential-labs' ? connection : undefined;
90
+ fake.ctx.integration.connection.resolve = async () => {
91
+ resolves++;
92
+ assert.ok(resolves <= 2, 'refresh reread must not call resolve recursively');
93
+ const stored = JSON.parse(db.prepare('SELECT value FROM credential WHERE id = ?').get(connection.id).value);
94
+ if (stored.expires > Date.now() + 300000) return stored;
95
+ const rotated = await method.refresh(stored);
96
+ db.prepare('UPDATE credential SET value = ? WHERE id = ?').run(JSON.stringify(rotated), connection.id);
97
+ return rotated;
98
+ };
99
+ const results = await Promise.all([fake.ctx.integration.connection.resolve(connection), fake.ctx.integration.connection.resolve(connection)]);
100
+ assert.equal(refreshes, 1);
101
+ assert.equal(resolves, 2);
102
+ assert.equal(results[0].access, 'rotated-access');
103
+ assert.deepEqual(results[0], results[1]);
104
+ assert.equal(JSON.parse(db.prepare('SELECT value FROM credential').get().value).refresh, 'rotated-refresh');
105
+ assert.equal(fake.storage.size, 0, 'plugin storage must not contain credentials');
106
+ db.prepare('UPDATE credential SET value = ?').run('{"type":"oauth","refresh":"secret-invalid"}');
107
+ await assert.rejects(readNativeOAuthCredential({ credentialFile, credentialID: connection.id, integrationID: 'experiential-labs', methodID: 'carthagent-device' }), error => {
108
+ assert.doesNotMatch(error.message, /secret-invalid/);
109
+ return /Unsupported native credential/.test(error.message);
110
+ });
111
+ });
112
+
113
+ test('refresh pins original A despite active B before lock and during receipt wait; legacy receipts fail closed', async t => {
114
+ const directory = await mkdtemp(join(os.tmpdir(), 'ctg-refresh-account-'));
115
+ let db;
116
+ t.after(async () => {
117
+ try { db?.close(); } finally { await rm(directory, { recursive: true, force: true }); }
118
+ });
119
+ const credentialFile = join(directory, 'native.sqlite');
120
+ db = new DatabaseSync(credentialFile);
121
+ db.exec('CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT)');
122
+ const a = { type: 'oauth', methodID: 'carthagent-device', access: 'a', refresh: 'refresh-a', expires: 1 };
123
+ const b = { ...a, access: 'b', refresh: 'refresh-b' };
124
+ const insert = (id, value) => db.prepare('INSERT INTO credential VALUES (?, ?, ?)').run(id, 'experiential-labs', JSON.stringify(value));
125
+ insert('A', a); insert('B', b);
126
+ const fake = fakeContext();
127
+ const cleanup = await registerProviders(fake.ctx, { credentialFile, env: {}, fetchImpl: async () => Response.json({ accessToken: 'new-a', refreshToken: 'new-refresh-a', expiresIn: 3600 }) });
128
+ t.after(cleanup);
129
+ // Any refresh-time active lookup would select the wrong account.
130
+ fake.ctx.integration.connection.active = async () => ({ type: 'credential', id: 'B' });
131
+ const method = fake.integrationTransforms[0][0];
132
+ const result = await method.refresh(a);
133
+ assert.equal(result.access, 'new-a');
134
+ db.prepare('UPDATE credential SET value = ? WHERE id = ?').run(JSON.stringify(result), 'A');
135
+ assert.deepEqual(JSON.parse(db.prepare('SELECT value FROM credential WHERE id = ?').get('B').value), b);
136
+ const digest = createHash('sha256').update(`${a.methodID}\0${a.refresh}`).digest('hex').slice(0, 32);
137
+ const pending = `${credentialFile}.ctg-refresh-${digest}.lock.pending`;
138
+ await rm(pending);
139
+ db.prepare('UPDATE credential SET value = ? WHERE id = ?').run(JSON.stringify(a), 'A');
140
+ const capture = await readNativeOAuthCredential({ credentialFile, integrationID: 'experiential-labs', methodID: a.methodID, refreshToken: a.refresh });
141
+ assert.equal(capture, 'A');
142
+ await writeFile(pending, JSON.stringify({ pid: process.pid, owner: 'fixture', from: digest, stage: 'complete', createdAt: Date.now(), credentialID: capture }));
143
+ let reads = 0;
144
+ const waited = await refreshNativeCredential({ credential: a, credentialFile, credentialID: capture, timeout: 500,
145
+ reread: async () => {
146
+ if (++reads === 3) db.prepare('UPDATE credential SET value = ? WHERE id = ?').run(JSON.stringify(result), 'A');
147
+ return readNativeOAuthCredential({ credentialFile, credentialID: capture, integrationID: 'experiential-labs', methodID: a.methodID });
148
+ }, refresh: () => { throw Error('must not rotate twice'); } });
149
+ assert.equal(waited.access, 'new-a');
150
+ assert.ok(reads >= 3);
151
+ await writeFile(pending, JSON.stringify({ pid: process.pid, owner: 'old-host', from: digest, stage: 'complete', createdAt: Date.now() }));
152
+ await assert.rejects(refreshNativeCredential({ credential: a, credentialFile, credentialID: capture,
153
+ reread: async () => result, refresh: () => { throw Error('must not refresh'); } }), /Unbound or different/);
154
+ await rm(pending);
155
+ db.prepare('UPDATE credential SET value = ? WHERE id = ?').run(JSON.stringify(a), 'A');
156
+ insert('duplicate-A', a);
157
+ await assert.rejects(method.refresh(a), /Ambiguous native OAuth credential identity/);
158
+ db.prepare('DELETE FROM credential WHERE id IN (?, ?)').run('A', 'duplicate-A');
159
+ await assert.rejects(method.refresh(a), /no longer stored/);
160
+ });
161
+
15
162
  function fakeContext({ integrations = ['experiential-labs', 'anthropic', 'openai-codex'], connections = {}, failAt, disposeFailure } = {}) {
16
163
  const hooks = [];
17
164
  const events = [];
@@ -35,10 +182,13 @@ function fakeContext({ integrations = ['experiential-labs', 'anthropic', 'openai
35
182
  } };
36
183
  };
37
184
  const ctx = {
185
+ app: { name: 'fixture', version: '2.0.32', channel: 'ctg.2.0.32' },
38
186
  provider: {
39
187
  async transform(callback) {
40
188
  const models = new Map();
41
189
  const editor = {
190
+ list: () => [],
191
+ remove() {},
42
192
  add(record) { providerTransforms.push(record); },
43
193
  get(id) { return integrations.includes(id) ? { provider: { id }, models: new Map() } : undefined; },
44
194
  models: { set(id, value) { models.set(id, value); } },
@@ -120,46 +270,66 @@ test('parallel token refresh callers share one rotation result and failures can
120
270
  assert.equal(await refreshSingleFlight('credential-2', async () => 'recovered'), 'recovered');
121
271
  });
122
272
 
123
- test('native-vault refresh rereads after cross-process lease acquisition and rotates once', async t => {
273
+ test('native SQLite vault rereads after cross-process lease acquisition and rotates once', async t => {
124
274
  const directory = await mkdtemp(join(os.tmpdir(), 'ctg-refresh-vault-'));
125
- t.after(() => rm(directory, { recursive: true, force: true }));
126
- const vault = join(directory, 'auth.json');
127
- const profile = join(directory, 'profile');
128
- await writeFile(vault, JSON.stringify({ refresh: 'issued-old-token', access: 'old-access' }), { mode: 0o600 });
275
+ let db;
276
+ const children = [];
277
+ const childClosures = [];
278
+ t.after(async () => {
279
+ // Stop and reap workers even when an assertion fails before outputsPromise.
280
+ for (const child of children) {
281
+ if (child.exitCode === null && child.signalCode === null) child.kill();
282
+ }
283
+ await Promise.all(childClosures);
284
+ try { db?.close(); } finally { await rm(directory, { recursive: true, force: true }); }
285
+ });
286
+ const vault = join(directory, 'native.sqlite');
287
+ const profile = vault;
288
+ db = new DatabaseSync(vault);
289
+ db.exec('PRAGMA journal_mode=WAL; CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT)');
290
+ db.prepare('INSERT INTO credential VALUES (?, ?, ?)').run('cred_fixture', 'experiential-labs', JSON.stringify({ type: 'oauth', methodID: 'carthagent-device', refresh: 'issued-old-token', access: 'old-access', expires: 1 }));
129
291
  const moduleUrl = new URL('../lib/opencode/credentials.mjs', import.meta.url).href;
130
292
  const worker = join(directory, 'worker.mjs');
131
293
  await writeFile(worker, `
132
- import { readFile, rename, writeFile } from 'node:fs/promises';
133
- import { refreshNativeCredential } from ${JSON.stringify(moduleUrl)};
294
+ import { readFile, writeFile } from 'node:fs/promises';
295
+ import { DatabaseSync } from 'node:sqlite';
296
+ import { refreshNativeCredential, readNativeOAuthCredential } from ${JSON.stringify(moduleUrl)};
134
297
  const [vault, profile, name, gate, rotating, resultFile] = process.argv.slice(2);
135
298
  while (true) { try { await readFile(gate); break; } catch { await new Promise(resolve => setTimeout(resolve, 5)); } }
136
299
  let rotatedByThisWorker = false;
137
300
  const result = await refreshNativeCredential({
138
301
  credential: { type: 'oauth', methodID: 'carthagent-device', refresh: 'issued-old-token', access: 'old-access' },
139
302
  credentialFile: profile,
140
- reread: async () => ({ type: 'oauth', methodID: 'carthagent-device', ...JSON.parse(await readFile(vault, 'utf8')) }),
303
+ credentialID: 'cred_fixture',
304
+ reread: () => readNativeOAuthCredential({ credentialFile: vault, credentialID: 'cred_fixture', integrationID: 'experiential-labs', methodID: 'carthagent-device' }),
141
305
  refresh: async current => {
142
306
  if (current.refresh !== 'issued-old-token') throw new Error('unexpected stale vault read');
143
307
  await writeFile(rotating, name, { flag: 'wx' });
144
308
  const rotated = { refresh: 'rotated-token', access: 'new-access' };
145
309
  rotatedByThisWorker = true;
146
- return { type: 'oauth', methodID: 'carthagent-device', ...rotated };
310
+ return { type: 'oauth', methodID: 'carthagent-device', expires: Date.now() + 3600000, ...rotated };
147
311
  },
148
312
  });
149
313
  // Model the native host: the same process that owns the refresh callback
150
314
  // persists its result atomically before exiting. Do not let the parent
151
315
  // mutate the vault after observing this worker's exit.
152
316
  if (rotatedByThisWorker) {
153
- const temporary = vault + '.' + name + '.tmp';
154
- await writeFile(temporary, JSON.stringify({ refresh: result.refresh, access: result.access }), { mode: 0o600 });
155
- await rename(temporary, vault);
317
+ // Deliberately expose the callback-return/native-write gap to contender.
318
+ await new Promise(resolve => setTimeout(resolve, 150));
319
+ const db = new DatabaseSync(vault);
320
+ db.prepare('UPDATE credential SET value = ? WHERE id = ?').run(JSON.stringify(result), 'cred_fixture');
321
+ db.close();
156
322
  }
157
323
  await writeFile(resultFile, JSON.stringify({ refresh: result.refresh, access: result.access }));
158
324
  console.log(JSON.stringify({ name, refreshed: true }));
159
325
  `);
160
326
  const rotating = join(directory, 'rotating');
161
327
  const resultFiles = ['one', 'two'].map(name => join(directory, `${name}.result`));
162
- const children = ['one', 'two'].map((name, index) => spawn(process.execPath, [worker, vault, profile, name, join(directory, 'go'), rotating, resultFiles[index]], { stdio: ['ignore', 'pipe', 'pipe'] }));
328
+ for (const [index, name] of ['one', 'two'].entries()) {
329
+ const child = spawn(process.execPath, [worker, vault, profile, name, join(directory, 'go'), rotating, resultFiles[index]], { stdio: ['ignore', 'pipe', 'pipe'] });
330
+ children.push(child);
331
+ childClosures.push(new Promise(resolve => child.once('close', resolve)));
332
+ }
163
333
  const outputsPromise = Promise.all(children.map(child => new Promise((resolve, reject) => {
164
334
  let stdout = '';
165
335
  let stderr = '';
@@ -182,9 +352,11 @@ test('native-vault refresh rereads after cross-process lease acquisition and rot
182
352
  for (const resultFile of resultFiles) {
183
353
  assert.deepEqual(JSON.parse(await readFile(resultFile, 'utf8')), { refresh: 'rotated-token', access: 'new-access' });
184
354
  }
185
- assert.deepEqual(JSON.parse(await readFile(vault, 'utf8')), { refresh: 'rotated-token', access: 'new-access' });
355
+ const persisted = await readNativeOAuthCredential({ credentialFile: vault, credentialID: 'cred_fixture', integrationID: 'experiential-labs', methodID: 'carthagent-device' });
356
+ assert.equal(persisted.refresh, 'rotated-token');
357
+ assert.equal(persisted.access, 'new-access');
186
358
  const lockFiles = await (await import('node:fs/promises')).readdir(directory);
187
- assert.equal(lockFiles.some(entry => entry.startsWith('profile.ctg-refresh-') && entry.endsWith('.lock')), false, 'completed lease should be released');
359
+ assert.equal(lockFiles.some(entry => entry.includes('.ctg-refresh-') && entry.endsWith('.lock')), false, 'completed lease should be released');
188
360
  assert.equal(lockFiles.filter(entry => entry.endsWith('.lock.pending')).length, 0, 'the pending receipt is removed after the native vault shows the matching rotation');
189
361
  for (const entry of lockFiles) {
190
362
  if (!entry.includes('ctg-refresh-')) continue;
@@ -209,7 +381,7 @@ test('native refresh waits are bounded and never retry an uncertain rotation', a
209
381
  timeout: 80,
210
382
  reread: async () => credential,
211
383
  refresh: async () => { refreshCalls++; return credential; },
212
- }), /Timed out waiting for OpenCode to persist/);
384
+ }), /Timed out waiting for Carthagent runtime to persist/);
213
385
  assert.equal(refreshCalls, 0, 'a contender must not submit the old single-use token again');
214
386
  const receipt = await readFile(pending, 'utf8');
215
387
  assert.doesNotMatch(receipt, /issued-old-token|old-access/);
@@ -351,6 +523,61 @@ test('legacy credential migration reads Ship OAuth and BYOK without writing Pi s
351
523
  assert.throws(() => credentialStorageKey('../auth'), /Invalid/);
352
524
  });
353
525
 
526
+ test('native sign-in starts fresh device authorization after opt-in legacy migration and on reconnect', async t => {
527
+ const directory = await mkdtemp(join(os.tmpdir(), 'ctg-signin-'));
528
+ t.after(() => rm(directory, { recursive: true, force: true }));
529
+ const legacyFile = join(directory, 'auth.json');
530
+ const source = JSON.stringify({ 'experiential-labs': { type: 'oauth', access: 'legacy-access', refresh: 'legacy-refresh', expires: 123 } });
531
+ await writeFile(legacyFile, source, { mode: 0o600 });
532
+ const connections = {};
533
+ const fake = fakeContext({ connections });
534
+ const requests = [];
535
+ const browserUrls = [];
536
+ let generation = 0;
537
+ const cleanup = await registerProviders(fake.ctx, {
538
+ env: { CARTHAGENT_CLOUD_URL: 'https://cloud.test' }, agentDir: directory, now: () => 1000,
539
+ openBrowser: url => { browserUrls.push(url); return false; },
540
+ fetchImpl: async (url, init) => {
541
+ requests.push({ url: String(url), body: JSON.parse(init.body) });
542
+ if (String(url).endsWith('/v1/device/authorizations')) {
543
+ generation++;
544
+ return Response.json({ deviceCode: `device-${generation}`, userCode: `CODE-${generation}`,
545
+ verificationUri: 'https://verify.test', verificationUriComplete: `https://verify.test/${generation}`, expiresIn: 60, interval: 1 });
546
+ }
547
+ assert.equal(String(url), 'https://cloud.test/v1/device/token');
548
+ const device = JSON.parse(init.body).deviceCode;
549
+ return Response.json({ accessToken: `access-${device}`, refreshToken: `refresh-${device}`, expiresIn: 3600 });
550
+ },
551
+ });
552
+ t.after(cleanup);
553
+ const method = fake.integrationTransforms[0][0];
554
+ assert.equal(method.method.form[0].default, false);
555
+ const imported = await method.authorize({ importLegacy: true });
556
+ assert.deepEqual(await imported.callback, { type: 'oauth', methodID: 'carthagent-device', access: 'legacy-access', refresh: 'legacy-refresh', expires: 123 });
557
+ assert.equal(requests.length, 0, 'one-way compatibility import remains available without browser authorization');
558
+
559
+ // Model the native host committing callback results to its active connection.
560
+ // The plugin must not create a second credential store or modify legacy auth.
561
+ connections['experiential-labs'] = { connection: { id: 'native-ship', credential: await imported.callback } };
562
+ for (const expected of [1, 2]) {
563
+ const authorization = await method.authorize({});
564
+ assert.equal(authorization.mode, 'auto');
565
+ assert.equal(authorization.url, `https://verify.test/${expected}`);
566
+ assert.equal(authorization.expiresAt, 61_000);
567
+ assert.match(authorization.instructions, new RegExp(`CODE-${expected}`));
568
+ const credential = await authorization.callback;
569
+ assert.deepEqual(credential, { type: 'oauth', methodID: 'carthagent-device',
570
+ access: `access-device-${expected}`, refresh: `refresh-device-${expected}`, expires: 3_601_000 });
571
+ connections['experiential-labs'].connection.credential = credential;
572
+ assert.deepEqual(await fake.ctx.integration.connection.resolve(await fake.ctx.integration.connection.active('experiential-labs')), credential);
573
+ }
574
+ assert.deepEqual(browserUrls, ['https://verify.test/1', 'https://verify.test/2']);
575
+ assert.equal(requests.filter(request => request.url.endsWith('/v1/device/authorizations')).length, 2);
576
+ assert.equal(requests.filter(request => request.url.endsWith('/v1/device/token')).length, 2);
577
+ assert.equal(fake.storage.size, 0, 'credentials are returned to native storage, not plugin storage');
578
+ assert.equal(await readFile(legacyFile, 'utf8'), source, 'migration and reconnect leave legacy credentials untouched');
579
+ });
580
+
354
581
  test('provider registration uses native provider transforms and cleanup; refresh discovers subscriptions through injected APIs', async () => {
355
582
  const token = `x.${Buffer.from(JSON.stringify({ sub: 'acct', sid: 'sid', scope: 'gateway:invoke' })).toString('base64url')}.x`;
356
583
  const connections = {