carthagent 1.0.7 → 1.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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-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/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 +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 +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/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 +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 +59 -31
- 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 +69 -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 +496 -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/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- 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 +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 +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 +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 +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 +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 +245 -18
- 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/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 +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 +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 +87 -63
- 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,83 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { loadNativeTestRuntime, preflightNativeTestRuntimeArtifact } from './native-runtime-identity.mjs';
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
|
|
9
|
+
/** Select the production runtime, binding every owned path to trusted release metadata. */
|
|
10
|
+
export async function selectNativePtyRuntime({ runtime, env = process.env, root = PACKAGE_ROOT, identityTools = { loadNativeTestRuntime, preflightNativeTestRuntimeArtifact } }) {
|
|
11
|
+
if (env.CTG_TEST_RUNTIME_ARTIFACT_ROOT && (env.CTG_OPENCODE_BIN || env.OPENCODE_BIN)) {
|
|
12
|
+
throw new Error('Do not combine an injected owned artifact root with an explicit binary override.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let options = {};
|
|
16
|
+
let expectedManifest;
|
|
17
|
+
let ownedRoot;
|
|
18
|
+
if (env.CTG_TEST_RUNTIME_ARTIFACT_ROOT) {
|
|
19
|
+
ownedRoot = resolve(env.CTG_TEST_RUNTIME_ARTIFACT_ROOT);
|
|
20
|
+
const expectedManifestPath = env.CTG_TEST_RUNTIME_RELEASE_MANIFEST;
|
|
21
|
+
// This verifies pinned source/patch/lock/platform provenance and the
|
|
22
|
+
// executable digest before resolveOpenCode is allowed to execute it.
|
|
23
|
+
const preflight = await identityTools.preflightNativeTestRuntimeArtifact({
|
|
24
|
+
ownedRoot,
|
|
25
|
+
expectedManifestPath,
|
|
26
|
+
env,
|
|
27
|
+
});
|
|
28
|
+
expectedManifest = preflight.expectedManifest;
|
|
29
|
+
options = { ownedRuntime: ownedRoot };
|
|
30
|
+
} else if (env.CTG_OWNED_OPENCODE_RUNTIME) {
|
|
31
|
+
throw new Error('CTG_OWNED_OPENCODE_RUNTIME is a developer selector, not an acceptance trust root; use CTG_TEST_RUNTIME_ARTIFACT_ROOT and CTG_TEST_RUNTIME_RELEASE_MANIFEST.');
|
|
32
|
+
} else if (env.CTG_OPENCODE_BIN || env.OPENCODE_BIN) {
|
|
33
|
+
const binary = env.CTG_OPENCODE_BIN ?? env.OPENCODE_BIN;
|
|
34
|
+
if (!binary.includes('/') && !binary.includes('\\')) throw new Error('Explicit native PTY binary must be a filesystem path.');
|
|
35
|
+
const expectedHash = env.CTG_NATIVE_PTY_EXPECTED_SHA256;
|
|
36
|
+
if (!/^[a-f0-9]{64}$/i.test(expectedHash ?? '')) throw new Error('Explicit native PTY binary requires CTG_NATIVE_PTY_EXPECTED_SHA256.');
|
|
37
|
+
const actualHash = createHash('sha256').update(await readFile(resolve(binary))).digest('hex');
|
|
38
|
+
if (actualHash !== expectedHash.toLowerCase()) throw new Error(`Explicit native PTY binary SHA-256 mismatch before runtime selection: ${actualHash}`);
|
|
39
|
+
options = { binary };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof runtime.resolveOpenCode !== 'function' || typeof runtime.runtimeIdentity !== 'function') {
|
|
43
|
+
throw new Error('Native PTY acceptance requires resolveOpenCode(options) and runtimeIdentity(binary, options) from the production runtime selector.');
|
|
44
|
+
}
|
|
45
|
+
const binary = await runtime.resolveOpenCode(options);
|
|
46
|
+
const identity = await runtime.runtimeIdentity(binary, options);
|
|
47
|
+
if (!identity.binaryPath || resolve(identity.binaryPath) !== resolve(binary)) {
|
|
48
|
+
throw new Error(`Runtime identity does not bind the selected executable: selected ${binary}, identity ${identity.binaryPath ?? 'missing'}.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (identity.kind === 'ctg-owned') {
|
|
52
|
+
if (!ownedRoot) {
|
|
53
|
+
const executableName = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
|
|
54
|
+
ownedRoot = resolve(dirname(dirname(dirname(binary))));
|
|
55
|
+
const expectedBinary = resolve(ownedRoot, `cli-${identity.platform}`, 'bin', executableName);
|
|
56
|
+
if (resolve(binary) !== expectedBinary) throw new Error(`Selected owned runtime is outside its canonical artifact layout: ${binary}`);
|
|
57
|
+
const manifestPath = resolve(root, 'lib/opencode/runtime-release.json');
|
|
58
|
+
try { expectedManifest = JSON.parse(await readFile(manifestPath, 'utf8')); }
|
|
59
|
+
catch (error) { throw new Error(`Default owned runtime has no independently pinned package release manifest at ${manifestPath}: ${error.message}`); }
|
|
60
|
+
}
|
|
61
|
+
const verified = await identityTools.loadNativeTestRuntime({ binary, ownedRoot, expectedManifest, env });
|
|
62
|
+
if (verified.binarySha256 !== identity.binarySha256
|
|
63
|
+
|| verified.sourceRevision !== identity.sourceRevision
|
|
64
|
+
|| verified.patchSha256 !== identity.patchSha256
|
|
65
|
+
|| verified.lockSha256 !== identity.lockSha256) {
|
|
66
|
+
throw new Error('Independent owned runtime verification does not match the production runtime identity.');
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze({ binary, identity, verified, binarySha256: verified.binarySha256 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (identity.kind !== 'upstream-compatible') throw new Error(`Unrecognized selected native runtime kind: ${identity.kind}`);
|
|
72
|
+
if (env.CTG_TEST_RUNTIME_ARTIFACT_ROOT) throw new Error('Injected owned artifact resolved as upstream-compatible; refusing the selected runtime.');
|
|
73
|
+
const verified = await identityTools.loadNativeTestRuntime({ binary, env });
|
|
74
|
+
if (verified.runtimeVersion !== identity.version || verified.runtimeVersion !== '2.0.32') {
|
|
75
|
+
throw new Error('Selected upstream-compatible runtime does not match the pinned compatibility version.');
|
|
76
|
+
}
|
|
77
|
+
const actual = createHash('sha256').update(await readFile(binary)).digest('hex');
|
|
78
|
+
if (actual !== identity.binarySha256) throw new Error(`Selected upstream-compatible runtime changed during runtime selection: ${actual}`);
|
|
79
|
+
const expectedHash = env.CTG_NATIVE_PTY_EXPECTED_SHA256;
|
|
80
|
+
if (expectedHash && actual !== expectedHash.toLowerCase()) throw new Error(`Explicit native PTY binary SHA-256 mismatch: ${actual}`);
|
|
81
|
+
const binarySha256 = actual;
|
|
82
|
+
return Object.freeze({ binary, identity, verified, binarySha256 });
|
|
83
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"""Exercise a native CTG plugin command in a disposable real terminal host."""
|
|
3
3
|
import argparse
|
|
4
4
|
import fcntl
|
|
5
|
+
import hashlib
|
|
5
6
|
import json
|
|
6
7
|
import os
|
|
7
8
|
import pathlib
|
|
@@ -17,32 +18,80 @@ import termios
|
|
|
17
18
|
import time
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def resolve_binary(value):
|
|
22
|
+
"""Resolve explicit developer paths; default binary selection belongs to the JS runtime gate."""
|
|
23
|
+
if value and (os.path.isabs(value) or os.sep in value or (os.altsep and os.altsep in value)):
|
|
24
|
+
candidate = pathlib.Path(value).expanduser().resolve()
|
|
25
|
+
return str(candidate) if candidate.is_file() else None
|
|
26
|
+
if value:
|
|
27
|
+
found = shutil.which(value)
|
|
28
|
+
if found:
|
|
29
|
+
return str(pathlib.Path(found).resolve())
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
20
33
|
def main():
|
|
21
34
|
parser = argparse.ArgumentParser()
|
|
22
|
-
parser.add_argument('--binary',
|
|
23
|
-
parser.add_argument('--
|
|
24
|
-
parser.add_argument('--
|
|
35
|
+
parser.add_argument('--binary', required=True, help='Binary selected and validated by the production runtime resolver')
|
|
36
|
+
parser.add_argument('--expected-sha256', required=True, help='Independently verified binary identity supplied by the JS acceptance gate')
|
|
37
|
+
parser.add_argument('--plugin', default=str(pathlib.Path(__file__).resolve().parents[1] / 'plugins/carthagent'))
|
|
25
38
|
parser.add_argument('--command', default='/ctg-status')
|
|
26
|
-
parser.add_argument('--
|
|
27
|
-
parser.add_argument('--
|
|
39
|
+
parser.add_argument('--after-command', action='append', help='Submit another command after expected output; may be repeated')
|
|
40
|
+
parser.add_argument('--after-expect', action='append', help='Expected output for each additional command')
|
|
41
|
+
parser.add_argument('--expect', default='Carthagent 2.0.32')
|
|
42
|
+
parser.add_argument('--capture', help='Save the full raw PTY transcript for diagnosis')
|
|
43
|
+
parser.add_argument('--expect-color', help='Require a truecolor ANSI RGB sequence after the command, e.g. 230,196,128')
|
|
44
|
+
parser.add_argument('--initial-theme', help='Select a provisioned native theme before launching the TUI')
|
|
45
|
+
parser.add_argument('--expect-theme', help='Verify the persisted native theme after command dispatch')
|
|
46
|
+
parser.add_argument('--ready', help='Wait for this marker before typing')
|
|
47
|
+
parser.add_argument('--confirm', action='store_true', help='Wait for command submission and confirmation handling to settle')
|
|
48
|
+
parser.add_argument('--second-enter', action='store_true', help='Wait for command submission and confirmation handling to settle')
|
|
49
|
+
parser.add_argument('--dismiss-completion', action='store_true', help='Dismiss slash suggestions before submitting the explicitly typed command')
|
|
50
|
+
parser.add_argument('--post-submit-marker', help='Require an additional unique marker after submission')
|
|
28
51
|
args = parser.parse_args()
|
|
52
|
+
args.binary = resolve_binary(args.binary)
|
|
29
53
|
if not args.binary:
|
|
30
|
-
parser.error('OpenCode binary is
|
|
31
|
-
|
|
32
|
-
|
|
54
|
+
parser.error('The selected OpenCode binary is missing; select and validate it through the production runtime resolver first')
|
|
55
|
+
if not re.fullmatch(r'[a-f0-9]{64}', args.expected_sha256):
|
|
56
|
+
parser.error('--expected-sha256 must be a lowercase SHA-256 digest')
|
|
57
|
+
actual = hashlib.sha256(pathlib.Path(args.binary).read_bytes()).hexdigest()
|
|
58
|
+
if actual != args.expected_sha256:
|
|
59
|
+
parser.error(f'Selected OpenCode binary SHA-256 changed before PTY execution: {actual}')
|
|
60
|
+
preferred_temp = pathlib.Path('/tmp/opencode')
|
|
61
|
+
with tempfile.TemporaryDirectory(prefix='ctg-pty-', dir=preferred_temp if preferred_temp.is_dir() else None) as temp:
|
|
33
62
|
root = pathlib.Path(temp)
|
|
34
63
|
for name in ['home', 'project', 'config/opencode', 'data', 'state', 'cache']:
|
|
35
64
|
(root / name).mkdir(parents=True, exist_ok=True)
|
|
36
65
|
(root / 'config/opencode/opencode.json').write_text(json.dumps({
|
|
37
|
-
'plugins':
|
|
66
|
+
'plugins': [str(pathlib.Path(args.plugin).resolve())], 'update': 'disable',
|
|
67
|
+
'model': 'ctg-pty/fixture',
|
|
68
|
+
'providers': {'ctg-pty': {
|
|
69
|
+
'package': '@opencode/ai/providers/openai-compatible',
|
|
70
|
+
'settings': {'baseURL': 'http://127.0.0.1:1/v1', 'apiKey': 'offline-fixture'},
|
|
71
|
+
'models': {'fixture': {'limit': {'context': 32000, 'output': 1024}}},
|
|
72
|
+
}},
|
|
38
73
|
}))
|
|
39
|
-
|
|
74
|
+
# Mirror the launcher contract: custom themes are installed before the
|
|
75
|
+
# native TUI scans its profile. Installing from inside the running TUI
|
|
76
|
+
# cannot make a newly-created theme selectable in that same process.
|
|
77
|
+
provisioner = pathlib.Path(args.plugin).resolve() / 'src/ui/theme-provision.mjs'
|
|
78
|
+
subprocess.run([
|
|
79
|
+
'node', '--input-type=module', '-e',
|
|
80
|
+
"const { provisionDefaultNativeTheme } = await import(process.argv[1]); await provisionDefaultNativeTheme(process.argv[2]);",
|
|
81
|
+
str(provisioner), str(root / 'config/opencode'),
|
|
82
|
+
], check=True)
|
|
83
|
+
if args.initial_theme:
|
|
84
|
+
(root / 'config/opencode/cli.json').write_text(json.dumps({'theme': {'name': args.initial_theme}}))
|
|
40
85
|
env = {key: os.environ[key] for key in ['PATH', 'LANG', 'LC_ALL'] if key in os.environ}
|
|
41
86
|
env.update({
|
|
42
87
|
'HOME': str(root / 'home'), 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor',
|
|
43
88
|
'XDG_CONFIG_HOME': str(root / 'config'), 'XDG_DATA_HOME': str(root / 'data'),
|
|
44
89
|
'XDG_STATE_HOME': str(root / 'state'), 'XDG_CACHE_HOME': str(root / 'cache'),
|
|
90
|
+
'OPENCODE_CONFIG_DIR': str(root / 'config/opencode'),
|
|
45
91
|
'OPENCODE_DB': str(root / 'database.sqlite'),
|
|
92
|
+
'OPENCODE_CONFIG_PROJECT_DISABLE': 'true',
|
|
93
|
+
'OPENCODE_DISABLE_MODELS_FETCH': 'true',
|
|
94
|
+
'OPENCODE_DISABLE_AUTOUPDATE': 'true',
|
|
46
95
|
})
|
|
47
96
|
master, slave = pty.openpty()
|
|
48
97
|
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 36, 120, 0, 0))
|
|
@@ -50,10 +99,18 @@ def main():
|
|
|
50
99
|
stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
|
|
51
100
|
os.close(slave)
|
|
52
101
|
output = bytearray()
|
|
102
|
+
transcript = bytearray()
|
|
53
103
|
started = time.monotonic()
|
|
54
104
|
sent = False
|
|
55
|
-
|
|
105
|
+
sent_at = None
|
|
56
106
|
submitted = False
|
|
107
|
+
confirmation_complete = False
|
|
108
|
+
completion_dismissed = False
|
|
109
|
+
submission_at = None
|
|
110
|
+
enter_count = 0
|
|
111
|
+
after_index = 0
|
|
112
|
+
after_typed_at = None
|
|
113
|
+
after_completion_dismissed = False
|
|
57
114
|
try:
|
|
58
115
|
while time.monotonic() - started < 30:
|
|
59
116
|
if child.poll() is not None:
|
|
@@ -61,29 +118,93 @@ def main():
|
|
|
61
118
|
if select.select([master], [], [], 0.1)[0]:
|
|
62
119
|
chunk = os.read(master, 65536)
|
|
63
120
|
output.extend(chunk)
|
|
121
|
+
transcript.extend(chunk)
|
|
64
122
|
# Terminal capability requests need replies on a headless PTY.
|
|
65
123
|
if b'\x1b[6n' in chunk:
|
|
66
124
|
os.write(master, b'\x1b[1;1R')
|
|
67
125
|
if b'\x1b[c' in chunk:
|
|
68
126
|
os.write(master, b'\x1b[?1;2c')
|
|
69
|
-
if
|
|
127
|
+
ready = args.ready.encode() in transcript if args.ready else time.monotonic() - started > 8
|
|
128
|
+
if not sent and ready:
|
|
129
|
+
output.clear()
|
|
130
|
+
sent_at = time.monotonic()
|
|
70
131
|
os.write(master, args.command.encode())
|
|
71
132
|
sent = True
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
133
|
+
if sent and args.dismiss_completion and not completion_dismissed and time.monotonic() - sent_at > 1:
|
|
134
|
+
output.clear()
|
|
135
|
+
os.write(master, b'\x1b')
|
|
136
|
+
completion_dismissed = True
|
|
137
|
+
sent_at = time.monotonic()
|
|
138
|
+
if sent and not submitted and after_index == 0 and time.monotonic() - sent_at > (0.5 if args.dismiss_completion and completion_dismissed else 1):
|
|
139
|
+
# Typed slash text has usually populated autocomplete now.
|
|
140
|
+
# Discard that frame before accepting the completion so it
|
|
141
|
+
# cannot satisfy the post-command assertion.
|
|
142
|
+
output.clear()
|
|
143
|
+
os.write(master, b'\r')
|
|
144
|
+
enter_count += 1
|
|
145
|
+
submitted = True
|
|
146
|
+
submission_at = time.monotonic()
|
|
147
|
+
confirmation_prompt = re.search(rb'(?i)(?:are you sure|confirm\?|\[y/n\]|\(y/n\)|press enter to confirm)', output)
|
|
148
|
+
if submitted and submission_at is not None and not confirmation_complete:
|
|
149
|
+
if confirmation_prompt:
|
|
150
|
+
# A second Enter must never be speculative. Keep the
|
|
151
|
+
# test blocked until an explicit confirmation UI is
|
|
152
|
+
# handled by a purpose-built confirmation action.
|
|
153
|
+
raise RuntimeError('Command opened a confirmation prompt; no explicit confirmation handler is configured')
|
|
154
|
+
if time.monotonic() - submission_at > 2:
|
|
155
|
+
confirmation_complete = True
|
|
156
|
+
after_commands = args.after_command or []
|
|
157
|
+
after_expects = args.after_expect or []
|
|
158
|
+
if len(after_commands) != len(after_expects):
|
|
159
|
+
raise ValueError('--after-command and --after-expect must be supplied in matching counts')
|
|
160
|
+
expected_output = args.expect if after_index == 0 else after_expects[after_index - 1]
|
|
161
|
+
if submitted and expected_output.encode() in output and after_index < len(after_commands):
|
|
162
|
+
output.clear()
|
|
163
|
+
os.write(master, after_commands[after_index].encode())
|
|
164
|
+
after_typed_at = time.monotonic()
|
|
165
|
+
after_completion_dismissed = False
|
|
166
|
+
after_index += 1
|
|
167
|
+
submitted = False
|
|
168
|
+
confirmation_complete = False
|
|
169
|
+
submission_at = None
|
|
170
|
+
continue
|
|
171
|
+
if after_index and not submitted and not after_completion_dismissed and time.monotonic() - after_typed_at > 1:
|
|
172
|
+
# Follow-up commands must use the same explicit submit path
|
|
173
|
+
# as the top-level probe. In particular, the theme command
|
|
174
|
+
# may leave the host's slash completion active; the first
|
|
175
|
+
# Enter could select a completion instead of submitting.
|
|
176
|
+
output.clear()
|
|
177
|
+
os.write(master, b'\x1b')
|
|
178
|
+
after_completion_dismissed = True
|
|
179
|
+
after_typed_at = time.monotonic()
|
|
180
|
+
if after_index and not submitted and after_completion_dismissed and time.monotonic() - after_typed_at > 0.5:
|
|
77
181
|
os.write(master, b'\r')
|
|
78
182
|
submitted = True
|
|
183
|
+
sent_at = time.monotonic()
|
|
184
|
+
submission_at = sent_at
|
|
79
185
|
output.clear()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
186
|
+
command_submitted = submitted and (confirmation_complete or not (args.confirm or args.second_enter))
|
|
187
|
+
result_output = command_submitted and expected_output.encode() in output and (not args.post_submit_marker or args.post_submit_marker.encode() in output)
|
|
188
|
+
if result_output:
|
|
189
|
+
if args.expect_theme:
|
|
190
|
+
theme_config = json.loads((root / 'config/opencode/cli.json').read_text())
|
|
191
|
+
assert theme_config['theme']['name'] == args.expect_theme, theme_config
|
|
192
|
+
if b'runtime unavailable' in output or b'No matching commands' in output:
|
|
83
193
|
raise RuntimeError('Command was unavailable or its RPC failed')
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
194
|
+
if args.expect_color and args.initial_theme:
|
|
195
|
+
theme_config = json.loads((root / 'config/opencode/cli.json').read_text())
|
|
196
|
+
assert theme_config['theme']['name'] == args.initial_theme, theme_config
|
|
197
|
+
channels = [part.strip() for part in args.expect_color.split(',')]
|
|
198
|
+
if len(channels) != 3 or any(not part.isdigit() or int(part) > 255 for part in channels):
|
|
199
|
+
raise ValueError('--expect-color must be an RGB triple such as 242,198,109')
|
|
200
|
+
ansi = f"\x1b[38;2;{';'.join(channels)}m".encode()
|
|
201
|
+
if ansi not in transcript:
|
|
202
|
+
print(f'Expected rendered foreground color {ansi!r} was not observed; recent raw PTY bytes: {output[-12000:]!r}')
|
|
203
|
+
raise AssertionError('Rendered accent color did not match the selected native theme')
|
|
204
|
+
print(json.dumps({'command': args.command, 'observed': expected_output,
|
|
205
|
+
'postSubmission': True, 'enterCount': enter_count,
|
|
206
|
+
'theme': args.initial_theme if args.expect_color else None,
|
|
207
|
+
'ansiColor': args.expect_color,
|
|
87
208
|
'elapsedMs': round((time.monotonic() - started) * 1000),
|
|
88
209
|
'terminalBytes': len(output)}))
|
|
89
210
|
return
|
|
@@ -92,8 +213,10 @@ def main():
|
|
|
92
213
|
lines = [line for line in log.read_text(errors='replace').splitlines()
|
|
93
214
|
if 'plugin' in line.lower() or 'error' in line.lower()]
|
|
94
215
|
print('\n'.join(lines[-30:]))
|
|
95
|
-
raise RuntimeError(f'Expected
|
|
216
|
+
raise RuntimeError(f'Expected {args.expect!r} not rendered: {plain[-8000:].decode(errors="replace")}')
|
|
96
217
|
finally:
|
|
218
|
+
if args.capture:
|
|
219
|
+
pathlib.Path(args.capture).write_bytes(transcript)
|
|
97
220
|
if child.poll() is None:
|
|
98
221
|
os.killpg(child.pid, signal.SIGTERM)
|
|
99
222
|
try:
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
/** Validate the explicit status trailer emitted by a wrapped native shell call. */
|
|
4
|
+
export function assertNativeShellResult(output, { status, passingTests } = {}) {
|
|
5
|
+
const match = /(?:^|\n)CTG_NATIVE_EXIT_STATUS=(\d+)\s*(?:\nExited with code \d+\s*)?$/.exec(output);
|
|
6
|
+
assert.ok(match, 'native shell result must end with its explicit CTG_NATIVE_EXIT_STATUS trailer');
|
|
7
|
+
assert.equal(Number(match[1]), status, `native shell exit status must be ${status}`);
|
|
8
|
+
if (passingTests !== undefined) {
|
|
9
|
+
assert.match(output, new RegExp(`(?:^|\\n)\\s*${passingTests} passing(?:\\s|\\()`, 'i'), `Mocha output must report ${passingTests} passing tests`);
|
|
10
|
+
assert.doesNotMatch(output, /(?:^|\n)\s*[1-9]\d* failing(?:\s|\()/i, 'Mocha output must not report any failing tests');
|
|
11
|
+
}
|
|
12
|
+
return Number(match[1]);
|
|
13
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { assertNativeShellResult } from './native-public-repo-shell-result.mjs';
|
|
4
|
+
|
|
5
|
+
test('accepts exact zero-status Mocha result with expected passing count', () => {
|
|
6
|
+
assert.equal(assertNativeShellResult(' 111 passing (10ms)\nCTG_NATIVE_EXIT_STATUS=0', { status: 0, passingTests: 111 }), 0);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('rejects Mocha output reporting passing tests and any failing tests despite a zero-status trailer', () => {
|
|
10
|
+
assert.throws(
|
|
11
|
+
() => assertNativeShellResult(' 111 passing (10ms)\n 1 failing\nCTG_NATIVE_EXIT_STATUS=0', { status: 0, passingTests: 111 }),
|
|
12
|
+
/must not report any failing tests/,
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('rejects nonzero native status even when output claims all package tests passed', () => {
|
|
17
|
+
assert.throws(
|
|
18
|
+
() => assertNativeShellResult(' 111 passing (10ms)\n 1 failing\nCTG_NATIVE_EXIT_STATUS=1\nExited with code 1', { status: 0, passingTests: 111 }),
|
|
19
|
+
/exit status must be 0/,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('fails closed when native result has no explicit status trailer', () => {
|
|
24
|
+
assert.throws(
|
|
25
|
+
() => assertNativeShellResult(' 111 passing (10ms)', { status: 0, passingTests: 111 }),
|
|
26
|
+
/must end with its explicit/,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { resolve, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const NATIVE_TEST_COMPATIBILITY_VERSION = '2.0.32';
|
|
7
|
+
|
|
8
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
9
|
+
const REVISION = /^[a-f0-9]{40,64}$/;
|
|
10
|
+
const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve and verify the executable used by native acceptance tests.
|
|
14
|
+
*
|
|
15
|
+
* `expectedManifest` is the caller's trusted, package-pinned runtime-release
|
|
16
|
+
* record (not the adjacent ctg-runtime.json). Its `assets[platform]` entry
|
|
17
|
+
* pins the executable digest. Developers may override that digest with
|
|
18
|
+
* CTG_TEST_RUNTIME_SHA256 while retaining the expected source, patch, lock,
|
|
19
|
+
* compatibility, and runtime identity from the trusted release record.
|
|
20
|
+
*
|
|
21
|
+
* Upstream runtimes are accepted only when --version is exactly
|
|
22
|
+
* `opencode v2.0.32`. The owned CTG build is accepted only when its exact
|
|
23
|
+
* runtime version, provenance manifest, platform path, and independently
|
|
24
|
+
* expected binary digest all match. Provenance and bytes are checked before
|
|
25
|
+
* executing the binary. No version normalization or substring matching is
|
|
26
|
+
* used.
|
|
27
|
+
*/
|
|
28
|
+
export async function loadNativeTestRuntime({
|
|
29
|
+
binary = process.env.CTG_OPENCODE_BIN,
|
|
30
|
+
ownedRoot,
|
|
31
|
+
expectedManifest,
|
|
32
|
+
platform = nativePlatform(),
|
|
33
|
+
env = process.env,
|
|
34
|
+
spawnImpl = spawn,
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (typeof binary !== 'string' || !binary.trim()) {
|
|
37
|
+
throw new Error('Pass the native test executable as `binary` or CTG_OPENCODE_BIN');
|
|
38
|
+
}
|
|
39
|
+
const executable = resolve(binary);
|
|
40
|
+
if (!ownedRoot) {
|
|
41
|
+
const versionOutput = await captureVersion(executable, spawnImpl, env);
|
|
42
|
+
if (versionOutput !== `opencode v${NATIVE_TEST_COMPATIBILITY_VERSION}`) {
|
|
43
|
+
throw new Error(`Unsupported native test runtime version: ${JSON.stringify(versionOutput)}`);
|
|
44
|
+
}
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
binary: executable,
|
|
47
|
+
compatibilityVersion: NATIVE_TEST_COMPATIBILITY_VERSION,
|
|
48
|
+
runtimeVersion: NATIVE_TEST_COMPATIBILITY_VERSION,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof ownedRoot !== 'string' || !ownedRoot.trim()) throw new Error('ownedRoot must be a non-empty path');
|
|
53
|
+
const root = resolve(ownedRoot);
|
|
54
|
+
const trusted = validateTrustedRelease(expectedManifest, platform);
|
|
55
|
+
const target = trusted.assets[platform];
|
|
56
|
+
const executableName = platform.startsWith('windows-') ? 'opencode.exe' : 'opencode';
|
|
57
|
+
const expectedBinary = resolve(root, `cli-${platform}`, 'bin', executableName);
|
|
58
|
+
if (executable !== expectedBinary) throw new Error(`Owned runtime executable must be ${expectedBinary}`);
|
|
59
|
+
|
|
60
|
+
const actualSha256 = createHash('sha256').update(await readFile(executable)).digest('hex');
|
|
61
|
+
const expectedSha256 = env.CTG_TEST_RUNTIME_SHA256 ?? target.executableSha256;
|
|
62
|
+
if (!SHA256.test(expectedSha256)) throw new Error('Expected owned runtime executable SHA-256 is invalid');
|
|
63
|
+
if (actualSha256 !== expectedSha256) {
|
|
64
|
+
throw new Error(`Owned runtime binary SHA-256 mismatch: expected ${expectedSha256}, got ${actualSha256}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const manifestPath = join(root, 'ctg-runtime.json');
|
|
68
|
+
let manifest;
|
|
69
|
+
try {
|
|
70
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new Error(`Could not read owned runtime manifest at ${manifestPath}: ${error.message}`, { cause: error });
|
|
73
|
+
}
|
|
74
|
+
const expectedInner = {
|
|
75
|
+
owner: 'Carthagent',
|
|
76
|
+
sourceRepository: 'https://github.com/anomalyco/opencode',
|
|
77
|
+
sourceRevision: trusted.sourceRevision,
|
|
78
|
+
patchSha256: trusted.patchSha256,
|
|
79
|
+
lockSha256: trusted.lockSha256,
|
|
80
|
+
compatibilityVersion: trusted.compatibilityVersion,
|
|
81
|
+
runtimeVersion: trusted.runtimeVersion,
|
|
82
|
+
channel: `ctg.${trusted.compatibilityVersion}`,
|
|
83
|
+
platform,
|
|
84
|
+
webUI: false,
|
|
85
|
+
};
|
|
86
|
+
for (const [key, expected] of Object.entries(expectedInner)) {
|
|
87
|
+
if (manifest?.[key] !== expected) throw new Error(`Owned runtime ${key} mismatch: expected ${String(expected)}, got ${String(manifest?.[key])}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const versionOutput = await captureVersion(executable, spawnImpl, env);
|
|
91
|
+
if (versionOutput !== `opencode v${trusted.runtimeVersion}`) {
|
|
92
|
+
throw new Error(`Unsupported owned native test runtime version: expected opencode v${trusted.runtimeVersion}, got ${JSON.stringify(versionOutput)}`);
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
binary: executable,
|
|
96
|
+
compatibilityVersion: trusted.compatibilityVersion,
|
|
97
|
+
runtimeVersion: trusted.runtimeVersion,
|
|
98
|
+
binarySha256: actualSha256,
|
|
99
|
+
sourceRevision: manifest.sourceRevision,
|
|
100
|
+
patchSha256: manifest.patchSha256,
|
|
101
|
+
lockSha256: manifest.lockSha256,
|
|
102
|
+
platform,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Verify a test-supplied artifact against explicit trusted metadata before a resolver can execute it. */
|
|
107
|
+
export async function preflightNativeTestRuntimeArtifact({
|
|
108
|
+
ownedRoot,
|
|
109
|
+
expectedManifestPath,
|
|
110
|
+
env = process.env,
|
|
111
|
+
platform = nativePlatform(),
|
|
112
|
+
spawnImpl = spawn,
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (typeof ownedRoot !== 'string' || !ownedRoot.trim()) throw new Error('ownedRoot must be a non-empty test artifact path');
|
|
115
|
+
if (typeof expectedManifestPath !== 'string' || !expectedManifestPath.trim()) {
|
|
116
|
+
throw new Error('A trusted CTG_TEST_RUNTIME_RELEASE_MANIFEST is required with CTG_TEST_RUNTIME_ARTIFACT_ROOT');
|
|
117
|
+
}
|
|
118
|
+
let expectedManifest;
|
|
119
|
+
try {
|
|
120
|
+
expectedManifest = JSON.parse(await readFile(expectedManifestPath, 'utf8'));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new Error(`Could not read trusted native runtime release manifest at ${expectedManifestPath}: ${error.message}`, { cause: error });
|
|
123
|
+
}
|
|
124
|
+
const executableName = platform.startsWith('windows-') ? 'opencode.exe' : 'opencode';
|
|
125
|
+
const binary = join(resolve(ownedRoot), `cli-${platform}`, 'bin', executableName);
|
|
126
|
+
const identity = await loadNativeTestRuntime({ binary, ownedRoot, expectedManifest, env, platform, spawnImpl });
|
|
127
|
+
return Object.freeze({ identity, expectedManifest });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validateTrustedRelease(release, platform) {
|
|
131
|
+
const invalid = reason => { throw new Error(`Invalid trusted native runtime release manifest: ${reason}`); };
|
|
132
|
+
if (!release || release.version !== 1 || !RELEASE_VERSION.test(release.releaseVersion ?? '')
|
|
133
|
+
|| release.compatibilityVersion !== NATIVE_TEST_COMPATIBILITY_VERSION
|
|
134
|
+
|| typeof release.runtimeVersion !== 'string' || !release.runtimeVersion.startsWith('0.0.0-ctg.')
|
|
135
|
+
|| !REVISION.test(release.sourceRevision ?? '')
|
|
136
|
+
|| !SHA256.test(release.patchSha256 ?? '') || !SHA256.test(release.lockSha256 ?? '')
|
|
137
|
+
|| !release.assets || typeof release.assets !== 'object' || Array.isArray(release.assets)) invalid('required pinned identity fields are missing or invalid');
|
|
138
|
+
const asset = release.assets[platform];
|
|
139
|
+
if (!asset || asset.name !== `opencode-${platform}.tar.gz` || !SHA256.test(asset.executableSha256 ?? '') || !SHA256.test(asset.archiveSha256 ?? '')) {
|
|
140
|
+
invalid(`no exact asset digest is pinned for ${platform}`);
|
|
141
|
+
}
|
|
142
|
+
return release;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function nativePlatform() {
|
|
146
|
+
const os = process.platform === 'win32' ? 'windows' : process.platform;
|
|
147
|
+
return `${os}-${process.arch}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function captureVersion(binary, spawnImpl, env) {
|
|
151
|
+
return new Promise((resolveOutput, reject) => {
|
|
152
|
+
let child;
|
|
153
|
+
try {
|
|
154
|
+
const childEnv = { ...env };
|
|
155
|
+
for (const key of Object.keys(childEnv)) {
|
|
156
|
+
if (key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN') delete childEnv[key];
|
|
157
|
+
}
|
|
158
|
+
child = spawnImpl(binary, ['--version'], { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
reject(error);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let stdout = '';
|
|
164
|
+
let stderr = '';
|
|
165
|
+
let settled = false;
|
|
166
|
+
const finish = callback => value => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
settled = true;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
callback(value);
|
|
171
|
+
};
|
|
172
|
+
const onOutput = (target, chunk) => {
|
|
173
|
+
if (settled) return;
|
|
174
|
+
target.value += chunk;
|
|
175
|
+
if (target.value.length > 64 * 1024) {
|
|
176
|
+
child.kill?.('SIGKILL');
|
|
177
|
+
finish(reject)(new Error('Native test runtime --version output exceeded 64 KiB'));
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const stdoutBuffer = { value: stdout };
|
|
181
|
+
const stderrBuffer = { value: stderr };
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
child.kill?.('SIGKILL');
|
|
184
|
+
finish(reject)(new Error('Native test runtime --version timed out after 5 seconds'));
|
|
185
|
+
}, 5000);
|
|
186
|
+
child.stdout?.setEncoding?.('utf8');
|
|
187
|
+
child.stderr?.setEncoding?.('utf8');
|
|
188
|
+
child.stdout?.on('data', chunk => onOutput(stdoutBuffer, chunk));
|
|
189
|
+
child.stderr?.on('data', chunk => onOutput(stderrBuffer, chunk));
|
|
190
|
+
child.once('error', finish(reject));
|
|
191
|
+
child.once('close', code => {
|
|
192
|
+
stdout = stdoutBuffer.value;
|
|
193
|
+
stderr = stderrBuffer.value;
|
|
194
|
+
if (code !== 0) {
|
|
195
|
+
finish(reject)(new Error(`Native test runtime --version exited ${code}: ${stderr.trim()}`));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
finish(resolveOutput)(stdout.trim());
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Strict child-environment allowlists for the native slash acceptance probe."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
SAFE_PARENT_ENV = ('PATH', 'LANG', 'LC_ALL')
|
|
5
|
+
SAFE_WINDOWS_ENV = ('SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT')
|
|
6
|
+
TUI_ENV_KEYS = frozenset((*SAFE_PARENT_ENV, *SAFE_WINDOWS_ENV, 'HOME', 'USERPROFILE',
|
|
7
|
+
'APPDATA', 'LOCALAPPDATA', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME',
|
|
8
|
+
'XDG_CACHE_HOME', 'OPENCODE_CONFIG_PROJECT_DISABLE', 'OPENCODE_PASSWORD',
|
|
9
|
+
'OPENCODE_SERVER_PASSWORD', 'OPENCODE_SERVER_USERNAME', 'TERM', 'COLORTERM'))
|
|
10
|
+
FIXTURE_ENV_KEYS = TUI_ENV_KEYS | frozenset((
|
|
11
|
+
'CTG_OWNED_OPENCODE_RUNTIME', 'CTG_TEST_RUNTIME_RELEASE_MANIFEST'))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fixture_environment(source, home, owned_root, release_manifest):
|
|
15
|
+
result = {key: source[key] for key in (*SAFE_PARENT_ENV, *SAFE_WINDOWS_ENV)
|
|
16
|
+
if key in source}
|
|
17
|
+
result['PATH'] = result.get('PATH', '/usr/bin:/bin')
|
|
18
|
+
result['LANG'] = result.get('LANG', 'C.UTF-8')
|
|
19
|
+
result.update({
|
|
20
|
+
'HOME': str(home), 'USERPROFILE': str(home),
|
|
21
|
+
'CTG_OWNED_OPENCODE_RUNTIME': str(owned_root),
|
|
22
|
+
'CTG_TEST_RUNTIME_RELEASE_MANIFEST': str(release_manifest),
|
|
23
|
+
'XDG_CONFIG_HOME': str(Path(home) / 'config'),
|
|
24
|
+
'XDG_DATA_HOME': str(Path(home) / 'data'),
|
|
25
|
+
'XDG_STATE_HOME': str(Path(home) / 'state'),
|
|
26
|
+
'XDG_CACHE_HOME': str(Path(home) / 'cache'),
|
|
27
|
+
})
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def assert_clean_child_environment(env, allowed=TUI_ENV_KEYS):
|
|
32
|
+
forbidden = {'ctg_runtime_github_token', 'anthropic_api_key', 'openai_api_key',
|
|
33
|
+
'openrouter_api_key', 'opencode_api_key'}
|
|
34
|
+
if any(key.casefold() in forbidden for key in env):
|
|
35
|
+
raise AssertionError('child environment contains a provider/runtime credential')
|
|
36
|
+
unexpected = sorted(set(env) - allowed)
|
|
37
|
+
if unexpected:
|
|
38
|
+
raise AssertionError(f'child environment contains non-allowlisted names: {unexpected}')
|