carthagent 1.0.7 → 1.1.0-preview.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -79
- package/bin/ctg.mjs +14 -732
- 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/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 +314 -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 +177 -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.patch +5137 -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.9.md +130 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -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 +8 -4
- 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 +298 -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/tui/framing.mjs +1 -80
- 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 +197 -0
- package/plugins/carthagent/src/server/delivery.ts +217 -106
- 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 +58 -30
- 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 +39 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +492 -0
- package/plugins/carthagent/src/ui/client.ts +125 -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 +232 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +31 -0
- package/plugins/carthagent/src/ui/native-review.mjs +154 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -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 +122 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
- package/plugins/carthagent/src/ui/voyages.tsx +127 -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 +60 -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 +258 -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/daily-evidence.test.mjs +62 -0
- package/tests/delivery.test.mjs +22 -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 +53 -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 +426 -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-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -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 +267 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +75 -0
- package/tests/native-ship-rpc.test.mjs +71 -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 +395 -0
- package/tests/native-voyages-ui.test.mjs +277 -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-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +546 -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 +230 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +36 -0
- 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 +43 -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 +876 -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 +100 -18
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +83 -61
- 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,45 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { selectNativePtyRuntime } from '../tools/native-pty-runtime.mjs';
|
|
5
|
+
|
|
6
|
+
test('injected owned artifact is independently preflighted before production runtime resolution', async () => {
|
|
7
|
+
const calls = [];
|
|
8
|
+
const runtime = {
|
|
9
|
+
async resolveOpenCode(options) { calls.push(['resolve', options]); return '/fixture/owned/cli-linux-x64/bin/opencode'; },
|
|
10
|
+
async runtimeIdentity() { calls.push(['identity']); return {}; },
|
|
11
|
+
};
|
|
12
|
+
const identityTools = {
|
|
13
|
+
async preflightNativeTestRuntimeArtifact(options) {
|
|
14
|
+
calls.push(['preflight', options]);
|
|
15
|
+
throw new Error('trusted digest mismatch');
|
|
16
|
+
},
|
|
17
|
+
async loadNativeTestRuntime() { calls.push(['verify']); },
|
|
18
|
+
};
|
|
19
|
+
await assert.rejects(selectNativePtyRuntime({
|
|
20
|
+
runtime,
|
|
21
|
+
env: { CTG_TEST_RUNTIME_ARTIFACT_ROOT: '/fixture/owned', CTG_TEST_RUNTIME_RELEASE_MANIFEST: '/trusted/release.json' },
|
|
22
|
+
identityTools,
|
|
23
|
+
}), /trusted digest mismatch/);
|
|
24
|
+
assert.deepEqual(calls.map(([operation]) => operation), ['preflight']);
|
|
25
|
+
assert.equal(calls[0][1].ownedRoot, resolve('/fixture/owned'));
|
|
26
|
+
assert.equal(calls[0][1].expectedManifestPath, '/trusted/release.json');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('ambient owned-runtime selector is rejected as an untrusted acceptance root', async () => {
|
|
30
|
+
let resolved = false;
|
|
31
|
+
await assert.rejects(selectNativePtyRuntime({
|
|
32
|
+
runtime: { async resolveOpenCode() { resolved = true; }, async runtimeIdentity() {} },
|
|
33
|
+
env: { CTG_OWNED_OPENCODE_RUNTIME: '/ambient/staging/runtime' },
|
|
34
|
+
}), /not an acceptance trust root/);
|
|
35
|
+
assert.equal(resolved, false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('explicit developer binary requires an independently supplied digest before runtime resolution', async () => {
|
|
39
|
+
let resolved = false;
|
|
40
|
+
await assert.rejects(selectNativePtyRuntime({
|
|
41
|
+
runtime: { async resolveOpenCode() { resolved = true; }, async runtimeIdentity() {} },
|
|
42
|
+
env: { CTG_OPENCODE_BIN: '/missing/native/opencode' },
|
|
43
|
+
}), /requires CTG_NATIVE_PTY_EXPECTED_SHA256/);
|
|
44
|
+
assert.equal(resolved, false);
|
|
45
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { findNativeResumeSession } from '../plugins/carthagent/src/ui/resume-session.mjs';
|
|
4
|
+
|
|
5
|
+
test('resume finds ID prefixes outside the server title-search results', async () => {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const byPrefix = { id: 'ses_abc123', title: 'An unrelated session title', time: { updated: 2 } };
|
|
8
|
+
const byTitle = { id: 'ses_xyz789', title: 'Parser implementation', time: { updated: 1 } };
|
|
9
|
+
const client = {
|
|
10
|
+
session: {
|
|
11
|
+
async get() { throw new Error('not an exact ID'); },
|
|
12
|
+
async list(input) {
|
|
13
|
+
calls.push(input);
|
|
14
|
+
const rows = input.search ? [byTitle] : [byPrefix];
|
|
15
|
+
return { data: rows, cursor: { next: null } };
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
assert.equal((await findNativeResumeSession({ query: 'ses_abc', directory: 'repo', client })).id, byPrefix.id);
|
|
20
|
+
assert.equal(calls[0].search, undefined, 'ID prefix lookup first uses unfiltered listing');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('resume falls back to title search after an unfiltered ID-prefix miss', async () => {
|
|
24
|
+
const calls = [];
|
|
25
|
+
const byTitle = { id: 'ses_xyz789', title: 'Parser implementation', time: { updated: 1 } };
|
|
26
|
+
const client = {
|
|
27
|
+
session: {
|
|
28
|
+
async get() { throw new Error('not an exact ID'); },
|
|
29
|
+
async list(input) {
|
|
30
|
+
calls.push(input);
|
|
31
|
+
return { data: input.search ? [byTitle] : [], cursor: { next: null } };
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
assert.equal((await findNativeResumeSession({ query: 'parser', directory: 'repo', client })).id, byTitle.id);
|
|
36
|
+
assert.equal(calls.length, 2);
|
|
37
|
+
assert.equal(calls[1].search, 'parser');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('resume rejects sessions from another project location', async () => {
|
|
41
|
+
const client = { session: {
|
|
42
|
+
get: async () => ({ id: 'ses_exact', location: { directory: 'other' } }),
|
|
43
|
+
list: async () => ({ data: [], cursor: { next: null } }),
|
|
44
|
+
} };
|
|
45
|
+
assert.equal(await findNativeResumeSession({ query: 'ses_exact', directory: 'repo', client }), undefined);
|
|
46
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { reviewKickoff } from '../lib/review.mjs';
|
|
4
|
+
import { createNativeReviewConsumer, nativeReviewMode, sendNativeReviewKickoff, subscribeNativeReviewEvents } from '../plugins/carthagent/src/ui/native-review.mjs';
|
|
5
|
+
|
|
6
|
+
const execution = (id, sessionID) => ({ id, data: { sessionID } });
|
|
7
|
+
const changed = [{ file: 'src/change.ts', additions: 1, deletions: 0, status: 'modified', patch: '@@ change' }];
|
|
8
|
+
|
|
9
|
+
function makeDiff() {
|
|
10
|
+
const calls = new Map();
|
|
11
|
+
return async (sessionID, signal) => {
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
const count = (calls.get(sessionID) ?? 0) + 1;
|
|
14
|
+
calls.set(sessionID, count);
|
|
15
|
+
return count % 2 === 1 ? [] : changed;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('native prompt payload is the legacy PR-review kickoff and only bypasses framing', async () => {
|
|
20
|
+
let request;
|
|
21
|
+
const signal = new AbortController().signal;
|
|
22
|
+
await sendNativeReviewKickoff({
|
|
23
|
+
client: { session: { async prompt(input, options) { request = { input, options }; } } },
|
|
24
|
+
sessionID: 'working-session', signal,
|
|
25
|
+
});
|
|
26
|
+
assert.equal(request.input.sessionID, 'working-session');
|
|
27
|
+
assert.equal(request.input.text, reviewKickoff());
|
|
28
|
+
assert.match(request.input.text, /gh pr create/);
|
|
29
|
+
assert.match(request.input.text, /ctg\.mjs" review <pr-number-or-url>/);
|
|
30
|
+
assert.deepEqual(request.input.metadata, { 'ctg.raw': true, 'ctg.reviewKickoff': true });
|
|
31
|
+
assert.equal(request.options.signal, signal);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('session explicit review mode overrides profile; launch mode overrides both', () => {
|
|
35
|
+
const session = { metadata: { 'carthagent.options': { review: 'yes' } } };
|
|
36
|
+
assert.equal(nativeReviewMode(session, { review: 'no' }), 'yes');
|
|
37
|
+
assert.equal(nativeReviewMode(session, { review: 'yes' }, 'no'), 'no');
|
|
38
|
+
assert.equal(nativeReviewMode({}, { review: 'no' }), 'no');
|
|
39
|
+
assert.equal(nativeReviewMode({}, {}), 'ask');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('ask confirmation is current-run-bound and one-shot; a later run invalidates the old offer', async () => {
|
|
43
|
+
const offers = [];
|
|
44
|
+
const kickoffs = [];
|
|
45
|
+
const consumer = createNativeReviewConsumer({
|
|
46
|
+
mode: () => 'ask', diff: makeDiff(), offer: async (offer) => offers.push(offer),
|
|
47
|
+
kickoff: async (sessionID) => kickoffs.push(sessionID),
|
|
48
|
+
});
|
|
49
|
+
consumer.onStarted(execution('run-1', 'session-a'));
|
|
50
|
+
await consumer.onSucceeded(execution('run-1', 'session-a'));
|
|
51
|
+
assert.equal(offers.length, 1);
|
|
52
|
+
consumer.onStarted(execution('run-2', 'session-a'));
|
|
53
|
+
assert.equal(await offers[0].start(), false, 'older confirmation cannot start after a new run');
|
|
54
|
+
await consumer.onSucceeded(execution('run-2', 'session-a'));
|
|
55
|
+
assert.equal(offers.length, 2);
|
|
56
|
+
assert.equal(await offers[1].start(), true);
|
|
57
|
+
assert.equal(await offers[1].start(), false, 'confirmation closure admits exactly once');
|
|
58
|
+
assert.deepEqual(kickoffs, ['session-a']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('yes kicks off only successful new-change runs and suppresses only that session loop', async () => {
|
|
62
|
+
const loops = new Set();
|
|
63
|
+
const started = [];
|
|
64
|
+
const consumer = createNativeReviewConsumer({
|
|
65
|
+
mode: () => 'yes', diff: makeDiff(), offer: async () => assert.fail('yes does not prompt'),
|
|
66
|
+
kickoff: async (sessionID) => started.push(sessionID),
|
|
67
|
+
isLoopActive: (sessionID) => loops.has(sessionID),
|
|
68
|
+
setLoopActive: (sessionID, active) => active ? loops.add(sessionID) : loops.delete(sessionID),
|
|
69
|
+
});
|
|
70
|
+
consumer.onStarted(execution('run-a1', 'session-a'));
|
|
71
|
+
consumer.onStarted(execution('run-b1', 'session-b'));
|
|
72
|
+
await Promise.all([
|
|
73
|
+
consumer.onSucceeded(execution('run-a1', 'session-a')),
|
|
74
|
+
consumer.onSucceeded(execution('run-b1', 'session-b')),
|
|
75
|
+
]);
|
|
76
|
+
assert.deepEqual(started.sort(), ['session-a', 'session-b'], 'one session loop must not suppress an unrelated session');
|
|
77
|
+
assert.ok(loops.has('session-a') && loops.has('session-b'));
|
|
78
|
+
consumer.onUserPromptAdmitted('session-a', { type: 'user', metadata: {} });
|
|
79
|
+
assert.equal(loops.has('session-a'), false, 'admitted user prompt exits the session loop state');
|
|
80
|
+
assert.equal(loops.has('session-b'), true, 'unrelated session loop state remains active');
|
|
81
|
+
consumer.onStarted(execution('run-a2', 'session-a'));
|
|
82
|
+
consumer.onStarted(execution('run-b2', 'session-b'));
|
|
83
|
+
await Promise.all([
|
|
84
|
+
consumer.onSucceeded(execution('run-a2', 'session-a')),
|
|
85
|
+
consumer.onSucceeded(execution('run-b2', 'session-b')),
|
|
86
|
+
]);
|
|
87
|
+
assert.equal(started.length, 3, 'only session-a may re-enter a loop after a new user prompt');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('inbox.delivered resets restored loop only for the admitted message, not auto-review kickoff', async () => {
|
|
91
|
+
const persisted = new Set(['session-restored', 'session-kickoff']);
|
|
92
|
+
const messages = new Map();
|
|
93
|
+
const consumer = createNativeReviewConsumer({
|
|
94
|
+
mode: () => 'no', diff: async () => [], offer: async () => {}, kickoff: async () => {},
|
|
95
|
+
isLoopActive: (sessionID) => persisted.has(sessionID),
|
|
96
|
+
setLoopActive: (sessionID, active) => active ? persisted.add(sessionID) : persisted.delete(sessionID),
|
|
97
|
+
});
|
|
98
|
+
const handlers = new Map();
|
|
99
|
+
const cleanups = subscribeNativeReviewEvents(
|
|
100
|
+
{ on(type, handler) { handlers.set(type, handler); return () => {}; } },
|
|
101
|
+
consumer,
|
|
102
|
+
assert.fail,
|
|
103
|
+
async (sessionID, messageID) => messages.get(`${sessionID}:${messageID}`),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
messages.set('session-restored:user-2', { type: 'user', metadata: {} });
|
|
107
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-restored', inboxID: 'user-2' } });
|
|
108
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
109
|
+
assert.equal(persisted.has('session-restored'), false, 'restored session loop state is explicitly persisted as cleared');
|
|
110
|
+
messages.set('session-kickoff:review-1', { type: 'user', metadata: { 'ctg.raw': true, 'ctg.reviewKickoff': true } });
|
|
111
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-kickoff', inboxID: 'review-1' } });
|
|
112
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
113
|
+
assert.equal(persisted.has('session-kickoff'), true, 'review kickoff does not clear its own loop suppression');
|
|
114
|
+
|
|
115
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
116
|
+
consumer.dispose();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('execution.started does not use stale cached message state; inbox deliveries distinguish kickoff then user and coalesced work', async () => {
|
|
120
|
+
const persisted = new Set(['session-a']);
|
|
121
|
+
const messages = new Map([
|
|
122
|
+
['session-a:kickoff', { type: 'user', metadata: { 'ctg.raw': true, 'ctg.reviewKickoff': true } }],
|
|
123
|
+
['session-a:user-1', { type: 'user', metadata: {} }],
|
|
124
|
+
['session-a:user-2', { type: 'user', metadata: {} }],
|
|
125
|
+
['session-a:assistant', { type: 'assistant', metadata: {} }],
|
|
126
|
+
]);
|
|
127
|
+
const writes = [];
|
|
128
|
+
const consumer = createNativeReviewConsumer({
|
|
129
|
+
mode: () => 'no', diff: async () => [], offer: async () => {}, kickoff: async () => {},
|
|
130
|
+
isLoopActive: (sessionID) => persisted.has(sessionID),
|
|
131
|
+
setLoopActive: (sessionID, active) => { writes.push([sessionID, active]); if (active) persisted.add(sessionID); else persisted.delete(sessionID); },
|
|
132
|
+
});
|
|
133
|
+
const handlers = new Map();
|
|
134
|
+
const cleanups = subscribeNativeReviewEvents(
|
|
135
|
+
{ on(type, handler) { handlers.set(type, handler); return () => {}; } }, consumer, assert.fail,
|
|
136
|
+
async (sessionID, messageID) => messages.get(`${sessionID}:${messageID}`),
|
|
137
|
+
);
|
|
138
|
+
// The execution-start lifecycle fires first; it must not inspect an old
|
|
139
|
+
// cached message and reset this restored loop.
|
|
140
|
+
handlers.get('session.execution.started')(execution('run-1', 'session-a'));
|
|
141
|
+
assert.deepEqual(writes, []);
|
|
142
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-a', inboxID: 'kickoff' } });
|
|
143
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
144
|
+
assert.deepEqual(writes, [], 'kickoff delivery preserves suppression');
|
|
145
|
+
|
|
146
|
+
// Multiple queued user messages are distinct admissions even if the host
|
|
147
|
+
// coalesces them into one later execution.started event.
|
|
148
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-a', inboxID: 'user-1' } });
|
|
149
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-a', inboxID: 'user-2' } });
|
|
150
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
151
|
+
assert.deepEqual(writes, [['session-a', false]], 'restored state is persisted cleared once on real user admission');
|
|
152
|
+
assert.equal(persisted.has('session-a'), false);
|
|
153
|
+
|
|
154
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-a', inboxID: 'assistant' } });
|
|
155
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
156
|
+
assert.deepEqual(writes, [['session-a', false]], 'non-user delivered message is ignored');
|
|
157
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
158
|
+
consumer.dispose();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('terminal handling waits for exact inbox admission lookup before deciding loop suppression', async () => {
|
|
162
|
+
const persisted = new Set(['session-race']);
|
|
163
|
+
const kickoffs = [];
|
|
164
|
+
const handlers = new Map();
|
|
165
|
+
let resolveMessage;
|
|
166
|
+
const consumer = createNativeReviewConsumer({
|
|
167
|
+
mode: () => 'yes', diff: makeDiff(), offer: async () => assert.fail(),
|
|
168
|
+
kickoff: async (sessionID) => kickoffs.push(sessionID),
|
|
169
|
+
isLoopActive: (sessionID) => persisted.has(sessionID),
|
|
170
|
+
setLoopActive: (sessionID, active) => active ? persisted.add(sessionID) : persisted.delete(sessionID),
|
|
171
|
+
});
|
|
172
|
+
const cleanups = subscribeNativeReviewEvents(
|
|
173
|
+
{ on(type, handler) { handlers.set(type, handler); return () => {}; } }, consumer, assert.fail,
|
|
174
|
+
async (_sessionID, _messageID, lookupSignal) => await new Promise((resolve) => {
|
|
175
|
+
resolveMessage = resolve;
|
|
176
|
+
lookupSignal.addEventListener('abort', () => resolve(undefined), { once: true });
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
handlers.get('session.execution.started')(execution('run-race', 'session-race'));
|
|
180
|
+
const admission = handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-race', inboxID: 'admitted-user' } });
|
|
181
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
182
|
+
const terminal = handlers.get('session.execution.succeeded')(execution('run-race', 'session-race'));
|
|
183
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
184
|
+
assert.deepEqual(kickoffs, [], 'success waits while admitted-message metadata is unresolved');
|
|
185
|
+
resolveMessage({ type: 'user', metadata: {} });
|
|
186
|
+
await admission;
|
|
187
|
+
await terminal;
|
|
188
|
+
assert.equal(kickoffs.length, 1, 'the admitted user prompt clears restored suppression before successful-run handling');
|
|
189
|
+
assert.equal(persisted.has('session-race'), true, 'the changed successful run starts its next review loop');
|
|
190
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
191
|
+
consumer.dispose();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('inbox lookup queue preserves event order, times out, and disposal cancels pending metadata lookup', async () => {
|
|
195
|
+
const loops = new Set(['session-ordered', 'session-disposed']);
|
|
196
|
+
const handlers = new Map();
|
|
197
|
+
const requested = [];
|
|
198
|
+
let resolveStale;
|
|
199
|
+
const consumer = createNativeReviewConsumer({
|
|
200
|
+
mode: () => 'no', diff: async () => [], offer: async () => {}, kickoff: async () => {},
|
|
201
|
+
isLoopActive: (sessionID) => loops.has(sessionID),
|
|
202
|
+
setLoopActive: (sessionID, active) => active ? loops.add(sessionID) : loops.delete(sessionID),
|
|
203
|
+
});
|
|
204
|
+
const cleanups = subscribeNativeReviewEvents(
|
|
205
|
+
{ on(type, handler) { handlers.set(type, handler); return () => {}; } }, consumer, assert.fail,
|
|
206
|
+
async (_sessionID, messageID, signal) => {
|
|
207
|
+
requested.push(messageID);
|
|
208
|
+
if (messageID === 'stale-slow') return await new Promise((resolve) => { resolveStale = resolve; });
|
|
209
|
+
if (messageID === 'kickoff-slow') await new Promise((resolve) => setTimeout(resolve, 30));
|
|
210
|
+
if (messageID === 'pending') await new Promise((resolve) => setTimeout(resolve, 50));
|
|
211
|
+
signal.throwIfAborted();
|
|
212
|
+
return { type: 'user', metadata: messageID === 'kickoff-slow' ? { 'ctg.reviewKickoff': true } : {} };
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-ordered', inboxID: 'kickoff-slow' } });
|
|
216
|
+
handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-ordered', inboxID: 'normal-next' } });
|
|
217
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
218
|
+
assert.deepEqual(requested.slice(0, 2), ['kickoff-slow', 'normal-next']);
|
|
219
|
+
assert.equal(loops.has('session-ordered'), false, 'later normal prompt wins after earlier kickoff lookup completes');
|
|
220
|
+
|
|
221
|
+
const staleLookup = handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-stale', inboxID: 'stale-slow' } });
|
|
222
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
223
|
+
consumer.setLoopActive('session-stale', true);
|
|
224
|
+
resolveStale({ type: 'user', metadata: {} });
|
|
225
|
+
await staleLookup;
|
|
226
|
+
assert.equal(loops.has('session-stale'), true, 'a lookup started before a newer loop generation cannot clear it');
|
|
227
|
+
|
|
228
|
+
const pendingLookup = handlers.get('session.inbox.delivered')({ data: { sessionID: 'session-disposed', inboxID: 'pending' } });
|
|
229
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
230
|
+
consumer.dispose();
|
|
231
|
+
await pendingLookup;
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
233
|
+
assert.equal(loops.has('session-disposed'), true, 'a lookup completing after disposal cannot mutate persisted state');
|
|
234
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('failed/interrupted and unchanged executions do not launch a workflow', async () => {
|
|
238
|
+
const starts = [];
|
|
239
|
+
const consumer = createNativeReviewConsumer({
|
|
240
|
+
mode: () => 'yes', diff: async () => [], offer: async () => assert.fail(),
|
|
241
|
+
kickoff: async (sessionID) => starts.push(sessionID),
|
|
242
|
+
});
|
|
243
|
+
consumer.onStarted(execution('failed', 'failed'));
|
|
244
|
+
await consumer.onFailed(execution('failed', 'failed'));
|
|
245
|
+
consumer.onStarted(execution('interrupted', 'interrupted'));
|
|
246
|
+
await consumer.onInterrupted(execution('interrupted', 'interrupted'));
|
|
247
|
+
consumer.onStarted(execution('unchanged', 'unchanged'));
|
|
248
|
+
await consumer.onSucceeded(execution('unchanged', 'unchanged'));
|
|
249
|
+
assert.deepEqual(starts, []);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('host execution subscriptions are disposed and plugin shutdown blocks pending review admission', async () => {
|
|
253
|
+
const handlers = new Map();
|
|
254
|
+
const unsubscribed = [];
|
|
255
|
+
const data = { on(type, handler) { handlers.set(type, handler); return () => unsubscribed.push(type); } };
|
|
256
|
+
const offers = [];
|
|
257
|
+
const consumer = createNativeReviewConsumer({ mode: () => 'ask', diff: makeDiff(), offer: async (value) => offers.push(value), kickoff: async () => {} });
|
|
258
|
+
const cleanups = subscribeNativeReviewEvents(data, consumer);
|
|
259
|
+
handlers.get('session.execution.started')(execution('run', 'session'));
|
|
260
|
+
handlers.get('session.execution.succeeded')(execution('run', 'session'));
|
|
261
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
262
|
+
assert.equal(offers.length, 1);
|
|
263
|
+
consumer.dispose();
|
|
264
|
+
assert.equal(await offers[0].start(), false);
|
|
265
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
266
|
+
assert.equal(unsubscribed.length, 5);
|
|
267
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import {
|
|
9
|
+
loadNativeTestRuntime,
|
|
10
|
+
preflightNativeTestRuntimeArtifact,
|
|
11
|
+
NATIVE_TEST_COMPATIBILITY_VERSION,
|
|
12
|
+
} from '../tools/native-runtime-identity.mjs';
|
|
13
|
+
|
|
14
|
+
const payload = Buffer.from('reviewed fixture executable bytes');
|
|
15
|
+
const digest = createHash('sha256').update(payload).digest('hex');
|
|
16
|
+
const sourceRevision = '843a6c514e606b436a7fe713422ace0a93ad3d61';
|
|
17
|
+
const patchSha256 = 'a'.repeat(64);
|
|
18
|
+
const lockSha256 = 'b'.repeat(64);
|
|
19
|
+
const runtimeVersion = '0.0.0-ctg.2.0.32';
|
|
20
|
+
const platform = process.platform === 'win32' ? `windows-${process.arch}` : `${process.platform}-${process.arch}`;
|
|
21
|
+
|
|
22
|
+
async function fixture(t, overrides = {}) {
|
|
23
|
+
const root = await mkdtemp(join(tmpdir(), 'ctg-native-identity-'));
|
|
24
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
25
|
+
const ownedRoot = join(root, 'owned');
|
|
26
|
+
const executableName = platform.startsWith('windows-') ? 'opencode.exe' : 'opencode';
|
|
27
|
+
const binary = join(ownedRoot, `cli-${platform}`, 'bin', executableName);
|
|
28
|
+
await mkdir(join(ownedRoot, `cli-${platform}`, 'bin'), { recursive: true });
|
|
29
|
+
await writeFile(binary, payload);
|
|
30
|
+
const manifest = {
|
|
31
|
+
owner: 'Carthagent',
|
|
32
|
+
sourceRepository: 'https://github.com/anomalyco/opencode',
|
|
33
|
+
compatibilityVersion: NATIVE_TEST_COMPATIBILITY_VERSION,
|
|
34
|
+
runtimeVersion,
|
|
35
|
+
sourceRevision,
|
|
36
|
+
patchSha256,
|
|
37
|
+
lockSha256,
|
|
38
|
+
channel: `ctg.${NATIVE_TEST_COMPATIBILITY_VERSION}`,
|
|
39
|
+
platform,
|
|
40
|
+
webUI: false,
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
await writeFile(join(ownedRoot, 'ctg-runtime.json'), JSON.stringify(manifest));
|
|
44
|
+
const release = {
|
|
45
|
+
version: 1,
|
|
46
|
+
releaseVersion: '1.2.3',
|
|
47
|
+
compatibilityVersion: NATIVE_TEST_COMPATIBILITY_VERSION,
|
|
48
|
+
runtimeVersion,
|
|
49
|
+
sourceRevision,
|
|
50
|
+
patchSha256,
|
|
51
|
+
lockSha256,
|
|
52
|
+
assets: {
|
|
53
|
+
[platform]: {
|
|
54
|
+
name: `opencode-${platform}.tar.gz`,
|
|
55
|
+
archiveSha256: 'c'.repeat(64),
|
|
56
|
+
executableSha256: digest,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { binary, ownedRoot, release };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function versionSpawner(version) {
|
|
64
|
+
return (_binary, args, options) => {
|
|
65
|
+
assert.deepEqual(args, ['--version']);
|
|
66
|
+
assert.equal(options.windowsHide, true);
|
|
67
|
+
const child = new EventEmitter();
|
|
68
|
+
child.stdout = new EventEmitter();
|
|
69
|
+
child.stderr = new EventEmitter();
|
|
70
|
+
queueMicrotask(() => {
|
|
71
|
+
child.stdout.emit('data', `${version}\n`);
|
|
72
|
+
child.emit('close', 0);
|
|
73
|
+
});
|
|
74
|
+
return child;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function versionSpawnerWithEnv(version, observeEnv) {
|
|
79
|
+
return (_binary, args, options) => {
|
|
80
|
+
assert.deepEqual(args, ['--version']);
|
|
81
|
+
assert.equal(options.windowsHide, true);
|
|
82
|
+
observeEnv(options.env);
|
|
83
|
+
const child = new EventEmitter();
|
|
84
|
+
child.stdout = new EventEmitter();
|
|
85
|
+
child.stderr = new EventEmitter();
|
|
86
|
+
queueMicrotask(() => {
|
|
87
|
+
child.stdout.emit('data', `${version}\n`);
|
|
88
|
+
child.emit('close', 0);
|
|
89
|
+
});
|
|
90
|
+
return child;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test('accepts exactly the upstream compatibility CLI identity', async t => {
|
|
95
|
+
const { binary } = await fixture(t);
|
|
96
|
+
assert.deepEqual(await loadNativeTestRuntime({ binary, spawnImpl: versionSpawner('opencode v2.0.32') }), {
|
|
97
|
+
binary,
|
|
98
|
+
compatibilityVersion: '2.0.32',
|
|
99
|
+
runtimeVersion: '2.0.32',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('independent native version verification removes only the draft transport credential from child env', async t => {
|
|
104
|
+
const { binary, ownedRoot, release } = await fixture(t);
|
|
105
|
+
let childEnv;
|
|
106
|
+
await loadNativeTestRuntime({
|
|
107
|
+
binary,
|
|
108
|
+
ownedRoot,
|
|
109
|
+
expectedManifest: release,
|
|
110
|
+
env: { PATH: '/fixture/path', LANG: 'C.UTF-8', CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token' },
|
|
111
|
+
spawnImpl: versionSpawnerWithEnv(`opencode v${runtimeVersion}`, env => { childEnv = env; }),
|
|
112
|
+
});
|
|
113
|
+
assert.equal(childEnv.CTG_RUNTIME_GITHUB_TOKEN, undefined);
|
|
114
|
+
assert.equal(childEnv.PATH, '/fixture/path');
|
|
115
|
+
assert.equal(childEnv.LANG, 'C.UTF-8');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('accepts the CTG runtime only with pinned provenance and independently expected hash', async t => {
|
|
119
|
+
const { binary, ownedRoot, release } = await fixture(t);
|
|
120
|
+
const runtime = await loadNativeTestRuntime({
|
|
121
|
+
binary,
|
|
122
|
+
ownedRoot,
|
|
123
|
+
expectedManifest: release,
|
|
124
|
+
spawnImpl: versionSpawner(`opencode v${runtimeVersion}`),
|
|
125
|
+
});
|
|
126
|
+
assert.deepEqual(runtime, {
|
|
127
|
+
binary,
|
|
128
|
+
compatibilityVersion: '2.0.32',
|
|
129
|
+
runtimeVersion,
|
|
130
|
+
binarySha256: digest,
|
|
131
|
+
sourceRevision,
|
|
132
|
+
patchSha256,
|
|
133
|
+
lockSha256,
|
|
134
|
+
platform,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('uses the explicit developer hash input when no trusted release record is passed', async t => {
|
|
139
|
+
const { binary, ownedRoot, release } = await fixture(t);
|
|
140
|
+
const runtime = await loadNativeTestRuntime({
|
|
141
|
+
binary,
|
|
142
|
+
ownedRoot,
|
|
143
|
+
expectedManifest: release,
|
|
144
|
+
env: { CTG_TEST_RUNTIME_SHA256: digest },
|
|
145
|
+
spawnImpl: versionSpawner(`opencode v${runtimeVersion}`),
|
|
146
|
+
});
|
|
147
|
+
assert.equal(runtime.binarySha256, digest);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('preflights an injected artifact against an explicit trusted manifest before runtime selection', async t => {
|
|
151
|
+
const { ownedRoot, binary, release } = await fixture(t);
|
|
152
|
+
const expectedManifestPath = join(ownedRoot, 'trusted-release.json');
|
|
153
|
+
await writeFile(expectedManifestPath, JSON.stringify(release));
|
|
154
|
+
const result = await preflightNativeTestRuntimeArtifact({
|
|
155
|
+
ownedRoot,
|
|
156
|
+
expectedManifestPath,
|
|
157
|
+
spawnImpl: versionSpawner(`opencode v${runtimeVersion}`),
|
|
158
|
+
});
|
|
159
|
+
assert.equal(result.identity.binary, binary);
|
|
160
|
+
assert.deepEqual(result.expectedManifest, release);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('requires a trusted test manifest before preflighting an injected artifact', async t => {
|
|
164
|
+
const { ownedRoot } = await fixture(t);
|
|
165
|
+
await assert.rejects(preflightNativeTestRuntimeArtifact({ ownedRoot }), /trusted CTG_TEST_RUNTIME_RELEASE_MANIFEST is required/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('rejects versions that merely contain the compatibility version', async t => {
|
|
169
|
+
const { binary } = await fixture(t);
|
|
170
|
+
await assert.rejects(loadNativeTestRuntime({
|
|
171
|
+
binary,
|
|
172
|
+
spawnImpl: versionSpawner('opencode v9.0.0-ctg.2.0.32'),
|
|
173
|
+
}), /Unsupported native test runtime version/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('requires trusted release metadata for owned artifacts', async t => {
|
|
177
|
+
const { binary, ownedRoot } = await fixture(t);
|
|
178
|
+
await assert.rejects(loadNativeTestRuntime({ binary, ownedRoot }), /required pinned identity fields/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('rejects self-declared provenance mismatches against trusted release metadata before execution', async t => {
|
|
182
|
+
const cases = [
|
|
183
|
+
['sourceRevision', 'f'.repeat(40), /sourceRevision mismatch/],
|
|
184
|
+
['patchSha256', 'd'.repeat(64), /patchSha256 mismatch/],
|
|
185
|
+
['lockSha256', 'e'.repeat(64), /lockSha256 mismatch/],
|
|
186
|
+
['compatibilityVersion', '2.0.33', /compatibilityVersion mismatch/],
|
|
187
|
+
['runtimeVersion', '0.0.0-ctg.2.0.33', /runtimeVersion mismatch/],
|
|
188
|
+
];
|
|
189
|
+
for (const [field, value, error] of cases) {
|
|
190
|
+
const { binary, ownedRoot, release } = await fixture(t, { [field]: value });
|
|
191
|
+
let spawned = false;
|
|
192
|
+
await assert.rejects(loadNativeTestRuntime({ binary, ownedRoot, expectedManifest: release, spawnImpl: (...args) => { spawned = true; return versionSpawner(`opencode v${runtimeVersion}`)(...args); } }), error);
|
|
193
|
+
assert.equal(spawned, false, `${field} mismatch must be rejected before executing binary`);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('checks owned executable path and bytes before invoking version command', async t => {
|
|
198
|
+
const { binary, ownedRoot, release } = await fixture(t);
|
|
199
|
+
let spawned = false;
|
|
200
|
+
await assert.rejects(loadNativeTestRuntime({
|
|
201
|
+
binary: join(ownedRoot, '..', 'opencode'),
|
|
202
|
+
ownedRoot,
|
|
203
|
+
expectedManifest: release,
|
|
204
|
+
spawnImpl: () => { spawned = true; throw Error('must not execute'); },
|
|
205
|
+
}), /executable must be/);
|
|
206
|
+
await writeFile(binary, 'tampered executable');
|
|
207
|
+
await assert.rejects(loadNativeTestRuntime({
|
|
208
|
+
binary,
|
|
209
|
+
ownedRoot,
|
|
210
|
+
expectedManifest: release,
|
|
211
|
+
spawnImpl: () => { spawned = true; throw Error('must not execute'); },
|
|
212
|
+
}), /binary SHA-256 mismatch/);
|
|
213
|
+
assert.equal(spawned, false);
|
|
214
|
+
});
|