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,373 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Behavioral proof for native TUI /clear on a trusted owned runtime (Linux PTY)."""
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_env_spec = importlib.util.spec_from_file_location(
|
|
14
|
+
'owned_visual_clear_env', Path(__file__).with_name('owned-visual-clear-env.py'))
|
|
15
|
+
_env_module = importlib.util.module_from_spec(_env_spec)
|
|
16
|
+
_env_spec.loader.exec_module(_env_module)
|
|
17
|
+
SAFE_PARENT_ENV = _env_module.SAFE_PARENT_ENV
|
|
18
|
+
TUI_ENV_KEYS = _env_module.TUI_ENV_KEYS
|
|
19
|
+
FIXTURE_ENV_KEYS = _env_module.FIXTURE_ENV_KEYS
|
|
20
|
+
fixture_environment = _env_module.fixture_environment
|
|
21
|
+
assert_clean_child_environment = _env_module.assert_clean_child_environment
|
|
22
|
+
_model_spec = importlib.util.spec_from_file_location(
|
|
23
|
+
'owned_visual_clear_model', Path(__file__).with_name('owned-visual-clear-model.py'))
|
|
24
|
+
_model_module = importlib.util.module_from_spec(_model_spec)
|
|
25
|
+
_model_spec.loader.exec_module(_model_module)
|
|
26
|
+
canonical_conversation = _model_module.canonical_conversation
|
|
27
|
+
latest_message = _model_module.latest_message
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def preflight(binary, owned_root, manifest, env):
|
|
31
|
+
import subprocess
|
|
32
|
+
|
|
33
|
+
helper = Path(__file__).with_name('native-runtime-identity.mjs')
|
|
34
|
+
script = r'''
|
|
35
|
+
import { readFile } from 'node:fs/promises';
|
|
36
|
+
const {loadNativeTestRuntime}=await import(process.argv[1]);
|
|
37
|
+
const expectedManifest=JSON.parse(await readFile(process.argv[4],'utf8'));
|
|
38
|
+
const identity=await loadNativeTestRuntime({binary:process.argv[2],ownedRoot:process.argv[3],expectedManifest,env:process.env});
|
|
39
|
+
process.stdout.write(JSON.stringify(identity));
|
|
40
|
+
'''
|
|
41
|
+
result = subprocess.run(['node', '--input-type=module', '-e', script,
|
|
42
|
+
str(helper), str(Path(binary).resolve()),
|
|
43
|
+
str(Path(owned_root).resolve()), str(Path(manifest).resolve())],
|
|
44
|
+
capture_output=True, text=True, env=env)
|
|
45
|
+
if result.returncode:
|
|
46
|
+
raise RuntimeError(f'trusted owned-runtime preflight failed: {result.stderr.strip()}')
|
|
47
|
+
return json.loads(result.stdout)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Tui:
|
|
51
|
+
active = set()
|
|
52
|
+
|
|
53
|
+
def __init__(self, binary, fixture, session_id, env):
|
|
54
|
+
import subprocess
|
|
55
|
+
|
|
56
|
+
self.master, slave = pty.openpty()
|
|
57
|
+
rows, columns = 60, 140
|
|
58
|
+
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, columns, 0, 0))
|
|
59
|
+
child_env = {**env, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor',
|
|
60
|
+
'OPENCODE_PASSWORD': fixture['password']}
|
|
61
|
+
assert_clean_child_environment(child_env)
|
|
62
|
+
self.child = subprocess.Popen([binary, '--server', fixture['baseUrl'],
|
|
63
|
+
'--session', session_id], cwd=fixture['cwd'],
|
|
64
|
+
env=child_env, stdin=slave, stdout=slave,
|
|
65
|
+
stderr=slave, start_new_session=True)
|
|
66
|
+
os.close(slave)
|
|
67
|
+
self.screen = Screen(rows=rows, columns=columns)
|
|
68
|
+
self.active.add(self)
|
|
69
|
+
|
|
70
|
+
def drain(self, seconds=.2):
|
|
71
|
+
end = time.monotonic() + seconds
|
|
72
|
+
while time.monotonic() < end:
|
|
73
|
+
ready, _, _ = select.select([self.master], [], [], .05)
|
|
74
|
+
if ready:
|
|
75
|
+
try:
|
|
76
|
+
data = os.read(self.master, 65536)
|
|
77
|
+
except OSError:
|
|
78
|
+
return
|
|
79
|
+
if not data:
|
|
80
|
+
return
|
|
81
|
+
self.screen.feed(data)
|
|
82
|
+
elif self.child.poll() is not None:
|
|
83
|
+
raise AssertionError(f'TUI exited early: {self.child.returncode}')
|
|
84
|
+
|
|
85
|
+
def wait_for(self, predicate, what, timeout=20):
|
|
86
|
+
end = time.monotonic() + timeout
|
|
87
|
+
while time.monotonic() < end:
|
|
88
|
+
self.drain(.1)
|
|
89
|
+
if predicate():
|
|
90
|
+
self.drain(.2)
|
|
91
|
+
return
|
|
92
|
+
raise AssertionError(f'timed out waiting for {what}:\n{self.screen.visible()}')
|
|
93
|
+
|
|
94
|
+
def submit(self, text):
|
|
95
|
+
os.write(self.master, text.encode())
|
|
96
|
+
os.write(self.master, b'\r')
|
|
97
|
+
|
|
98
|
+
def submit_slash(self, text):
|
|
99
|
+
os.write(self.master, text.encode())
|
|
100
|
+
self.drain(.5) # native slash autocomplete
|
|
101
|
+
os.write(self.master, b'\r') # select registered command
|
|
102
|
+
self.drain(.3)
|
|
103
|
+
os.write(self.master, b'\r') # dispatch
|
|
104
|
+
|
|
105
|
+
def stop(self):
|
|
106
|
+
import subprocess
|
|
107
|
+
|
|
108
|
+
if self not in self.active:
|
|
109
|
+
return
|
|
110
|
+
if self.child.poll() is None:
|
|
111
|
+
os.killpg(self.child.pid, signal.SIGTERM)
|
|
112
|
+
try:
|
|
113
|
+
self.child.wait(timeout=5)
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
os.killpg(self.child.pid, signal.SIGKILL)
|
|
116
|
+
self.child.wait(timeout=5)
|
|
117
|
+
os.close(self.master)
|
|
118
|
+
self.active.remove(self)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main():
|
|
122
|
+
# PTY and terminal semantics are POSIX-only; importing this runner is safe
|
|
123
|
+
# on platforms that only execute its child-environment unit tests.
|
|
124
|
+
global pty, fcntl, select, struct, termios, Screen
|
|
125
|
+
import pty
|
|
126
|
+
import fcntl
|
|
127
|
+
import select
|
|
128
|
+
import struct
|
|
129
|
+
import termios
|
|
130
|
+
import subprocess
|
|
131
|
+
|
|
132
|
+
_spec = importlib.util.spec_from_file_location(
|
|
133
|
+
'terminal_semantics', Path(__file__).with_name('native-terminal-semantics.py'))
|
|
134
|
+
_module = importlib.util.module_from_spec(_spec)
|
|
135
|
+
_spec.loader.exec_module(_module)
|
|
136
|
+
Screen = _module.Screen
|
|
137
|
+
|
|
138
|
+
parser = argparse.ArgumentParser()
|
|
139
|
+
parser.add_argument('--binary', required=True)
|
|
140
|
+
parser.add_argument('--owned-root', required=True)
|
|
141
|
+
parser.add_argument('--release-manifest', required=True)
|
|
142
|
+
parser.add_argument('--artifact')
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
binary = str(Path(args.binary).resolve())
|
|
146
|
+
owned_root = str(Path(args.owned_root).resolve())
|
|
147
|
+
root = Path(__file__).resolve().parents[1]
|
|
148
|
+
work = Path(args.artifact).resolve().parent if args.artifact else Path('/tmp')
|
|
149
|
+
resolver_env = fixture_environment(os.environ, work / 'ctg-visual-clear-runner',
|
|
150
|
+
owned_root, Path(args.release_manifest).resolve())
|
|
151
|
+
assert_clean_child_environment(resolver_env, FIXTURE_ENV_KEYS)
|
|
152
|
+
# This test selects a local artifact and performs no download. No runtime
|
|
153
|
+
# token is needed by preflight or any child, so the allowlist excludes it.
|
|
154
|
+
if any(key.casefold() == 'ctg_runtime_github_token' for key in resolver_env):
|
|
155
|
+
raise AssertionError('resolver environment retains a reserved runtime token')
|
|
156
|
+
preflight_env = dict(resolver_env)
|
|
157
|
+
print('Preflighting pinned executable...', file=sys.stderr, flush=True)
|
|
158
|
+
identity = preflight(binary, owned_root, args.release_manifest, preflight_env)
|
|
159
|
+
|
|
160
|
+
# Make the trusted resolver invocation and launch arguments explicit. The
|
|
161
|
+
# fixture will repeat identity checks before its host process is started.
|
|
162
|
+
fixture_script = r'''
|
|
163
|
+
const {nativeFixture}=await import((await import('node:url')).pathToFileURL(process.argv[1]));
|
|
164
|
+
const host=await nativeFixture({plugin:null,verifyInventory:false,preserveTemp:true,
|
|
165
|
+
respond(body,res){res.writeHead(200,{'content-type':'text/event-stream','connection':'close'});
|
|
166
|
+
const latestUser=[...(body.messages??[])].reverse().find(message=>message.role==='user');
|
|
167
|
+
const prompt=typeof latestUser?.content==='string'?latestUser.content:
|
|
168
|
+
(latestUser?.content??[]).map(part=>part.text??'').join(' ');
|
|
169
|
+
const reply=prompt.includes('VISUAL_CLEAR_CONTINUED_PROMPT')?
|
|
170
|
+
'CLEAR_FOLLOWUP_ASSISTANT_REPLY':
|
|
171
|
+
prompt.includes('CLEAR_SESSION_A_UNIQUE_MARKER')?'CLEAR_A_INITIAL_ASSISTANT_REPLY':
|
|
172
|
+
prompt.includes('UNCLEARED_CONTROL_OLD_MARKER')?'CLEAR_CONTROL_INITIAL_ASSISTANT_REPLY':
|
|
173
|
+
'CLEAR_NEIGHBOR_ASSISTANT_REPLY';
|
|
174
|
+
const chunk=(delta,finish_reason=null)=>`data: ${JSON.stringify({id:'offline-clear',object:'chat.completion.chunk',created:1,model:'fixture',choices:[{index:0,delta,finish_reason}]})}\n\n`;
|
|
175
|
+
res.end(chunk({role:'assistant',content:reply})+chunk({},'stop')+'data: [DONE]\n\n');}});
|
|
176
|
+
try {
|
|
177
|
+
const sessions={};
|
|
178
|
+
for(const [key,marker] of [['a','CLEAR_SESSION_A_UNIQUE_MARKER'],['b','NEIGHBOR_SESSION_B_UNIQUE_MARKER'],['control','UNCLEARED_CONTROL_OLD_MARKER']]){
|
|
179
|
+
const s=await host.client.session.create({location:host.location,model:host.model,title:`clear ${key}`},host.request());
|
|
180
|
+
await host.client.session.prompt({sessionID:s.id,text:`Remember this visible marker: ${marker}`},host.request());
|
|
181
|
+
await host.client.session.wait({sessionID:s.id},host.request()); sessions[key]={id:s.id,marker}; }
|
|
182
|
+
await host.client.session.prompt({sessionID:sessions.control.id,text:'VISUAL_CLEAR_CONTINUED_PROMPT'},host.request());
|
|
183
|
+
await host.client.session.wait({sessionID:sessions.control.id},host.request());
|
|
184
|
+
process.stdout.write(JSON.stringify({baseUrl:host.baseUrl,password:host.password,temp:host.temp,cwd:host.cwd,sessions})+'\n');
|
|
185
|
+
process.once('SIGTERM',async()=>{await host.close({preserveTemp:false});process.exit(0)});
|
|
186
|
+
await new Promise(()=>{});
|
|
187
|
+
} catch(e){await host.close({preserveTemp:false});throw e}
|
|
188
|
+
'''
|
|
189
|
+
child = subprocess.Popen(['node', '--input-type=module', '-e', fixture_script,
|
|
190
|
+
str(root / 'tools/native-fixture.mjs')], cwd=root,
|
|
191
|
+
env=resolver_env, stdout=subprocess.PIPE,
|
|
192
|
+
stderr=subprocess.PIPE, text=True)
|
|
193
|
+
tui = None
|
|
194
|
+
try:
|
|
195
|
+
line = child.stdout.readline()
|
|
196
|
+
if not line:
|
|
197
|
+
raise RuntimeError(f'fixture failed: {child.stderr.read()}')
|
|
198
|
+
fixture = json.loads(line)
|
|
199
|
+
bridge = r'''
|
|
200
|
+
import {OpenCode} from '@opencode/client';
|
|
201
|
+
const [url,password,id]=process.argv.slice(1);const c=OpenCode.make({baseUrl:url,headers:{authorization:`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`}});
|
|
202
|
+
process.stdout.write(JSON.stringify(await c.message.list({sessionID:id},{signal:AbortSignal.timeout(10000)})));
|
|
203
|
+
'''
|
|
204
|
+
def durable(session_id):
|
|
205
|
+
return json.loads(subprocess.run(['node','--input-type=module','-e',bridge,
|
|
206
|
+
fixture['baseUrl'],fixture['password'],session_id],cwd=root,check=True,
|
|
207
|
+
capture_output=True,text=True,env=resolver_env).stdout)
|
|
208
|
+
def conversation(value):
|
|
209
|
+
return [x for x in value.get('data',[]) if x.get('type') in ('user','assistant','tool')]
|
|
210
|
+
def stable(session_id):
|
|
211
|
+
previous=durable(session_id); same=0; end=time.monotonic()+10
|
|
212
|
+
while time.monotonic()<end:
|
|
213
|
+
time.sleep(.25); current=durable(session_id)
|
|
214
|
+
if current==previous:
|
|
215
|
+
same+=1
|
|
216
|
+
if same==2:return current
|
|
217
|
+
else:previous=current;same=0
|
|
218
|
+
raise AssertionError('durable message projection did not settle')
|
|
219
|
+
|
|
220
|
+
def durable_messages(session_id):
|
|
221
|
+
return stable(session_id)
|
|
222
|
+
|
|
223
|
+
def durable_snapshot(session_id):
|
|
224
|
+
return canonical_conversation(conversation(durable_messages(session_id)))
|
|
225
|
+
|
|
226
|
+
def wait_for_file_value(path, predicate, description, timeout=10):
|
|
227
|
+
end = time.monotonic() + timeout
|
|
228
|
+
last_value = None
|
|
229
|
+
while time.monotonic() < end:
|
|
230
|
+
try:
|
|
231
|
+
value = json.loads(path.read_text())
|
|
232
|
+
except (OSError, json.JSONDecodeError):
|
|
233
|
+
value = None
|
|
234
|
+
last_value = value
|
|
235
|
+
if value is not None and predicate(value):
|
|
236
|
+
return value
|
|
237
|
+
time.sleep(.05)
|
|
238
|
+
raise AssertionError(
|
|
239
|
+
f'timed out waiting for persisted {description}; '
|
|
240
|
+
f'last state was {json.dumps(last_value, sort_keys=True)}')
|
|
241
|
+
|
|
242
|
+
a,b,control=(fixture['sessions'][key] for key in ('a','b','control'))
|
|
243
|
+
before_b=durable_snapshot(b['id'])
|
|
244
|
+
before_control=durable_snapshot(control['id'])
|
|
245
|
+
if not any(x.get('text') == 'Remember this visible marker: UNCLEARED_CONTROL_OLD_MARKER'
|
|
246
|
+
for x in before_control if x.get('type') == 'user') or not any(
|
|
247
|
+
x.get('text') == 'VISUAL_CLEAR_CONTINUED_PROMPT'
|
|
248
|
+
for x in before_control if x.get('type') == 'user'):
|
|
249
|
+
raise AssertionError('positive-control transcript was not seeded with both user turns')
|
|
250
|
+
profile_script="import {runtimeEnvironment} from './lib/opencode/runtime.mjs';process.stdout.write(JSON.stringify(runtimeEnvironment({root:process.argv[1],env:process.env})))"
|
|
251
|
+
env=json.loads(subprocess.run(['node','--input-type=module','-e',profile_script,fixture['temp']],
|
|
252
|
+
cwd=root,env=resolver_env,check=True,capture_output=True,text=True).stdout)
|
|
253
|
+
env={key:value for key,value in env.items() if key in TUI_ENV_KEYS}
|
|
254
|
+
env.update({key:value for key,value in resolver_env.items() if key in SAFE_PARENT_ENV})
|
|
255
|
+
env['OPENCODE_PASSWORD']=fixture['password']
|
|
256
|
+
assert_clean_child_environment(env)
|
|
257
|
+
|
|
258
|
+
for key, value in env.items():
|
|
259
|
+
if key.casefold() in ('opencode_password', 'opencode_server_password'):
|
|
260
|
+
continue
|
|
261
|
+
if value in (fixture['password'], fixture['baseUrl']):
|
|
262
|
+
raise AssertionError(f'synthetic fixture credential unexpectedly occupies allowlisted variable {key}')
|
|
263
|
+
env['OPENCODE_SERVER_PASSWORD'] = fixture['password']
|
|
264
|
+
env['OPENCODE_SERVER_USERNAME'] = 'opencode'
|
|
265
|
+
assert_clean_child_environment(env)
|
|
266
|
+
|
|
267
|
+
tui=Tui(binary,fixture,a['id'],env)
|
|
268
|
+
tui.wait_for(lambda:a['marker'] in tui.screen.visible(),'A marker')
|
|
269
|
+
tui.wait_for(lambda:'CLEAR_A_INITIAL_ASSISTANT_REPLY' in tui.screen.visible(),'A initial assistant reply')
|
|
270
|
+
before_a_response=durable_messages(a['id'])
|
|
271
|
+
before_a=canonical_conversation(conversation(before_a_response))
|
|
272
|
+
cutoff_message=latest_message(before_a_response)
|
|
273
|
+
tui.submit_slash('/clear')
|
|
274
|
+
tui.wait_for(lambda:a['marker'] not in tui.screen.visible() and
|
|
275
|
+
'CLEAR_A_INITIAL_ASSISTANT_REPLY' not in tui.screen.visible(),
|
|
276
|
+
'/clear visual removal')
|
|
277
|
+
after_clear_durable = durable_snapshot(a['id'])
|
|
278
|
+
if after_clear_durable != before_a:
|
|
279
|
+
raise AssertionError('/clear changed durable conversational messages')
|
|
280
|
+
storage_matches = list((Path(fixture['temp']) / 'state').rglob('transcript-presentation.json'))
|
|
281
|
+
if len(storage_matches) != 1:
|
|
282
|
+
raise AssertionError(f'expected one isolated transcript-presentation store, found {len(storage_matches)}')
|
|
283
|
+
storage_path = storage_matches[0]
|
|
284
|
+
state_after_clear = wait_for_file_value(storage_path,
|
|
285
|
+
lambda state: state.get(a['id'], {}).get('before') == cutoff_message['id'],
|
|
286
|
+
f'A transcript cutoff for session {a["id"]} at message {cutoff_message["id"]}')
|
|
287
|
+
tui.submit('VISUAL_CLEAR_CONTINUED_PROMPT')
|
|
288
|
+
tui.wait_for(lambda:'CLEAR_FOLLOWUP_ASSISTANT_REPLY' in tui.screen.visible(),
|
|
289
|
+
'follow-up assistant reply')
|
|
290
|
+
after_followup=durable_snapshot(a['id'])
|
|
291
|
+
if len(after_followup)<=len(before_a) or not any(x.get('type')=='user' and x.get('text')=='VISUAL_CLEAR_CONTINUED_PROMPT' for x in after_followup):
|
|
292
|
+
raise AssertionError('follow-up prompt was not durably submitted')
|
|
293
|
+
state_after_followup = wait_for_file_value(storage_path,
|
|
294
|
+
lambda state: state.get(a['id'], {}).get('before') == cutoff_message['id'],
|
|
295
|
+
'cutoff unchanged after follow-up')
|
|
296
|
+
if state_after_followup.get(a['id'], {}).get('before') != cutoff_message['id']:
|
|
297
|
+
raise AssertionError('follow-up turn changed the persisted transcript cutoff')
|
|
298
|
+
tui.stop();tui=None
|
|
299
|
+
|
|
300
|
+
neighbor=Tui(binary,fixture,b['id'],env)
|
|
301
|
+
neighbor.wait_for(lambda:b['marker'] in neighbor.screen.visible(),'B marker')
|
|
302
|
+
if a['marker'] in neighbor.screen.visible() or durable_snapshot(b['id']) != before_b:
|
|
303
|
+
raise AssertionError('neighbor session B was affected by clearing A')
|
|
304
|
+
neighbor.stop()
|
|
305
|
+
|
|
306
|
+
# Positive control: same short two-turn transcript, no clear command.
|
|
307
|
+
# At the same terminal dimensions and initial tail position, both old
|
|
308
|
+
# and new markers must remain visible without the presentation cutoff.
|
|
309
|
+
control_tui=Tui(binary,fixture,control['id'],env)
|
|
310
|
+
control_tui.wait_for(lambda:control['marker'] in control_tui.screen.visible(),
|
|
311
|
+
'uncleared positive-control initial marker')
|
|
312
|
+
control_tui.wait_for(lambda:'VISUAL_CLEAR_CONTINUED_PROMPT' in control_tui.screen.visible(),
|
|
313
|
+
'uncleared positive-control follow-up marker')
|
|
314
|
+
control_tui.wait_for(lambda:'CLEAR_FOLLOWUP_ASSISTANT_REPLY' in control_tui.screen.visible(),
|
|
315
|
+
'uncleared positive-control follow-up assistant reply')
|
|
316
|
+
control_visible = control_tui.screen.visible()
|
|
317
|
+
if (control['marker'] not in control_visible or
|
|
318
|
+
'VISUAL_CLEAR_CONTINUED_PROMPT' not in control_visible or
|
|
319
|
+
'CLEAR_FOLLOWUP_ASSISTANT_REPLY' not in control_visible):
|
|
320
|
+
raise AssertionError('uncleared control old marker, new marker and assistant reply must all be visible')
|
|
321
|
+
control_tui.stop()
|
|
322
|
+
|
|
323
|
+
reopened=Tui(binary,fixture,a['id'],env)
|
|
324
|
+
reopened.wait_for(lambda:'VISUAL_CLEAR_CONTINUED_PROMPT' in reopened.screen.visible(),
|
|
325
|
+
'post-clear marker visible after reopening A')
|
|
326
|
+
reopened.wait_for(lambda:'CLEAR_FOLLOWUP_ASSISTANT_REPLY' in reopened.screen.visible(),
|
|
327
|
+
'post-clear assistant reply visible after reopening A')
|
|
328
|
+
reopened_visible = reopened.screen.visible()
|
|
329
|
+
if a['marker'] in reopened_visible:
|
|
330
|
+
raise AssertionError('old A marker remains visible; transcript presentation cutoff was not restored')
|
|
331
|
+
if ('VISUAL_CLEAR_CONTINUED_PROMPT' not in reopened_visible or
|
|
332
|
+
'CLEAR_FOLLOWUP_ASSISTANT_REPLY' not in reopened_visible):
|
|
333
|
+
raise AssertionError('reopened A must show both the post-clear user prompt and assistant reply')
|
|
334
|
+
if (control['marker'] not in control_visible or
|
|
335
|
+
'VISUAL_CLEAR_CONTINUED_PROMPT' not in control_visible or
|
|
336
|
+
'CLEAR_FOLLOWUP_ASSISTANT_REPLY' not in control_visible):
|
|
337
|
+
raise AssertionError('positive control did not show old and post-turn content at tested PTY position')
|
|
338
|
+
reopened_durable = durable_snapshot(a['id'])
|
|
339
|
+
if reopened_durable != after_followup:
|
|
340
|
+
raise AssertionError('durable A conversation changed on reopening')
|
|
341
|
+
reopened_state = wait_for_file_value(storage_path,
|
|
342
|
+
lambda state: state.get(a['id'], {}).get('before') == cutoff_message['id'],
|
|
343
|
+
'cutoff restored on reopen')
|
|
344
|
+
if reopened_state.get(a['id'], {}).get('before') != cutoff_message['id']:
|
|
345
|
+
raise AssertionError('reopened TUI lost the persisted presentation cutoff')
|
|
346
|
+
reopened.stop()
|
|
347
|
+
tui=None
|
|
348
|
+
result={'status':'PASS','scope':'Linux POSIX PTY; explicit trusted-preflighted owned staging artifact; offline loopback provider',
|
|
349
|
+
'runtimeIdentity':identity,'sessionIDs':{'a':a['id'],'b':b['id']},
|
|
350
|
+
'command':'native slash autocomplete selected /clear; Enter dispatched it',
|
|
351
|
+
'checks':{'A initial user and assistant markers disappeared from parsed PTY screen':True,
|
|
352
|
+
'A conversational message IDs/content unchanged by clear':True,
|
|
353
|
+
'follow-up prompt submitted and unique assistant reply visibly rendered':True,
|
|
354
|
+
'neighbor B unaffected':True,
|
|
355
|
+
'A old marker hidden while post-clear user and assistant markers are visible on reopen':True,
|
|
356
|
+
'uncleared same-length positive control visibly shows old user, follow-up user, and follow-up assistant reply at same PTY size':True,
|
|
357
|
+
'presentation cutoff ID persisted and unchanged across follow-up/reopen':True,
|
|
358
|
+
'session.revert.clear used':False,
|
|
359
|
+
'ambient credentials excluded by child environment allowlists':True},
|
|
360
|
+
'binarySha256':identity['binarySha256'],'cutoffMessageID':cutoff_message['id'],
|
|
361
|
+
'messagesBeforeClear':len(before_a),'messagesAfterFollowup':len(after_followup)}
|
|
362
|
+
rendered=json.dumps(result,indent=2)+'\n';print(rendered,end='')
|
|
363
|
+
if args.artifact:Path(args.artifact).write_text(rendered)
|
|
364
|
+
finally:
|
|
365
|
+
for running_tui in list(Tui.active):
|
|
366
|
+
running_tui.stop()
|
|
367
|
+
child.terminate()
|
|
368
|
+
try:child.wait(timeout=5)
|
|
369
|
+
except subprocess.TimeoutExpired:child.kill();child.wait()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__=='__main__':
|
|
373
|
+
main()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const REQUIRED_RUNTIME_ASSETS = Object.freeze([
|
|
7
|
+
'opencode-linux-x64.tar.gz',
|
|
8
|
+
'opencode-linux-arm64.tar.gz',
|
|
9
|
+
'opencode-darwin-x64.tar.gz',
|
|
10
|
+
'opencode-darwin-arm64.tar.gz',
|
|
11
|
+
'opencode-windows-x64.tar.gz',
|
|
12
|
+
]);
|
|
13
|
+
export const REQUIRED_RELEASE_ASSETS = Object.freeze([
|
|
14
|
+
...REQUIRED_RUNTIME_ASSETS,
|
|
15
|
+
'runtime-release.json',
|
|
16
|
+
'runtime-release-SHA256SUMS.txt',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const apiRepository = 'B-icy/Carthagent';
|
|
21
|
+
|
|
22
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
23
|
+
await main(process.argv.slice(2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Check the exact remote draft/tag and local checkout before or after acceptance. */
|
|
27
|
+
export async function verifyDraftRuntimeGate({
|
|
28
|
+
phase,
|
|
29
|
+
releaseTag,
|
|
30
|
+
expectedSha,
|
|
31
|
+
expectedReleaseId,
|
|
32
|
+
repo = apiRepository,
|
|
33
|
+
cwd = process.cwd(),
|
|
34
|
+
env = process.env,
|
|
35
|
+
run = runCommand,
|
|
36
|
+
}) {
|
|
37
|
+
if (!['before', 'recheck', 'after'].includes(phase)) throw new Error('phase must be before, recheck, or after');
|
|
38
|
+
if (repo !== apiRepository) throw new Error(`Draft runtime gate is restricted to ${apiRepository}.`);
|
|
39
|
+
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
|
40
|
+
if (releaseTag !== `v${packageJson.version}`) {
|
|
41
|
+
throw new Error(`Release tag must be exactly v${packageJson.version}, received ${JSON.stringify(releaseTag)}.`);
|
|
42
|
+
}
|
|
43
|
+
const version = packageJson.version;
|
|
44
|
+
const releases = await listReleases({ repo, env, run });
|
|
45
|
+
const matches = releases.filter((release) => release?.tag_name === releaseTag);
|
|
46
|
+
if (matches.length !== 1) throw new Error(matches.length ? 'GitHub returned duplicate exact-tag releases.' : `No release exists for ${releaseTag}.`);
|
|
47
|
+
const release = matches[0];
|
|
48
|
+
if (release.draft !== true) throw new Error(`Release ${releaseTag} must remain a draft during installed acceptance.`);
|
|
49
|
+
if (!Number.isSafeInteger(release.id) || release.id <= 0) throw new Error('GitHub returned invalid draft release identity.');
|
|
50
|
+
if (expectedReleaseId !== undefined && String(release.id) !== String(expectedReleaseId)) {
|
|
51
|
+
throw new Error(`Draft release identity changed during acceptance (expected ${expectedReleaseId}, found ${release.id}).`);
|
|
52
|
+
}
|
|
53
|
+
assertRuntimeAssets(release.assets, releaseTag);
|
|
54
|
+
|
|
55
|
+
const remoteSha = await resolveRemoteTag({ repo, releaseTag, env, run });
|
|
56
|
+
const checkoutSha = run('git', ['rev-parse', 'HEAD'], { cwd, env }).trim().toLowerCase();
|
|
57
|
+
if (!/^[a-f0-9]{40}$/.test(checkoutSha)) throw new Error('Could not identify the checked-out release commit.');
|
|
58
|
+
if (remoteSha !== checkoutSha) throw new Error(`Release tag ${releaseTag} resolves to ${remoteSha}, but this checkout is ${checkoutSha}.`);
|
|
59
|
+
if (phase === 'recheck' || phase === 'after') {
|
|
60
|
+
if (!/^[a-f0-9]{40}$/.test(expectedSha ?? '')) throw new Error(`${phase} check requires the pre-acceptance tested SHA.`);
|
|
61
|
+
if (remoteSha !== expectedSha.toLowerCase()) throw new Error(`Release tag changed during acceptance: expected ${expectedSha}, found ${remoteSha}.`);
|
|
62
|
+
if (!/^[1-9][0-9]*$/.test(String(expectedReleaseId ?? ''))) throw new Error(`${phase} check requires the pre-acceptance draft release ID.`);
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze({ releaseId: release.id, sha: remoteSha, version });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertRuntimeAssets(assets, tag) {
|
|
68
|
+
if (!Array.isArray(assets)) throw new Error(`Draft release ${tag} has no asset list.`);
|
|
69
|
+
const names = assets.map((asset) => asset?.name);
|
|
70
|
+
for (const name of REQUIRED_RELEASE_ASSETS) {
|
|
71
|
+
const count = names.filter((candidate) => candidate === name).length;
|
|
72
|
+
if (count !== 1) throw new Error(`Draft release ${tag} must contain exactly one ${name} asset.`);
|
|
73
|
+
}
|
|
74
|
+
const unexpected = names.filter((name) => !REQUIRED_RELEASE_ASSETS.includes(name));
|
|
75
|
+
if (unexpected.length) throw new Error(`Draft release ${tag} contains unexpected assets: ${unexpected.join(', ')}.`);
|
|
76
|
+
if (names.length !== REQUIRED_RELEASE_ASSETS.length) {
|
|
77
|
+
throw new Error(`Draft release ${tag} must contain exactly the seven reviewed runtime release assets.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function listReleases({ repo, env, run }) {
|
|
82
|
+
const output = run('gh', ['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`], { env });
|
|
83
|
+
let pages;
|
|
84
|
+
try { pages = JSON.parse(output); }
|
|
85
|
+
catch { throw new Error('GitHub returned invalid release-list JSON.'); }
|
|
86
|
+
if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) throw new Error('GitHub returned invalid paginated release metadata.');
|
|
87
|
+
return pages.flat();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function resolveRemoteTag({ repo, releaseTag, env, run }) {
|
|
91
|
+
let ref;
|
|
92
|
+
try { ref = JSON.parse(run('gh', ['api', `repos/${repo}/git/ref/tags/${releaseTag}`], { env })); }
|
|
93
|
+
catch (error) { throw new Error(`Could not resolve remote tag ${releaseTag}: ${error.message}`); }
|
|
94
|
+
let type = ref?.object?.type;
|
|
95
|
+
let sha = ref?.object?.sha;
|
|
96
|
+
for (let depth = 0; type === 'tag'; depth++) {
|
|
97
|
+
if (depth >= 5) throw new Error(`Release tag ${releaseTag} has excessive nested annotated tags.`);
|
|
98
|
+
if (!/^[a-f0-9]{40}$/i.test(sha ?? '')) throw new Error(`Release tag ${releaseTag} has an invalid annotated tag object.`);
|
|
99
|
+
let annotated;
|
|
100
|
+
try { annotated = JSON.parse(run('gh', ['api', `repos/${repo}/git/tags/${sha}`], { env })); }
|
|
101
|
+
catch (error) { throw new Error(`Could not resolve annotated tag object for ${releaseTag}: ${error.message}`); }
|
|
102
|
+
type = annotated?.object?.type;
|
|
103
|
+
sha = annotated?.object?.sha;
|
|
104
|
+
}
|
|
105
|
+
if (type !== 'commit' || !/^[a-f0-9]{40}$/i.test(sha ?? '')) {
|
|
106
|
+
throw new Error(`Release tag ${releaseTag} must resolve to a commit.`);
|
|
107
|
+
}
|
|
108
|
+
return sha.toLowerCase();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function main(args) {
|
|
112
|
+
const [phase, releaseTag] = args;
|
|
113
|
+
if (!['before', 'recheck', 'after'].includes(phase) || !releaseTag) {
|
|
114
|
+
console.error('Usage: node tools/verify-draft-runtime-gate.mjs <before|recheck|after> <release-tag>');
|
|
115
|
+
process.exit(2);
|
|
116
|
+
}
|
|
117
|
+
const result = await verifyDraftRuntimeGate({
|
|
118
|
+
phase,
|
|
119
|
+
releaseTag,
|
|
120
|
+
expectedSha: process.env.EXPECTED_SHA,
|
|
121
|
+
expectedReleaseId: process.env.EXPECTED_RELEASE_ID,
|
|
122
|
+
repo: process.env.GITHUB_REPOSITORY,
|
|
123
|
+
cwd: process.cwd(),
|
|
124
|
+
});
|
|
125
|
+
if (phase === 'before') {
|
|
126
|
+
if (!process.env.GITHUB_OUTPUT) throw new Error('GITHUB_OUTPUT is required for the pre-acceptance gate.');
|
|
127
|
+
const { appendFile } = await import('node:fs/promises');
|
|
128
|
+
await appendFile(process.env.GITHUB_OUTPUT, `tested_sha=${result.sha}\nrelease_id=${result.releaseId}\n`);
|
|
129
|
+
}
|
|
130
|
+
console.log(`Verified draft release ${releaseTag} at ${result.sha} (${phase} installed acceptance).`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function runCommand(command, args, { cwd, env } = {}) {
|
|
134
|
+
const result = spawnSync(command, args, { cwd, env, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
|
|
135
|
+
if (result.error) throw result.error;
|
|
136
|
+
if (result.status !== 0) throw new Error(`${command} exited ${result.status}: ${(result.stderr || result.stdout).trim().slice(-2000)}`);
|
|
137
|
+
return result.stdout;
|
|
138
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { createRuntimeGithubFetch } from '../lib/opencode/runtime-github-transport.mjs';
|
|
5
|
+
import { readPinnedOwnedRuntimeInputs } from './pinned-owned-runtime-inputs.mjs';
|
|
6
|
+
|
|
7
|
+
export const REQUIRED_RUNTIME_TARGETS = Object.freeze({
|
|
8
|
+
'linux-x64': ['linux', 'x64', 'opencode-linux-x64.tar.gz'],
|
|
9
|
+
'linux-arm64': ['linux', 'arm64', 'opencode-linux-arm64.tar.gz'],
|
|
10
|
+
'darwin-x64': ['darwin', 'x64', 'opencode-darwin-x64.tar.gz'],
|
|
11
|
+
'darwin-arm64': ['darwin', 'arm64', 'opencode-darwin-arm64.tar.gz'],
|
|
12
|
+
'windows-x64': ['windows', 'x64', 'opencode-windows-x64.tar.gz'],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const HEX_40 = /^[a-f0-9]{40}$/i;
|
|
16
|
+
const HEX_64 = /^[a-f0-9]{64}$/i;
|
|
17
|
+
|
|
18
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
19
|
+
const [releaseVersion, manifestPath, assetsBaseUrl] = process.argv.slice(2);
|
|
20
|
+
if (!releaseVersion || !manifestPath || !assetsBaseUrl) {
|
|
21
|
+
console.error('Usage: node tools/verify-owned-runtime-release.mjs <version> <trusted-manifest.json> <release-assets-base-url>');
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
await verifyOwnedRuntimeRelease({ releaseVersion, manifestPath, assetsBaseUrl, repoRoot: process.cwd() });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Validate the packaged trusted outer manifest and acquire/verify each exact asset. */
|
|
28
|
+
export async function verifyOwnedRuntimeRelease({
|
|
29
|
+
releaseVersion,
|
|
30
|
+
manifestPath,
|
|
31
|
+
assetsBaseUrl,
|
|
32
|
+
repoRoot,
|
|
33
|
+
acquireRuntime,
|
|
34
|
+
cacheRoot,
|
|
35
|
+
fetchImpl,
|
|
36
|
+
runtimeToken = process.env.CTG_RUNTIME_GITHUB_TOKEN,
|
|
37
|
+
}) {
|
|
38
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
39
|
+
validateRuntimeReleaseManifest(manifest, releaseVersion);
|
|
40
|
+
await validateRuntimeBuildInputs(manifest, path.resolve(repoRoot ?? process.cwd()));
|
|
41
|
+
const acquireOwnedRuntime = acquireRuntime ?? (await import('../lib/opencode/runtime-artifact.mjs')).acquireOwnedRuntime;
|
|
42
|
+
const runtimeFetch = runtimeToken
|
|
43
|
+
? createRuntimeGithubFetch({ token: runtimeToken, fetchImpl: fetchImpl ?? globalThis.fetch })
|
|
44
|
+
: fetchImpl;
|
|
45
|
+
|
|
46
|
+
for (const [key, [platform, arch, name]] of Object.entries(REQUIRED_RUNTIME_TARGETS)) {
|
|
47
|
+
const asset = manifest.assets[key];
|
|
48
|
+
const installed = await acquireOwnedRuntime({
|
|
49
|
+
releaseVersion,
|
|
50
|
+
platform,
|
|
51
|
+
arch,
|
|
52
|
+
baseUrl: assetsBaseUrl,
|
|
53
|
+
expectedArchiveSha256: asset.archiveSha256,
|
|
54
|
+
expectedExecutableSha256: asset.executableSha256,
|
|
55
|
+
expectedSourceRevision: manifest.sourceRevision,
|
|
56
|
+
expectedPatchSha256: manifest.patchSha256,
|
|
57
|
+
expectedLockSha256: manifest.lockSha256,
|
|
58
|
+
expectedCompatibilityVersion: manifest.compatibilityVersion,
|
|
59
|
+
expectedRuntimeVersion: manifest.runtimeVersion,
|
|
60
|
+
expectedChannel: `ctg.${manifest.compatibilityVersion}`,
|
|
61
|
+
...(cacheRoot ? { cacheRoot } : {}),
|
|
62
|
+
...(runtimeFetch ? { fetchImpl: runtimeFetch } : {}),
|
|
63
|
+
});
|
|
64
|
+
if (typeof installed !== 'string' || !installed) throw new Error(`Acquisition did not verify ${name}`);
|
|
65
|
+
}
|
|
66
|
+
return manifest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateRuntimeReleaseManifest(value, releaseVersion) {
|
|
70
|
+
if (value?.version !== 1 || value.releaseVersion !== releaseVersion) {
|
|
71
|
+
throw new Error('Trusted runtime release manifest version does not match the release tag');
|
|
72
|
+
}
|
|
73
|
+
if (typeof value.compatibilityVersion !== 'string' || !value.compatibilityVersion.trim()) {
|
|
74
|
+
throw new Error('Runtime compatibilityVersion is required');
|
|
75
|
+
}
|
|
76
|
+
if (typeof value.runtimeVersion !== 'string' || !value.runtimeVersion.trim()) {
|
|
77
|
+
throw new Error('Runtime runtimeVersion is required');
|
|
78
|
+
}
|
|
79
|
+
if (typeof value.sourceRevision !== 'string' || !HEX_40.test(value.sourceRevision)) {
|
|
80
|
+
throw new Error('Runtime sourceRevision must be a 40-character commit SHA');
|
|
81
|
+
}
|
|
82
|
+
for (const field of ['patchSha256', 'lockSha256']) {
|
|
83
|
+
if (typeof value[field] !== 'string' || !HEX_64.test(value[field])) {
|
|
84
|
+
throw new Error(`Runtime ${field} must be a 64-character SHA-256 digest`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!value.assets || typeof value.assets !== 'object' || Array.isArray(value.assets)) {
|
|
88
|
+
throw new Error('Runtime assets must be a platform map');
|
|
89
|
+
}
|
|
90
|
+
const actual = Object.keys(value.assets).sort();
|
|
91
|
+
const required = Object.keys(REQUIRED_RUNTIME_TARGETS).sort();
|
|
92
|
+
if (JSON.stringify(actual) !== JSON.stringify(required)) {
|
|
93
|
+
throw new Error(`Trusted runtime manifest must contain exactly: ${required.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
for (const [key, [, , name]] of Object.entries(REQUIRED_RUNTIME_TARGETS)) {
|
|
96
|
+
const asset = value.assets[key];
|
|
97
|
+
if (asset?.name !== name) throw new Error(`Runtime manifest asset name for ${key} must be ${name}`);
|
|
98
|
+
for (const field of ['archiveSha256', 'executableSha256']) {
|
|
99
|
+
if (typeof asset[field] !== 'string' || !HEX_64.test(asset[field])) {
|
|
100
|
+
throw new Error(`${key}.${field} must be a 64-character SHA-256 digest`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Bind trusted artifact provenance to the exact recipe and patch in the tagged tree. */
|
|
107
|
+
export async function validateRuntimeBuildInputs(value, repoRoot) {
|
|
108
|
+
const pinnedInputs = await readPinnedOwnedRuntimeInputs(repoRoot);
|
|
109
|
+
for (const field of ['sourceRevision', 'patchSha256', 'lockSha256']) {
|
|
110
|
+
if (pinnedInputs[field] !== value[field].toLowerCase()) {
|
|
111
|
+
throw new Error(`Runtime ${field} does not match the tagged build recipe`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -34,4 +34,24 @@ if (packInfo.length !== 1 || packInfo[0].name !== packageJson.name || packInfo[0
|
|
|
34
34
|
throw new Error(`Packed npm manifest must be ${packageJson.name}@${version}`);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// npm publish silently drops bin entries it considers invalid (npm 11 rejects
|
|
38
|
+
// "./bin/ctg.mjs"), so a published version could ship without the ctg command.
|
|
39
|
+
// Check the publish-time manifest npm itself would upload, not package.json.
|
|
40
|
+
const published = spawnSync('npm', ['publish', '--dry-run', '--json', '--tag', 'next'], { encoding: 'utf8' });
|
|
41
|
+
if (published.status !== 0) {
|
|
42
|
+
process.stderr.write(published.stderr);
|
|
43
|
+
process.exit(published.status ?? 1);
|
|
44
|
+
}
|
|
45
|
+
const corrections = published.stderr.split('\n').filter(line => /auto-corrected|was invalid and removed/.test(line));
|
|
46
|
+
if (corrections.length) {
|
|
47
|
+
throw new Error(`npm publish would rewrite package.json; fix it before releasing:\n${published.stderr}`);
|
|
48
|
+
}
|
|
49
|
+
const binPath = packageJson.bin?.ctg;
|
|
50
|
+
if (typeof binPath !== 'string' || binPath.startsWith('./')) {
|
|
51
|
+
throw new Error(`package.json bin.ctg must be a plain relative path npm publish keeps (got ${JSON.stringify(binPath)})`);
|
|
52
|
+
}
|
|
53
|
+
if (!packInfo[0].files.some(file => file.path === binPath && (file.mode & 0o111))) {
|
|
54
|
+
throw new Error(`Packed npm package must contain executable ${binPath} for the ctg command`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log(`Verified release tag ${tag}, lockfile, and packed package ${packageJson.name}@${version} with ctg -> ${binPath}`);
|