carthagent 1.0.7 → 1.1.0-preview.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -79
- package/bin/ctg.mjs +14 -732
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +314 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +177 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +8 -4
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +298 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +197 -0
- package/plugins/carthagent/src/server/delivery.ts +217 -106
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +58 -30
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +39 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +492 -0
- package/plugins/carthagent/src/ui/client.ts +125 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +232 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +31 -0
- package/plugins/carthagent/src/ui/native-review.mjs +154 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +122 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
- package/plugins/carthagent/src/ui/voyages.tsx +127 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +60 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +258 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +53 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +426 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +267 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +75 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +395 -0
- package/tests/native-voyages-ui.test.mjs +277 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +546 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +230 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +36 -0
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +100 -18
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +83 -61
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import pathlib
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
MODULE_PATH = pathlib.Path(__file__).resolve().parents[1] / "tools" / "test-native-terminal-performance.py"
|
|
7
|
+
SPEC = importlib.util.spec_from_file_location("native_terminal_probe", MODULE_PATH)
|
|
8
|
+
PROBE = importlib.util.module_from_spec(SPEC)
|
|
9
|
+
SPEC.loader.exec_module(PROBE)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EditorEchoTests(unittest.TestCase):
|
|
13
|
+
def test_native_placeholder_anchor_and_exact_changed_cell_are_required(self):
|
|
14
|
+
screen = PROBE.Screen(rows=4, cols=40)
|
|
15
|
+
row, col = 2, 5
|
|
16
|
+
line = PROBE.NATIVE_EDITOR_PLACEHOLDER
|
|
17
|
+
for offset, char in enumerate(line):
|
|
18
|
+
screen.cells[row][col + offset] = char
|
|
19
|
+
before = [cells[:] for cells in screen.cells]
|
|
20
|
+
self.assertEqual(PROBE.locate_text(screen, line), (row, col))
|
|
21
|
+
screen.cells[row][col] = "z"
|
|
22
|
+
self.assertTrue(PROBE.editor_contains_typed_cell(screen, "z", before, row, col))
|
|
23
|
+
|
|
24
|
+
def test_arbitrary_transcript_or_another_editor_cell_is_not_an_echo(self):
|
|
25
|
+
screen = PROBE.Screen(rows=4, cols=40)
|
|
26
|
+
row, col = 2, 5
|
|
27
|
+
for offset, char in enumerate(PROBE.NATIVE_EDITOR_PLACEHOLDER):
|
|
28
|
+
screen.cells[row][col + offset] = char
|
|
29
|
+
before = [cells[:] for cells in screen.cells]
|
|
30
|
+
screen.cells[row][col + 4] = "z"
|
|
31
|
+
self.assertFalse(PROBE.editor_contains_typed_cell(screen, "z", before, row, col))
|
|
32
|
+
screen.cells[row][col + 4] = before[row][col + 4]
|
|
33
|
+
screen.cells[0][0] = "z"
|
|
34
|
+
self.assertFalse(PROBE.editor_contains_typed_cell(screen, "z", before, row, col))
|
|
35
|
+
|
|
36
|
+
def test_pty_ready_detection_uses_the_actual_current_product_hint(self):
|
|
37
|
+
screen = PROBE.Screen(rows=4, cols=80)
|
|
38
|
+
hint = "Message carthagent…"
|
|
39
|
+
for offset, char in enumerate(hint):
|
|
40
|
+
screen.cells[2][5 + offset] = char
|
|
41
|
+
self.assertEqual(PROBE.locate_text(screen, PROBE.NATIVE_EDITOR_PLACEHOLDER), (2, 5))
|
|
42
|
+
self.assertNotEqual(PROBE.NATIVE_EDITOR_PLACEHOLDER, "Ask anything")
|
|
43
|
+
|
|
44
|
+
def test_workspace_render_requires_fresh_post_submit_route_and_loaded_section(self):
|
|
45
|
+
screen = PROBE.Screen(rows=5, cols=80)
|
|
46
|
+
screen.cells[1][2:25] = list("CARTHAGENT · WORKSPACE")
|
|
47
|
+
before = ["old session", "slash autocomplete /ctg-workspace", "", "", ""]
|
|
48
|
+
screen.cells[3][2:26] = list("Workspace native sessions")
|
|
49
|
+
self.assertTrue(PROBE.workspace_render_completed(screen, before))
|
|
50
|
+
|
|
51
|
+
def test_workspace_render_rejects_blank_partial_and_stale_frames(self):
|
|
52
|
+
screen = PROBE.Screen(rows=5, cols=80)
|
|
53
|
+
before = ["old session", "", "", "", ""]
|
|
54
|
+
screen.cells[1][2:25] = list("CARTHAGENT · WORKSPACE")
|
|
55
|
+
self.assertFalse(PROBE.workspace_render_completed(screen, before))
|
|
56
|
+
screen.cells[3][2:26] = list("Workspace native sessions")
|
|
57
|
+
stale = ["", "CARTHAGENT · WORKSPACE", "", "Workspace native sessions", ""]
|
|
58
|
+
self.assertFalse(PROBE.workspace_render_completed(screen, stale))
|
|
59
|
+
|
|
60
|
+
def test_workspace_command_is_not_timed_while_completion_or_old_editor_is_visible(self):
|
|
61
|
+
screen = PROBE.Screen(rows=5, cols=80)
|
|
62
|
+
origin = (1, 2)
|
|
63
|
+
screen.cells[0][20:34] = list("/ctg-workspace")
|
|
64
|
+
screen.cells[2][2:20] = list("Carthagent commands")
|
|
65
|
+
self.assertFalse(PROBE.workspace_command_staged(screen, origin))
|
|
66
|
+
screen.cells[2][2:20] = list(" ")
|
|
67
|
+
screen.cells[3][2:20] = list(PROBE.NATIVE_EDITOR_PLACEHOLDER)
|
|
68
|
+
self.assertFalse(PROBE.workspace_command_staged(screen, origin))
|
|
69
|
+
screen.cells[3][2:20] = list(" ")
|
|
70
|
+
self.assertFalse(PROBE.workspace_command_staged(screen, origin))
|
|
71
|
+
screen.cells[1][2:16] = list("/ctg-workspace")
|
|
72
|
+
self.assertTrue(PROBE.workspace_command_staged(screen, origin))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
unittest.main()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import hashlib
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import Mock, patch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PTY_AVAILABLE = False
|
|
14
|
+
PROBE = None
|
|
15
|
+
if os.name == 'posix':
|
|
16
|
+
try:
|
|
17
|
+
import fcntl
|
|
18
|
+
import pty
|
|
19
|
+
import tty
|
|
20
|
+
except ImportError:
|
|
21
|
+
pass
|
|
22
|
+
else:
|
|
23
|
+
PTY_AVAILABLE = hasattr(os, 'openpty')
|
|
24
|
+
|
|
25
|
+
if PTY_AVAILABLE:
|
|
26
|
+
MODULE_PATH = pathlib.Path(__file__).resolve().parents[1] / 'tools' / 'native-terminal-semantics.py'
|
|
27
|
+
SPEC = importlib.util.spec_from_file_location('native_terminal_semantics', MODULE_PATH)
|
|
28
|
+
PROBE = importlib.util.module_from_spec(SPEC)
|
|
29
|
+
SPEC.loader.exec_module(PROBE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@unittest.skipUnless(PTY_AVAILABLE, 'requires POSIX fcntl, pty, tty and os.openpty')
|
|
33
|
+
class DuplexTransportTests(unittest.TestCase):
|
|
34
|
+
def assert_flags_restored(self, master, flags):
|
|
35
|
+
restored = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
36
|
+
# XNU fcntl.h: FWASWRITTEN (0x10000) records successful writes;
|
|
37
|
+
# sys_generic.c sets it, and kern_descrip.c F_SETFL preserves it
|
|
38
|
+
# outside FCNTLFLAGS. It is not O_LARGEFILE or a controllable flag.
|
|
39
|
+
# Compare every other bit, including access mode and O_NONBLOCK.
|
|
40
|
+
kernel_write_history = 0x10000 if sys.platform == 'darwin' else 0
|
|
41
|
+
self.assertEqual(restored & ~kernel_write_history, flags & ~kernel_write_history)
|
|
42
|
+
self.assertEqual(os.get_blocking(master), not bool(flags & os.O_NONBLOCK))
|
|
43
|
+
|
|
44
|
+
def launch(self, program):
|
|
45
|
+
master, slave = pty.openpty()
|
|
46
|
+
tty.setraw(slave)
|
|
47
|
+
child = subprocess.Popen([sys.executable, '-u', '-c', program],
|
|
48
|
+
stdin=slave, stdout=slave, stderr=slave)
|
|
49
|
+
os.close(slave)
|
|
50
|
+
self.addCleanup(os.close, master)
|
|
51
|
+
|
|
52
|
+
def stop():
|
|
53
|
+
if child.poll() is None:
|
|
54
|
+
child.kill()
|
|
55
|
+
child.wait(timeout=3)
|
|
56
|
+
|
|
57
|
+
self.addCleanup(stop)
|
|
58
|
+
return master, child
|
|
59
|
+
|
|
60
|
+
def test_large_input_drains_output_and_preserves_every_byte(self):
|
|
61
|
+
payload = b'\x1b[200~' + ('café 日本語 😀\n' * 20000).encode() + b'\x1b[201~'
|
|
62
|
+
# The child deliberately fills output BEFORE consuming any input. A
|
|
63
|
+
# write-only parent deadlocks once the independent PTY queues fill.
|
|
64
|
+
master, child = self.launch(f'''
|
|
65
|
+
import hashlib, os, time
|
|
66
|
+
block = b'O' * 4096
|
|
67
|
+
for _ in range(128):
|
|
68
|
+
os.write(1, block)
|
|
69
|
+
data = bytearray()
|
|
70
|
+
while len(data) < {len(payload)}:
|
|
71
|
+
data.extend(os.read(0, min(4096, {len(payload)} - len(data))))
|
|
72
|
+
os.write(1, b'\\nDIGEST:' + hashlib.sha256(data).hexdigest().encode() + b'\\n')
|
|
73
|
+
time.sleep(2)
|
|
74
|
+
''')
|
|
75
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
76
|
+
output = bytearray()
|
|
77
|
+
original_write = os.write
|
|
78
|
+
counts = []
|
|
79
|
+
retries = [InterruptedError(), BlockingIOError()]
|
|
80
|
+
|
|
81
|
+
def partial_write(fd, data):
|
|
82
|
+
if retries:
|
|
83
|
+
raise retries.pop()
|
|
84
|
+
# Force short writes independently of the kernel's queue sizing.
|
|
85
|
+
count = original_write(fd, data[:997])
|
|
86
|
+
counts.append(count)
|
|
87
|
+
return count
|
|
88
|
+
|
|
89
|
+
deadline = time.monotonic() + 5
|
|
90
|
+
with patch.object(PROBE.os, 'write', side_effect=partial_write):
|
|
91
|
+
PROBE.write_pty_input(master, payload, deadline, output.extend, child)
|
|
92
|
+
self.assert_flags_restored(master, flags)
|
|
93
|
+
self.assertEqual(sum(counts), len(payload))
|
|
94
|
+
self.assertGreater(len(counts), 1)
|
|
95
|
+
self.assertIn(b'O' * 4096, output)
|
|
96
|
+
expected = b'DIGEST:' + hashlib.sha256(payload).hexdigest().encode()
|
|
97
|
+
while expected not in output and time.monotonic() < deadline:
|
|
98
|
+
if PROBE.select.select([master], [], [], .05)[0]:
|
|
99
|
+
output.extend(os.read(master, 65536))
|
|
100
|
+
self.assertIn(expected, output)
|
|
101
|
+
|
|
102
|
+
def test_unconsumed_input_times_out_and_restores_flags(self):
|
|
103
|
+
master, child = self.launch('import time; time.sleep(10)')
|
|
104
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
105
|
+
started = time.monotonic()
|
|
106
|
+
with self.assertRaisesRegex(TimeoutError, r'bytes written; child status=None'):
|
|
107
|
+
PROBE.write_pty_input(master, b'x' * 1000000, started + .2, lambda _: None, child)
|
|
108
|
+
self.assertLess(time.monotonic() - started, 1.5)
|
|
109
|
+
self.assert_flags_restored(master, flags)
|
|
110
|
+
|
|
111
|
+
def test_exited_child_fails_without_waiting_for_deadline(self):
|
|
112
|
+
master, child = self.launch('pass')
|
|
113
|
+
child.wait(timeout=3)
|
|
114
|
+
with self.assertRaisesRegex(RuntimeError, 'exited during PTY input'):
|
|
115
|
+
PROBE.write_pty_input(master, b'x', time.monotonic() + 5, lambda _: None, child)
|
|
116
|
+
|
|
117
|
+
def test_output_callback_failure_restores_flags(self):
|
|
118
|
+
master, child = self.launch("import os, time; os.write(1, b'ready'); time.sleep(10)")
|
|
119
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
120
|
+
|
|
121
|
+
def reject_output(_chunk):
|
|
122
|
+
raise ValueError('screen rejected output')
|
|
123
|
+
|
|
124
|
+
with self.assertRaisesRegex(ValueError, 'screen rejected output'):
|
|
125
|
+
PROBE.write_pty_input(master, b'x' * 1000000, time.monotonic() + 2,
|
|
126
|
+
reject_output, child)
|
|
127
|
+
self.assert_flags_restored(master, flags)
|
|
128
|
+
|
|
129
|
+
def test_initially_nonblocking_descriptor_stays_nonblocking(self):
|
|
130
|
+
master, child = self.launch('import time; time.sleep(10)')
|
|
131
|
+
os.set_blocking(master, False)
|
|
132
|
+
flags = fcntl.fcntl(master, fcntl.F_GETFL)
|
|
133
|
+
PROBE.write_pty_input(master, b'x', time.monotonic() + 2, lambda _: None, child)
|
|
134
|
+
self.assert_flags_restored(master, flags)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@unittest.skipUnless(PTY_AVAILABLE, 'requires POSIX probe imports')
|
|
138
|
+
class SnapshotTests(unittest.TestCase):
|
|
139
|
+
def test_utf8_sync_and_queries_survive_every_chunk_boundary(self):
|
|
140
|
+
payload = b'\x1b[?2026h' + 'café 日本語 😀'.encode() + b'\x1b[6n\x1b[c\x1b[?2026l'
|
|
141
|
+
for boundary in range(len(payload) + 1):
|
|
142
|
+
with self.subTest(boundary=boundary):
|
|
143
|
+
screen = PROBE.Screen()
|
|
144
|
+
screen.feed(payload[:boundary])
|
|
145
|
+
screen.feed(payload[boundary:])
|
|
146
|
+
self.assertFalse(screen.sync_open)
|
|
147
|
+
self.assertEqual(screen.mode, 'text')
|
|
148
|
+
self.assertIn('café 日本語 😀', screen.visible())
|
|
149
|
+
self.assertEqual(screen.queries, [b'\x1b[1;1R', b'\x1b[?1;2c'])
|
|
150
|
+
|
|
151
|
+
def test_backlogged_paint_is_consumed_and_all_queries_answered_before_snapshot(self):
|
|
152
|
+
screen = PROBE.Screen()
|
|
153
|
+
screen.feed(b'\x1b[?2026hSearch sessions and projects\x1b[6')
|
|
154
|
+
chunks = [b'n\x1b[', b'c\x1b[2Jshift+tab agents ctrl+p commands\x1b[?2026', b'l']
|
|
155
|
+
child = Mock(returncode=None)
|
|
156
|
+
child.poll.return_value = None
|
|
157
|
+
replies = []
|
|
158
|
+
# select times out once mid-frame: no partial snapshot may escape.
|
|
159
|
+
with patch.object(PROBE.select, 'select', side_effect=[([], [], []),
|
|
160
|
+
([123], [], []), ([123], [], []), ([123], [], []), ([], [], [])]), \
|
|
161
|
+
patch.object(PROBE.os, 'read', side_effect=chunks):
|
|
162
|
+
PROBE.drain_terminal_snapshot(123, screen, time.monotonic() + 2,
|
|
163
|
+
screen.feed, replies.append, child)
|
|
164
|
+
self.assertFalse(screen.sync_open)
|
|
165
|
+
self.assertNotIn('sessions and projects', screen.visible())
|
|
166
|
+
self.assertIn('ctrl+p commands', screen.visible())
|
|
167
|
+
self.assertEqual(replies, [b'\x1b[1;1R', b'\x1b[?1;2c'])
|
|
168
|
+
self.assertEqual(screen.queries, [])
|
|
169
|
+
|
|
170
|
+
def test_unfinished_frame_and_continuous_backlog_obey_existing_deadline(self):
|
|
171
|
+
for incomplete in (False, True):
|
|
172
|
+
with self.subTest(incomplete=incomplete):
|
|
173
|
+
screen = PROBE.Screen()
|
|
174
|
+
if incomplete:
|
|
175
|
+
screen.feed(b'\x1b[?2026h')
|
|
176
|
+
child = Mock(returncode=None)
|
|
177
|
+
child.poll.return_value = None
|
|
178
|
+
with patch.object(PROBE.time, 'monotonic', side_effect=[1, 2, 3]), \
|
|
179
|
+
patch.object(PROBE.select, 'select', return_value=([123], [], [])), \
|
|
180
|
+
patch.object(PROBE.os, 'read', return_value=b'x'):
|
|
181
|
+
with self.assertRaisesRegex(TimeoutError, 'existing input deadline'):
|
|
182
|
+
PROBE.drain_terminal_snapshot(123, screen, 3, screen.feed, Mock(), child)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@unittest.skipUnless(PTY_AVAILABLE, 'requires POSIX probe imports')
|
|
186
|
+
class PickerSequencingTests(unittest.TestCase):
|
|
187
|
+
def test_pending_acceptance_prevents_resize_and_paste_with_stale_home_footer(self):
|
|
188
|
+
# Execute the actual prompt phase from main, not a duplicate predicate.
|
|
189
|
+
# A slow startup (>5s) leaves the home footer visible behind the picker
|
|
190
|
+
# in the same iteration that Escape marks its acceptance PENDING.
|
|
191
|
+
tree = ast.parse(MODULE_PATH.read_text(encoding='utf8'))
|
|
192
|
+
phases = [node for node in ast.walk(tree) if isinstance(node, ast.If) and
|
|
193
|
+
isinstance(node.test, ast.BoolOp) and
|
|
194
|
+
[ast.unparse(value) for value in node.test.values[:2]] ==
|
|
195
|
+
['not sent', 'not picker_only']]
|
|
196
|
+
self.assertEqual(len(phases), 1)
|
|
197
|
+
phase = compile(ast.Module(body=phases, type_ignores=[]), str(MODULE_PATH), 'exec')
|
|
198
|
+
for status in ('PENDING', 'PASS', 'FAIL', 'UNSUPPORTED'):
|
|
199
|
+
with self.subTest(status=status):
|
|
200
|
+
screen = PROBE.Screen()
|
|
201
|
+
screen.feed(b'shift+tab agents ctrl+p commands')
|
|
202
|
+
payload = ('native-pty: café 日本語 😀\n' + 'large-paste-λ-' * 1200 +
|
|
203
|
+
'\nEND-PASTE-終').encode('utf8')
|
|
204
|
+
write_input = Mock()
|
|
205
|
+
namespace = dict(sent=False, picker_only=False, focused_case=False,
|
|
206
|
+
picker_cancelled=True, elapsed=6, home_ready=True,
|
|
207
|
+
result={'checks': {'pickerEscapeCancel': {'status': status}}},
|
|
208
|
+
args=Mock(focused_host_roundtrip=False), layout_only=False,
|
|
209
|
+
resize_case=True, screen=screen, output=bytearray(),
|
|
210
|
+
master=123, child=Mock(pid=456), payload=payload,
|
|
211
|
+
write_input=write_input, fcntl=Mock(), termios=PROBE.termios,
|
|
212
|
+
struct=PROBE.struct, os=Mock(), signal=PROBE.signal,
|
|
213
|
+
time=Mock())
|
|
214
|
+
exec(phase, namespace)
|
|
215
|
+
if status == 'PENDING':
|
|
216
|
+
namespace['fcntl'].ioctl.assert_not_called()
|
|
217
|
+
namespace['os'].kill.assert_not_called()
|
|
218
|
+
write_input.assert_not_called()
|
|
219
|
+
self.assertEqual((screen.rows, screen.columns), (32, 100))
|
|
220
|
+
self.assertIn('ctrl+p commands', screen.visible())
|
|
221
|
+
self.assertFalse(namespace['sent'])
|
|
222
|
+
else:
|
|
223
|
+
namespace['fcntl'].ioctl.assert_called_once()
|
|
224
|
+
namespace['os'].kill.assert_called_once_with(456, PROBE.signal.SIGWINCH)
|
|
225
|
+
write_input.assert_called_once_with(b'\x1b[200~' + payload + b'\x1b[201~')
|
|
226
|
+
self.assertEqual(len(payload), 18047)
|
|
227
|
+
self.assertEqual((screen.rows, screen.columns), (12, 40))
|
|
228
|
+
self.assertTrue(namespace['sent'])
|
|
229
|
+
self.assertEqual(namespace['result']['checks']['pickerEscapeCancel']['status'], status)
|
|
230
|
+
|
|
231
|
+
if __name__ == '__main__':
|
|
232
|
+
unittest.main()
|
package/tests/update.test.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
fetchLatestRelease,
|
|
11
11
|
latestReleaseCached,
|
|
12
12
|
normalizeRelease,
|
|
13
|
+
npmInstallSpec,
|
|
13
14
|
staleGlobalInstallPaths,
|
|
14
15
|
updateCheckEnabled,
|
|
15
16
|
} from '../lib/update.mjs';
|
|
@@ -82,6 +83,16 @@ test('normalizeRelease rejects install specs that do not install the release tag
|
|
|
82
83
|
assert.equal(normalizeRelease(manifest).installSpec, 'github:B-icy/Carthagent#v0.3.0');
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
test('normalizeRelease binds the install ref exactly to the validated version', () => {
|
|
87
|
+
for (const tag of ['main', 'v0.3.1', 'v0.3.0%2Fmain', ' v0.3.0', 'v0.3.0\n']) {
|
|
88
|
+
assert.equal(normalizeRelease({ version: '0.3.0', tag }), null, `rejected tag ${JSON.stringify(tag)}`);
|
|
89
|
+
}
|
|
90
|
+
assert.equal(normalizeRelease({ version: '1.2.3-beta.2', tag_name: 'v1.2.3-beta.2' })?.installSpec,
|
|
91
|
+
'github:B-icy/Carthagent#v1.2.3-beta.2');
|
|
92
|
+
assert.equal(normalizeRelease({ version: '1.2.3-beta.2', tag_name: 'v1.2.3' }), null);
|
|
93
|
+
assert.equal(normalizeRelease({ version: '01.2.3', tag: 'v01.2.3' }), null);
|
|
94
|
+
});
|
|
95
|
+
|
|
85
96
|
test('fetchLatestRelease falls back to GitHub when the manifest fails validation', async () => {
|
|
86
97
|
// A control-plane 200 whose installSpec is rejected must not dead-end the
|
|
87
98
|
// update — the GitHub release still resolves.
|
|
@@ -100,6 +111,22 @@ test('fetchLatestRelease falls back to GitHub when the manifest fails validation
|
|
|
100
111
|
assert.equal(urls.length, 2);
|
|
101
112
|
});
|
|
102
113
|
|
|
114
|
+
test('fetchLatestRelease forwards cancellation and does not fall back after an abort', async () => {
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
let calls = 0;
|
|
117
|
+
await assert.rejects(fetchLatestRelease({
|
|
118
|
+
cloudUrl: 'https://cloud.test',
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
fetchImpl: async (_url, options) => {
|
|
121
|
+
calls++;
|
|
122
|
+
assert.equal(options.signal.aborted, false);
|
|
123
|
+
controller.abort(new Error('cancelled'));
|
|
124
|
+
throw controller.signal.reason;
|
|
125
|
+
},
|
|
126
|
+
}), /cancelled/);
|
|
127
|
+
assert.equal(calls, 1, 'an aborted control-plane request must not trigger GitHub fallback');
|
|
128
|
+
});
|
|
129
|
+
|
|
103
130
|
// ---- latestReleaseCached
|
|
104
131
|
|
|
105
132
|
test('latestReleaseCached fetches once, then serves the cache', () => withTempDir(async dir => {
|
|
@@ -147,6 +174,15 @@ test('detectInstallMethod distinguishes clones from packed installs', () => with
|
|
|
147
174
|
assert.equal(detectInstallMethod(dir), 'clone');
|
|
148
175
|
}));
|
|
149
176
|
|
|
177
|
+
test('npmInstallSpec prefers an exact registry version and falls back when lookup fails', async () => {
|
|
178
|
+
const release = { version: '1.2.3', installSpec: 'github:B-icy/Carthagent#v1.2.3' };
|
|
179
|
+
let lookedUp;
|
|
180
|
+
assert.equal(await npmInstallSpec(release, async spec => { lookedUp = spec; return true; }), 'carthagent@1.2.3');
|
|
181
|
+
assert.equal(lookedUp, 'carthagent@1.2.3');
|
|
182
|
+
assert.equal(await npmInstallSpec(release, async () => false), release.installSpec);
|
|
183
|
+
assert.equal(await npmInstallSpec(release, async () => { throw new Error('npm unavailable'); }), release.installSpec);
|
|
184
|
+
});
|
|
185
|
+
|
|
150
186
|
// ---- staleGlobalInstallPaths / cleanStaleGlobalInstall
|
|
151
187
|
|
|
152
188
|
test('staleGlobalInstallPaths flags non-directory carthagent and stash leftovers', () => withTempDir(dir => {
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { REQUIRED_RELEASE_ASSETS, verifyDraftRuntimeGate } from '../tools/verify-draft-runtime-gate.mjs';
|
|
9
|
+
|
|
10
|
+
const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
|
+
const tag = `v${version}`;
|
|
12
|
+
const sha = 'a'.repeat(40);
|
|
13
|
+
const producerReleaseAssets = [
|
|
14
|
+
'opencode-linux-x64.tar.gz',
|
|
15
|
+
'opencode-linux-arm64.tar.gz',
|
|
16
|
+
'opencode-darwin-x64.tar.gz',
|
|
17
|
+
'opencode-darwin-arm64.tar.gz',
|
|
18
|
+
'opencode-windows-x64.tar.gz',
|
|
19
|
+
'runtime-release.json',
|
|
20
|
+
'runtime-release-SHA256SUMS.txt',
|
|
21
|
+
].map((name) => ({ name }));
|
|
22
|
+
const release = { id: 77, tag_name: tag, draft: true, assets: producerReleaseAssets };
|
|
23
|
+
|
|
24
|
+
function mockRun({ releaseValue = release, remoteSha = sha, checkoutSha = sha, annotated = false } = {}) {
|
|
25
|
+
const calls = [];
|
|
26
|
+
const run = (command, args) => {
|
|
27
|
+
calls.push([command, args]);
|
|
28
|
+
if (command === 'git' && args[0] === 'rev-parse') return `${checkoutSha}\n`;
|
|
29
|
+
if (command !== 'gh') throw new Error(`Unexpected fake command ${command} ${args.join(' ')}`);
|
|
30
|
+
if (args[0] === 'api' && args[1] === `repos/B-icy/Carthagent/git/ref/tags/${tag}`) return JSON.stringify({ object: { type: annotated ? 'tag' : 'commit', sha: annotated ? 'c'.repeat(40) : remoteSha } });
|
|
31
|
+
if (args[0] === 'api' && args[1] === `repos/B-icy/Carthagent/git/tags/${'c'.repeat(40)}`) return JSON.stringify({ object: { type: 'commit', sha: remoteSha } });
|
|
32
|
+
if (args[0] === 'api' && args[1] === '--paginate' && args[2] === '--slurp' && args[3] === 'repos/B-icy/Carthagent/releases?per_page=100') return JSON.stringify([[releaseValue]]);
|
|
33
|
+
throw new Error(`Unexpected fake GitHub API call ${args.join(' ')}`);
|
|
34
|
+
};
|
|
35
|
+
return { run, calls };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('accepts only the exact draft and returns its release ID and pinned commit', async () => {
|
|
39
|
+
const { run, calls } = mockRun();
|
|
40
|
+
const result = await verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', cwd: '/fixture', run });
|
|
41
|
+
assert.deepEqual(result, { releaseId: 77, sha, version });
|
|
42
|
+
assert.equal(calls.filter(([command]) => command === 'gh').length, 2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('resolves an annotated tag object to the exact commit before acceptance', async () => {
|
|
46
|
+
const { run, calls } = mockRun({ annotated: true });
|
|
47
|
+
const result = await verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', cwd: '/fixture', run });
|
|
48
|
+
assert.equal(result.sha, sha);
|
|
49
|
+
assert.equal(calls.some(([, args]) => args?.[1] === `repos/B-icy/Carthagent/git/tags/${'c'.repeat(40)}`), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('rejects a malformed tag before invoking git or GitHub', async () => {
|
|
53
|
+
const { run, calls } = mockRun();
|
|
54
|
+
await assert.rejects(verifyDraftRuntimeGate({ phase: 'before', releaseTag: `not-${tag}`, repo: 'B-icy/Carthagent', run }), /must be exactly/);
|
|
55
|
+
assert.equal(calls.length, 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('rejects a stale/moved tag before installed acceptance', async () => {
|
|
59
|
+
const { run, calls } = mockRun({ remoteSha: 'b'.repeat(40) });
|
|
60
|
+
await assert.rejects(verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', cwd: '/fixture', run }), /but this checkout is/);
|
|
61
|
+
assert.equal(calls.some(([command]) => command === 'git'), true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('rejects a published release before installed acceptance', async () => {
|
|
65
|
+
const { run, calls } = mockRun({ releaseValue: { ...release, draft: false } });
|
|
66
|
+
await assert.rejects(verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', run }), /must remain a draft/);
|
|
67
|
+
assert.equal(calls.some(([, args]) => args?.[1]?.includes('/git/ref/')), false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('requires the exact seven-asset producer contract: five archives, manifest, and combined checksums', async () => {
|
|
71
|
+
const expectedAssets = [
|
|
72
|
+
'opencode-linux-x64.tar.gz',
|
|
73
|
+
'opencode-linux-arm64.tar.gz',
|
|
74
|
+
'opencode-darwin-x64.tar.gz',
|
|
75
|
+
'opencode-darwin-arm64.tar.gz',
|
|
76
|
+
'opencode-windows-x64.tar.gz',
|
|
77
|
+
'runtime-release.json',
|
|
78
|
+
'runtime-release-SHA256SUMS.txt',
|
|
79
|
+
];
|
|
80
|
+
assert.deepEqual(REQUIRED_RELEASE_ASSETS, expectedAssets);
|
|
81
|
+
const result = await verifyDraftRuntimeGate({
|
|
82
|
+
phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', cwd: '/fixture',
|
|
83
|
+
run: mockRun({ releaseValue: { ...release, assets: expectedAssets.map((name) => ({ name })) } }).run,
|
|
84
|
+
});
|
|
85
|
+
assert.deepEqual(result, { releaseId: 77, sha, version });
|
|
86
|
+
|
|
87
|
+
for (const [label, assets, expected] of [
|
|
88
|
+
['missing manifest', release.assets.filter(({ name }) => name !== 'runtime-release.json'), /exactly one runtime-release\.json asset/],
|
|
89
|
+
['duplicate platform archive', [...release.assets, { name: REQUIRED_RELEASE_ASSETS[0] }], /exactly one opencode-linux-x64\.tar\.gz asset/],
|
|
90
|
+
['unexpected extra asset', [...release.assets, { name: 'unreviewed-runtime.tar.gz' }], /unexpected assets: unreviewed-runtime\.tar\.gz/],
|
|
91
|
+
['renamed checksum asset', release.assets.map((asset) => asset.name === 'runtime-release-SHA256SUMS.txt' ? { name: 'SHA256SUMS.txt' } : asset), /exactly one runtime-release-SHA256SUMS\.txt asset/],
|
|
92
|
+
]) {
|
|
93
|
+
const { run } = mockRun({ releaseValue: { ...release, assets } });
|
|
94
|
+
await assert.rejects(verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', run }), expected, label);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the workflow precheck command fails before acceptance for malformed tag, moved tag, or published release', async (t) => {
|
|
99
|
+
const temp = await mkdtemp(path.join(tmpdir(), 'ctg-draft-gate-fake-gh-'));
|
|
100
|
+
t.after(() => rm(temp, { recursive: true, force: true }));
|
|
101
|
+
const requestsLog = path.join(temp, 'gh-requests');
|
|
102
|
+
const releaseFile = path.join(temp, 'release.json');
|
|
103
|
+
const targetFile = path.join(temp, 'target-sha');
|
|
104
|
+
const preloadFile = path.join(temp, 'fake-gh-preload.mjs');
|
|
105
|
+
const currentSha = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim();
|
|
106
|
+
await writeFile(targetFile, currentSha);
|
|
107
|
+
await writeFile(releaseFile, JSON.stringify(release));
|
|
108
|
+
await writeFile(preloadFile, `
|
|
109
|
+
import fs from 'node:fs';
|
|
110
|
+
import childProcess from 'node:child_process';
|
|
111
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
112
|
+
const originalSpawnSync = childProcess.spawnSync;
|
|
113
|
+
childProcess.spawnSync = function(command, args, options) {
|
|
114
|
+
if (command !== 'gh') return originalSpawnSync.call(this, command, args, options);
|
|
115
|
+
fs.appendFileSync(process.env.FAKE_GH_LOG, JSON.stringify(args) + '\\n');
|
|
116
|
+
let stdout;
|
|
117
|
+
if (args.some(value => value.includes('/releases?per_page=100'))) {
|
|
118
|
+
stdout = JSON.stringify([[JSON.parse(fs.readFileSync(process.env.FAKE_RELEASE_JSON, 'utf8'))]]);
|
|
119
|
+
} else if (args.some(value => value.includes('/git/ref/tags/'))) {
|
|
120
|
+
stdout = JSON.stringify({ object: { type: 'commit', sha: fs.readFileSync(process.env.FAKE_TARGET_SHA, 'utf8').trim() } });
|
|
121
|
+
} else {
|
|
122
|
+
return { status: 2, stdout: '', stderr: 'Unexpected fake GitHub API call', error: undefined };
|
|
123
|
+
}
|
|
124
|
+
return { status: 0, stdout, stderr: '', error: undefined };
|
|
125
|
+
};
|
|
126
|
+
syncBuiltinESMExports();
|
|
127
|
+
`);
|
|
128
|
+
const tool = fileURLToPath(new URL('../tools/verify-draft-runtime-gate.mjs', import.meta.url));
|
|
129
|
+
const invoke = (tagValue) => spawnSync(process.execPath, [tool, 'before', tagValue], {
|
|
130
|
+
cwd: path.resolve('.'), encoding: 'utf8',
|
|
131
|
+
env: {
|
|
132
|
+
...process.env,
|
|
133
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${pathToFileURL(preloadFile).href}`.trim(),
|
|
134
|
+
GITHUB_REPOSITORY: 'B-icy/Carthagent', GITHUB_OUTPUT: path.join(temp, 'github-output'),
|
|
135
|
+
FAKE_GH_LOG: requestsLog, FAKE_RELEASE_JSON: releaseFile, FAKE_TARGET_SHA: targetFile,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const malformed = invoke('not-a-version');
|
|
140
|
+
assert.notEqual(malformed.status, 0);
|
|
141
|
+
await assert.rejects(readFile(requestsLog));
|
|
142
|
+
|
|
143
|
+
await writeFile(targetFile, 'b'.repeat(40));
|
|
144
|
+
const stale = invoke(tag);
|
|
145
|
+
assert.notEqual(stale.status, 0);
|
|
146
|
+
assert.match(stale.stderr, /but this checkout is/);
|
|
147
|
+
|
|
148
|
+
await writeFile(targetFile, currentSha);
|
|
149
|
+
await writeFile(releaseFile, JSON.stringify({ ...release, draft: false }));
|
|
150
|
+
const published = invoke(tag);
|
|
151
|
+
assert.notEqual(published.status, 0);
|
|
152
|
+
assert.match(published.stderr, /must remain a draft/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('trusted inline bootstrap accepts a main tag and rejects non-main dispatch and a foreign tag', async (t) => {
|
|
156
|
+
const workflow = (await readFile(new URL('../.github/workflows/accept-draft-release.yml', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
|
|
157
|
+
const bootstrap = workflow.match(/- name: Require main dispatch and a release tag descended from origin\/main[\s\S]*?\n run: \|\n((?: .*\n)+)/)?.[1];
|
|
158
|
+
const postcheck = workflow.match(/- name: Reverify release tag ancestry against approved main after acceptance[\s\S]*?\n run: \|\n((?: .*\n?)+?)(?=\n - |\n\s*$)/)?.[1];
|
|
159
|
+
assert.ok(bootstrap, 'trusted ancestry bootstrap block must be extractable');
|
|
160
|
+
assert.ok(postcheck, 'post-acceptance trusted ancestry block must be extractable');
|
|
161
|
+
|
|
162
|
+
const temp = await mkdtemp(path.join(tmpdir(), 'ctg-main-ancestry-'));
|
|
163
|
+
t.after(() => rm(temp, { recursive: true, force: true }));
|
|
164
|
+
const source = path.join(temp, 'source');
|
|
165
|
+
const bare = path.join(temp, 'origin.git');
|
|
166
|
+
const output = path.join(temp, 'github-output');
|
|
167
|
+
const fixtureVersion = '1.0.3';
|
|
168
|
+
const fixtureTag = `v${fixtureVersion}`;
|
|
169
|
+
await mkdir(source);
|
|
170
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim();
|
|
171
|
+
git(source, 'init', '-b', 'main');
|
|
172
|
+
git(source, 'config', 'user.email', 'gate-fixture@example.invalid');
|
|
173
|
+
git(source, 'config', 'user.name', 'Gate Fixture');
|
|
174
|
+
await writeFile(path.join(source, 'package.json'), JSON.stringify({ version: fixtureVersion }));
|
|
175
|
+
git(source, 'add', 'package.json');
|
|
176
|
+
git(source, 'commit', '-m', 'approved main release commit');
|
|
177
|
+
const approvedSha = git(source, 'rev-parse', 'HEAD');
|
|
178
|
+
git(source, 'tag', '-a', fixtureTag, '-m', 'approved annotated draft release', approvedSha);
|
|
179
|
+
execFileSync('git', ['init', '--bare', bare], { stdio: 'pipe' });
|
|
180
|
+
git(source, 'remote', 'add', 'origin', bare);
|
|
181
|
+
git(source, 'push', 'origin', 'main', '--tags');
|
|
182
|
+
|
|
183
|
+
const bashPath = (value) => {
|
|
184
|
+
if (process.platform !== 'win32') return value;
|
|
185
|
+
return execFileSync('cygpath', ['-u', value], { encoding: 'utf8' }).trim();
|
|
186
|
+
};
|
|
187
|
+
const invoke = (script, ref, expectedSha = '') => spawnSync('bash', ['-euo', 'pipefail', '-c', script], {
|
|
188
|
+
cwd: source, encoding: 'utf8',
|
|
189
|
+
env: {
|
|
190
|
+
...process.env,
|
|
191
|
+
GITHUB_REF: ref,
|
|
192
|
+
GITHUB_OUTPUT: bashPath(output),
|
|
193
|
+
RELEASE_TAG: fixtureTag,
|
|
194
|
+
TRUSTED_REPOSITORY_URL: bashPath(bare),
|
|
195
|
+
EXPECTED_SHA: expectedSha,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const accepted = invoke(bootstrap, 'refs/heads/main');
|
|
200
|
+
assert.equal(accepted.status, 0, accepted.stderr);
|
|
201
|
+
assert.match(await readFile(output, 'utf8'), new RegExp(`sha=${approvedSha}`));
|
|
202
|
+
assert.equal(invoke(bootstrap, 'refs/heads/release-candidate').status, 1);
|
|
203
|
+
assert.equal(invoke(postcheck, 'refs/heads/main', approvedSha).status, 0);
|
|
204
|
+
|
|
205
|
+
git(source, 'checkout', '-b', 'foreign/release');
|
|
206
|
+
await writeFile(path.join(source, 'foreign.txt'), 'not in main');
|
|
207
|
+
git(source, 'add', 'foreign.txt');
|
|
208
|
+
git(source, 'commit', '-m', 'unapproved foreign release commit');
|
|
209
|
+
git(source, 'tag', '-f', fixtureTag, 'HEAD');
|
|
210
|
+
git(source, 'push', 'origin', `+refs/tags/${fixtureTag}:refs/tags/${fixtureTag}`);
|
|
211
|
+
const rejectedForeign = invoke(bootstrap, 'refs/heads/main');
|
|
212
|
+
assert.equal(rejectedForeign.status, 1, rejectedForeign.stderr);
|
|
213
|
+
assert.match(rejectedForeign.stderr, /must point to a commit descended from approved origin\/main/);
|
|
214
|
+
const rejectedMovedTag = invoke(postcheck, 'refs/heads/main', approvedSha);
|
|
215
|
+
assert.equal(rejectedMovedTag.status, 1, rejectedMovedTag.stderr);
|
|
216
|
+
assert.match(rejectedMovedTag.stderr, /moved during acceptance/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('post-check requires the same release ID and the pre-acceptance tested SHA', async () => {
|
|
220
|
+
const { run: goodRun } = mockRun();
|
|
221
|
+
assert.deepEqual(await verifyDraftRuntimeGate({ phase: 'after', releaseTag: tag, expectedSha: sha, expectedReleaseId: 77, repo: 'B-icy/Carthagent', cwd: '/fixture', run: goodRun }), {
|
|
222
|
+
releaseId: 77, sha, version,
|
|
223
|
+
});
|
|
224
|
+
const { run: movedRun } = mockRun({ remoteSha: 'b'.repeat(40) });
|
|
225
|
+
await assert.rejects(verifyDraftRuntimeGate({ phase: 'after', releaseTag: tag, expectedSha: sha, expectedReleaseId: 77, repo: 'B-icy/Carthagent', cwd: '/fixture', run: movedRun }), /but this checkout is/);
|
|
226
|
+
const { run: idRun } = mockRun({ releaseValue: { ...release, id: 78 } });
|
|
227
|
+
await assert.rejects(verifyDraftRuntimeGate({ phase: 'after', releaseTag: tag, expectedSha: sha, expectedReleaseId: 77, repo: 'B-icy/Carthagent', run: idRun }), /identity changed/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('immediate pre-acceptance recheck pins the original SHA and release ID', async () => {
|
|
231
|
+
const { run } = mockRun();
|
|
232
|
+
assert.deepEqual(await verifyDraftRuntimeGate({ phase: 'recheck', releaseTag: tag, expectedSha: sha, expectedReleaseId: 77, repo: 'B-icy/Carthagent', cwd: '/fixture', run }), {
|
|
233
|
+
releaseId: 77, sha, version,
|
|
234
|
+
});
|
|
235
|
+
await assert.rejects(verifyDraftRuntimeGate({ phase: 'recheck', releaseTag: tag, expectedSha: 'b'.repeat(40), expectedReleaseId: 77, repo: 'B-icy/Carthagent', cwd: '/fixture', run }), /changed during acceptance/);
|
|
236
|
+
await assert.rejects(verifyDraftRuntimeGate({ phase: 'recheck', releaseTag: tag, expectedSha: sha, expectedReleaseId: 76, repo: 'B-icy/Carthagent', cwd: '/fixture', run }), /identity changed/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('draft workflow grants required push capability but performs only read operations', async () => {
|
|
240
|
+
const workflow = await readFile(new URL('../.github/workflows/accept-draft-release.yml', import.meta.url), 'utf8');
|
|
241
|
+
assert.match(workflow, /permissions:\s*\n\s+contents:\s+read/);
|
|
242
|
+
assert.match(workflow, /jobs:[\s\S]*?permissions:\s*\n\s+contents:\s+write/);
|
|
243
|
+
assert.match(workflow, /draft releases only to callers with push access/i);
|
|
244
|
+
assert.match(workflow, /minimum GITHUB_TOKEN capability/i);
|
|
245
|
+
assert.match(workflow, /token only for GETs/i);
|
|
246
|
+
const acceptance = workflow.match(/- name: Run the current full non-focused installed-package gate through the default resolver([\s\S]*?\n\s+run: npm run test:native-package)/)?.[0];
|
|
247
|
+
assert.ok(acceptance);
|
|
248
|
+
assert.match(acceptance, /CTG_REQUIRE_OWNED_RUNTIME:\s*['"]?1/);
|
|
249
|
+
assert.match(acceptance, /CTG_RUNTIME_GITHUB_TOKEN:\s*\$\{\{ github\.token \}\}/);
|
|
250
|
+
assert.match(acceptance, /GH_TOKEN:\s*['"]{2}/);
|
|
251
|
+
assert.match(workflow, /run: npm run test:native-package/);
|
|
252
|
+
assert.doesNotMatch(workflow, /gh release (?:create|upload|edit)|releases\/assets|curl .*cli\/releases/);
|
|
253
|
+
assert.doesNotMatch(workflow, /gh api\s+(?:--[^\n]*\s+)?--method\s+(?:POST|PATCH|PUT|DELETE)|gh api\s+-X\s+(?:POST|PATCH|PUT|DELETE)/i);
|
|
254
|
+
assert.match(workflow, /npm ci/);
|
|
255
|
+
assert.match(workflow, /npm run quality/);
|
|
256
|
+
assert.match(workflow, /verify-draft-runtime-gate\.mjs before/);
|
|
257
|
+
assert.match(workflow, /verify-draft-runtime-gate\.mjs recheck/);
|
|
258
|
+
assert.match(workflow, /verify-draft-runtime-gate\.mjs after/);
|
|
259
|
+
assert.match(workflow, /verify release tag ancestry against approved main after acceptance/);
|
|
260
|
+
const bootstrapIndex = workflow.indexOf('Require main dispatch and a release tag descended from origin/main');
|
|
261
|
+
const checkoutIndex = workflow.indexOf('uses: actions/checkout@v4');
|
|
262
|
+
const verifierIndex = workflow.indexOf('verify-draft-runtime-gate.mjs before');
|
|
263
|
+
assert.ok(bootstrapIndex >= 0 && bootstrapIndex < checkoutIndex && checkoutIndex < verifierIndex,
|
|
264
|
+
'trusted inline ancestry bootstrap must run before release checkout and tag-sourced verifier');
|
|
265
|
+
assert.match(workflow, /ref:\s*\$\{\{ steps\.trusted-ancestry\.outputs\.sha \}\}/,
|
|
266
|
+
'checkout must use the trusted ancestry-checked SHA, not resolve the mutable tag again');
|
|
267
|
+
const docs = (await readFile(new URL('../docs/opencode-owned-runtime.md', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
|
|
268
|
+
assert.match(docs, /current full,\s+non-focused installed-package gate/);
|
|
269
|
+
assert.match(docs, /exactly those seven assets/);
|
|
270
|
+
assert.match(docs, /independently reviewed host-controls, glyph,\s+controlled-terminal semantics, and visual-clear\/reopen probes are now composed\s+into that gate/);
|
|
271
|
+
assert.match(docs, /Their live final-artifact results remain pending/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('published release workflow explicitly empties optional draft credentials for public acceptance', async () => {
|
|
275
|
+
const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
|
|
276
|
+
const acceptance = workflow.match(/- name: Accept the installed package with its owned runtime required([\s\S]*?)(?=\n - |\n\s+run:)/)?.[1];
|
|
277
|
+
const verify = workflow.match(/- name: Verify runtime archives against the packaged immutable manifest([\s\S]*?)(?=\n - |\n\s+run:)/)?.[1];
|
|
278
|
+
assert.match(acceptance ?? '', /CTG_RUNTIME_GITHUB_TOKEN:\s*['"]{2}/);
|
|
279
|
+
assert.match(verify ?? '', /CTG_RUNTIME_GITHUB_TOKEN:\s*['"]{2}/);
|
|
280
|
+
});
|