carthagent 1.0.7 → 1.1.0-beta.2
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.
- package/README.md +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/beta-rewards-e2e-plan.md +48 -0
- package/docs/beta-rewards-e2e-results.md +84 -0
- package/docs/cloud-onboarding-acceptance.md +65 -0
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/launch-readiness-live-results.md +65 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +175 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-beta.1.md +60 -0
- package/docs/release-notes-beta.2.md +60 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/cloud/client.mjs +13 -7
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +19 -6
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1188 -0
- package/lib/opencode/cli.mjs +253 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +22 -5
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/providers/names.mjs +1 -1
- package/lib/tui/framing.mjs +1 -80
- package/lib/tui/models.mjs +1 -1
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +65 -36
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +71 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +506 -0
- package/plugins/carthagent/src/ui/client.ts +127 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
- package/plugins/carthagent/src/ui/voyages.tsx +131 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +64 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/cloud-client.test.mjs +2 -2
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- package/tests/delivery.test.mjs +22 -1
- package/tests/experiential-extension.test.mjs +2 -2
- package/tests/experiential.test.mjs +1 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +55 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +525 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +33 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-guidance.test.mjs +16 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +83 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +397 -0
- package/tests/native-voyages-ui.test.mjs +307 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +248 -20
- package/tests/opencode-runtime.test.mjs +597 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +231 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/tui.test.mjs +2 -2
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +45 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +882 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +91 -65
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- 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
|
|
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
|
-
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
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,
|
|
133
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
await
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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', CARTHAGENT_ADVANCED_AUTH: '1' }, 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 = {
|
|
@@ -374,9 +601,10 @@ test('provider registration uses native provider transforms and cleanup; refresh
|
|
|
374
601
|
openBrowser: () => false, requestSecret: 'test-secret',
|
|
375
602
|
});
|
|
376
603
|
assert.equal(typeof cleanup, 'function');
|
|
377
|
-
assert.deepEqual(fake.integrationTransforms[0].map(item => item.integrationID), ['experiential-labs', 'experiential-labs'
|
|
604
|
+
assert.deepEqual(fake.integrationTransforms[0].map(item => item.integrationID), ['experiential-labs', 'experiential-labs']);
|
|
378
605
|
assert.equal(fake.integrationTransforms[0][0].method.id, 'carthagent-device');
|
|
379
|
-
assert.equal(fake.integrationTransforms[0][
|
|
606
|
+
assert.equal(fake.integrationTransforms[0][0].method.form, undefined, 'fresh sign-in has no migration question');
|
|
607
|
+
assert.equal(fake.integrationTransforms[0][1].method.type, 'env');
|
|
380
608
|
assert.equal(fake.providerTransforms.find(item => item.info?.id === providerConstants.SHIP_PROVIDER_ID).info.package,
|
|
381
609
|
'@opencode/ai/providers/openai-compatible');
|
|
382
610
|
assert.ok(fake.hooks.some(item => item.name === 'http.request' && item.options.providerID === 'experiential-labs'));
|