carthagent 1.0.7 → 1.1.0-preview.13
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/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 +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 +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-combined-v5.patch +13965 -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.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 +11 -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 +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 +211 -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 +223 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -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 +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 +142 -0
- package/plugins/carthagent/src/ui/voyages.tsx +129 -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 +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 +11 -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 +451 -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-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 +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-delivery.test.mjs +16 -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/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
|
@@ -2,11 +2,12 @@ import test from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import { getEventListeners } from 'node:events';
|
|
6
|
-
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
|
7
8
|
import { spawn } from 'node:child_process';
|
|
8
9
|
import { nativeCommandConfig } from '../lib/opencode/compatibility.mjs';
|
|
9
|
-
import { provisionRuntime, resolveOpenCode, runtimeEnvironment, runtimePaths, withFileLock, workspaceEnvironment } from '../lib/opencode/runtime.mjs';
|
|
10
|
+
import { CTG_RUNTIME_LOCK_SHA256, CTG_RUNTIME_PATCH_SHA256, CTG_RUNTIME_SOURCE, CTG_RUNTIME_VERSION, OPENCODE_VERSION, provisionRuntime, readRuntimeReleaseManifest, resolveOpenCode, resolveOwnedOpenCode, resolvePathCommand, runtimeEnvironment, runtimeIdentity, runtimePaths, withFileLock, workspaceEnvironment, processAlive } from '../lib/opencode/runtime.mjs';
|
|
10
11
|
import { cancelSession, createSession, ensureService, getSession, launchTui, promptSession, serviceRequest, sessionMessages, setSessionEnvironment, stopService } from '../lib/opencode/service.mjs';
|
|
11
12
|
import { runHeadless } from '../lib/opencode/headless.mjs';
|
|
12
13
|
|
|
@@ -25,7 +26,7 @@ function pendingUntilAbort(signal) {
|
|
|
25
26
|
test('CTG runtime paths and env are isolated from host OpenCode roots', () => {
|
|
26
27
|
const root = path.join(os.tmpdir(), 'ctg-test-profile');
|
|
27
28
|
const paths = runtimePaths({ root });
|
|
28
|
-
const env = runtimeEnvironment({ root, env: { ANTHROPIC_API_KEY: 'test' } });
|
|
29
|
+
const env = runtimeEnvironment({ root, env: { ANTHROPIC_API_KEY: 'test', CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token' } });
|
|
29
30
|
assert.equal(env.XDG_CONFIG_HOME, paths.config);
|
|
30
31
|
assert.equal(env.XDG_DATA_HOME, paths.data);
|
|
31
32
|
assert.equal(env.XDG_STATE_HOME, paths.state);
|
|
@@ -35,9 +36,11 @@ test('CTG runtime paths and env are isolated from host OpenCode roots', () => {
|
|
|
35
36
|
assert.equal(env.LOCALAPPDATA, path.join(paths.home, 'AppData', 'Local'));
|
|
36
37
|
assert.equal(env.OPENCODE_TEST_HOME, paths.home);
|
|
37
38
|
assert.equal(env.OPENCODE_CONFIG_DIR, paths.configDir);
|
|
38
|
-
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE,
|
|
39
|
+
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, undefined);
|
|
40
|
+
assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, undefined);
|
|
39
41
|
assert.equal(env.OPENCODE_DB, path.join(paths.data, 'opencode.db'));
|
|
40
42
|
assert.equal(env.ANTHROPIC_API_KEY, 'test');
|
|
43
|
+
assert.equal(env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
|
|
41
44
|
assert.equal(env.OPENCODE_CUSTOM_CONFIG, undefined);
|
|
42
45
|
assert.notEqual(paths.serviceFile, path.join(os.homedir(), '.local/state/opencode/service.json'));
|
|
43
46
|
});
|
|
@@ -51,16 +54,32 @@ test('runtime removes inherited OpenCode profile overrides', () => {
|
|
|
51
54
|
OPENCODE_CONFIG_PROJECT_DISABLE: 'false',
|
|
52
55
|
OPENCODE_SERVER_PASSWORD: 'user-service-secret',
|
|
53
56
|
OPENAI_API_KEY: 'preserved',
|
|
57
|
+
CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token',
|
|
54
58
|
} });
|
|
55
59
|
assert.equal(env.OPENCODE_CONFIG_DIR, paths.configDir);
|
|
56
60
|
assert.equal(env.OPENCODE_CONFIG, paths.configFile);
|
|
57
|
-
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE,
|
|
61
|
+
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, undefined);
|
|
58
62
|
assert.equal(env.OPENCODE_SERVER_PASSWORD, undefined);
|
|
59
63
|
assert.equal(env.OPENAI_API_KEY, 'preserved');
|
|
64
|
+
assert.equal(env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
|
|
60
65
|
assert.equal(env.APPDATA, paths.appData);
|
|
61
66
|
assert.equal(env.LOCALAPPDATA, paths.localAppData);
|
|
62
67
|
});
|
|
63
68
|
|
|
69
|
+
test('normal native runtime leaves pinned-host project discovery at its default', () => {
|
|
70
|
+
const env = runtimeEnvironment({ root: path.resolve('/tmp/ctg-normal-discovery') });
|
|
71
|
+
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, undefined);
|
|
72
|
+
assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE, undefined);
|
|
73
|
+
assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, undefined);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('isolated runtime explicitly disables project and Claude skill discovery', () => {
|
|
77
|
+
const env = runtimeEnvironment({ root: path.resolve('/tmp/ctg-isolate-test'), isolate: true });
|
|
78
|
+
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, 'true');
|
|
79
|
+
assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE, 'true');
|
|
80
|
+
assert.equal(env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, 'true');
|
|
81
|
+
});
|
|
82
|
+
|
|
64
83
|
test('runtime overrides inherited Windows profile roots with CTG-owned paths', () => {
|
|
65
84
|
const root = path.resolve('/tmp/ctg-windows-roots');
|
|
66
85
|
const paths = runtimePaths({ root });
|
|
@@ -71,7 +90,7 @@ test('runtime overrides inherited Windows profile roots with CTG-owned paths', (
|
|
|
71
90
|
} });
|
|
72
91
|
assert.equal(env.APPDATA, paths.appData);
|
|
73
92
|
assert.equal(env.LOCALAPPDATA, paths.localAppData);
|
|
74
|
-
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE,
|
|
93
|
+
assert.equal(env.OPENCODE_CONFIG_PROJECT_DISABLE, undefined);
|
|
75
94
|
});
|
|
76
95
|
|
|
77
96
|
test('stale-lock recovery is serialized across independent processes', async () => {
|
|
@@ -158,12 +177,84 @@ test('profile provisioning creates private isolated profile and absolute plugin
|
|
|
158
177
|
} finally { await rm(root, { recursive: true, force: true }); }
|
|
159
178
|
});
|
|
160
179
|
|
|
180
|
+
test('profile provisioning replaces stale CTG checkout plugins and preserves unrelated settings', async t => {
|
|
181
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-plugin-migration-'));
|
|
182
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
183
|
+
const pluginPath = path.join(root, 'installed/plugins/carthagent');
|
|
184
|
+
const paths = await provisionRuntime({ root });
|
|
185
|
+
const unrelated = { package: 'other-plugin', options: { enabled: true } };
|
|
186
|
+
await writeFile(paths.configFile, JSON.stringify({
|
|
187
|
+
model: 'kept/model', providers: { custom: { name: 'Keep me' } },
|
|
188
|
+
plugins: ['before-plugin', '/deleted/pr169/plugins/carthagent', unrelated,
|
|
189
|
+
'/old/checkout/plugins/carthagent/index.ts', '/old/checkout/plugins/carthagent/src/index.ts',
|
|
190
|
+
pathToFileURL(path.join(root, 'deleted/review/plugins/carthagent')).href, 'C:\\old\\checkout\\plugins\\carthagent\\',
|
|
191
|
+
pluginPath, 'after-plugin', '/unrelated/plugins/carthagent-extra'],
|
|
192
|
+
}));
|
|
193
|
+
await provisionRuntime({ root, pluginPath });
|
|
194
|
+
const updated = JSON.parse(await readFile(paths.configFile, 'utf8'));
|
|
195
|
+
assert.deepEqual(updated.plugins, ['before-plugin', pluginPath, unrelated, 'after-plugin', '/unrelated/plugins/carthagent-extra']);
|
|
196
|
+
assert.equal(updated.model, 'kept/model');
|
|
197
|
+
assert.deepEqual(updated.providers, { custom: { name: 'Keep me' } });
|
|
198
|
+
await provisionRuntime({ root, pluginPath });
|
|
199
|
+
assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')), updated, 'repeated launch is idempotent');
|
|
200
|
+
const next = path.join(root, 'next-install/plugins/carthagent');
|
|
201
|
+
await provisionRuntime({ root, pluginPath: next });
|
|
202
|
+
assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')).plugins,
|
|
203
|
+
['before-plugin', next, unrelated, 'after-plugin', '/unrelated/plugins/carthagent-extra']);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('managed plugin replacement retains object options and prefers the selected installation', async t => {
|
|
207
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-plugin-options-'));
|
|
208
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
209
|
+
const pluginPath = path.join(root, 'installed/plugins/carthagent');
|
|
210
|
+
const paths = await provisionRuntime({ root });
|
|
211
|
+
const chosen = { package: pluginPath, options: { maxTools: 42 } };
|
|
212
|
+
await writeFile(paths.configFile, JSON.stringify({ plugins: [
|
|
213
|
+
'/deleted/plugins/carthagent', chosen,
|
|
214
|
+
{ package: '/another/plugins/carthagent', options: { maxTools: 10 } },
|
|
215
|
+
'/last/deleted/plugins/carthagent',
|
|
216
|
+
] }));
|
|
217
|
+
await provisionRuntime({ root, pluginPath });
|
|
218
|
+
assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')).plugins, [chosen]);
|
|
219
|
+
const next = path.join(root, 'next/plugins/carthagent');
|
|
220
|
+
await provisionRuntime({ root, pluginPath: next });
|
|
221
|
+
assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')).plugins, [{ ...chosen, package: next }]);
|
|
222
|
+
await writeFile(paths.configFile, JSON.stringify({ plugins: [chosen, '/last/deleted/plugins/carthagent'] }));
|
|
223
|
+
await provisionRuntime({ root, pluginPath: next });
|
|
224
|
+
assert.deepEqual(JSON.parse(await readFile(paths.configFile, 'utf8')).plugins, [{ ...chosen, package: next }],
|
|
225
|
+
'a trailing string reference must not discard saved plugin options');
|
|
226
|
+
});
|
|
227
|
+
|
|
161
228
|
test('workspace environment records workspace without leaking CTG XDG overrides', () => {
|
|
162
229
|
const workspace = path.resolve('/tmp/workspace');
|
|
163
|
-
const env = workspaceEnvironment(workspace, { XDG_CONFIG_HOME: '/host/config', OPENAI_API_KEY: 'secret' });
|
|
230
|
+
const env = workspaceEnvironment(workspace, { XDG_CONFIG_HOME: '/host/config', OPENAI_API_KEY: 'secret', CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token' });
|
|
164
231
|
assert.equal(env.XDG_CONFIG_HOME, '/host/config');
|
|
165
232
|
assert.equal(env.CTG_WORKSPACE, workspace);
|
|
166
233
|
assert.equal(env.OPENAI_API_KEY, 'secret');
|
|
234
|
+
assert.equal(env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('runtime version-check subprocess does not inherit the draft transport credential', async () => {
|
|
238
|
+
const { PassThrough } = await import('node:stream');
|
|
239
|
+
let childEnv;
|
|
240
|
+
const child = new (await import('node:events')).EventEmitter();
|
|
241
|
+
child.stdout = new PassThrough();
|
|
242
|
+
child.stderr = new PassThrough();
|
|
243
|
+
child.kill = () => {};
|
|
244
|
+
const pending = (await import('../lib/opencode/runtime.mjs')).runCapture('fixture-runtime', ['--version'], {
|
|
245
|
+
env: { CTG_RUNTIME_GITHUB_TOKEN: 'fake-runtime-token', PATH: '/bin' },
|
|
246
|
+
spawnImpl: (_command, _args, options) => {
|
|
247
|
+
childEnv = options.env;
|
|
248
|
+
queueMicrotask(() => {
|
|
249
|
+
child.stdout.end('opencode v2.0.32');
|
|
250
|
+
child.stderr.end();
|
|
251
|
+
child.emit('close', 0, null);
|
|
252
|
+
});
|
|
253
|
+
return child;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
assert.equal((await pending).stdout, 'opencode v2.0.32');
|
|
257
|
+
assert.equal(childEnv.CTG_RUNTIME_GITHUB_TOKEN, undefined);
|
|
167
258
|
});
|
|
168
259
|
|
|
169
260
|
test('native slash command conversion validates and preserves template', () => {
|
|
@@ -183,6 +274,162 @@ test('native API requests preserve structured responses and reject status failur
|
|
|
183
274
|
} finally { globalThis.fetch = original; }
|
|
184
275
|
});
|
|
185
276
|
|
|
277
|
+
test('default packaged OpenCode resolves through Node package exports when hoisted beside CTG', async () => {
|
|
278
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-hoisted-resolution-'));
|
|
279
|
+
const packageRoot = path.join(root, 'node_modules', 'carthagent');
|
|
280
|
+
const dependencyRoot = path.join(root, 'node_modules', '@opencode', 'cli');
|
|
281
|
+
try {
|
|
282
|
+
await mkdir(path.join(packageRoot, 'lib', 'opencode'), { recursive: true });
|
|
283
|
+
await mkdir(path.join(dependencyRoot, 'bin'), { recursive: true });
|
|
284
|
+
const runtime = await readFile(new URL('../lib/opencode/runtime.mjs', import.meta.url), 'utf8');
|
|
285
|
+
const artifactModule = await readFile(new URL('../lib/opencode/runtime-artifact.mjs', import.meta.url), 'utf8');
|
|
286
|
+
const transportModule = await readFile(new URL('../lib/opencode/runtime-github-transport.mjs', import.meta.url), 'utf8');
|
|
287
|
+
await writeFile(path.join(packageRoot, 'lib', 'opencode', 'runtime.mjs'), runtime);
|
|
288
|
+
await writeFile(path.join(packageRoot, 'lib', 'opencode', 'runtime-artifact.mjs'), artifactModule);
|
|
289
|
+
await writeFile(path.join(packageRoot, 'lib', 'opencode', 'runtime-github-transport.mjs'), transportModule);
|
|
290
|
+
await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'carthagent', version: '1.0.0', type: 'module' }));
|
|
291
|
+
await writeFile(path.join(dependencyRoot, 'package.json'), JSON.stringify({ name: '@opencode/cli', bin: { opencode: './bin/opencode.exe' }, version: OPENCODE_VERSION }));
|
|
292
|
+
const binary = path.join(dependencyRoot, 'bin', 'opencode.exe');
|
|
293
|
+
await writeFile(binary, 'hoisted package fixture');
|
|
294
|
+
const { pathToFileURL } = await import('node:url');
|
|
295
|
+
const imported = await import(`${pathToFileURL(path.join(packageRoot, 'lib/opencode/runtime.mjs')).href}?test=${Date.now()}`);
|
|
296
|
+
const spawnImpl = (_command, _args, options) => {
|
|
297
|
+
const child = spawn(process.execPath, ['-e', `process.stdout.write('opencode v${OPENCODE_VERSION}')`], options);
|
|
298
|
+
return child;
|
|
299
|
+
};
|
|
300
|
+
assert.equal(await realpath(await imported.resolveOpenCode({ spawnImpl })), await realpath(binary));
|
|
301
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('packaged release manifest automatically acquires the pinned owned runtime and binds identity', async () => {
|
|
305
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-release-manifest-'));
|
|
306
|
+
const artifact = path.join(root, 'cache', `${process.platform}-${process.arch}`);
|
|
307
|
+
const manifestPath = path.join(root, 'runtime-release.json');
|
|
308
|
+
const hostPlatform = process.platform === 'win32' ? 'windows' : process.platform;
|
|
309
|
+
const target = `${hostPlatform}-${process.arch}`;
|
|
310
|
+
const binary = path.join(artifact, `cli-${target}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
311
|
+
const { createHash } = await import('node:crypto');
|
|
312
|
+
const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
313
|
+
const binaryBytes = Buffer.from('pinned acquired executable');
|
|
314
|
+
const binaryHash = createHash('sha256').update(binaryBytes).digest('hex');
|
|
315
|
+
const platformKeys = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'];
|
|
316
|
+
const assets = Object.fromEntries(platformKeys.map((key) => [key, {
|
|
317
|
+
name: `opencode-${key}.tar.gz`, archiveSha256: 'a'.repeat(64), executableSha256: key === target ? binaryHash : 'b'.repeat(64),
|
|
318
|
+
}]));
|
|
319
|
+
const release = {
|
|
320
|
+
version: 1, releaseVersion: packageMetadata.version, compatibilityVersion: OPENCODE_VERSION,
|
|
321
|
+
runtimeVersion: CTG_RUNTIME_VERSION, sourceRevision: CTG_RUNTIME_SOURCE,
|
|
322
|
+
patchSha256: CTG_RUNTIME_PATCH_SHA256, lockSha256: CTG_RUNTIME_LOCK_SHA256, assets,
|
|
323
|
+
};
|
|
324
|
+
try {
|
|
325
|
+
await mkdir(path.dirname(binary), { recursive: true });
|
|
326
|
+
await writeFile(binary, binaryBytes, { mode: 0o755 });
|
|
327
|
+
await writeFile(path.join(artifact, 'ctg-runtime.json'), JSON.stringify({
|
|
328
|
+
owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
|
|
329
|
+
sourceRevision: CTG_RUNTIME_SOURCE, lockSha256: CTG_RUNTIME_LOCK_SHA256,
|
|
330
|
+
patchSha256: CTG_RUNTIME_PATCH_SHA256, compatibilityVersion: OPENCODE_VERSION,
|
|
331
|
+
runtimeVersion: CTG_RUNTIME_VERSION, channel: `ctg.${OPENCODE_VERSION}`,
|
|
332
|
+
platform: target, webUI: false,
|
|
333
|
+
}));
|
|
334
|
+
await writeFile(path.join(artifact, 'SHA256SUMS'), `${binaryHash} cli-${target}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
|
|
335
|
+
await writeFile(manifestPath, JSON.stringify(release));
|
|
336
|
+
const calls = [];
|
|
337
|
+
const spawnImpl = (command, args, options) => spawn(process.execPath, ['-e', `process.stdout.write('opencode v${CTG_RUNTIME_VERSION}')`], options);
|
|
338
|
+
const options = {
|
|
339
|
+
runtimeReleaseManifestPath: manifestPath,
|
|
340
|
+
runtimeCacheRoot: path.join(root, 'owned-cache'),
|
|
341
|
+
runtimeReleaseBaseUrl: `https://example.test/releases/v${packageMetadata.version}`,
|
|
342
|
+
acquireOwnedRuntime: async (input) => {
|
|
343
|
+
calls.push(input);
|
|
344
|
+
const currentHash = createHash('sha256').update(await readFile(binary)).digest('hex');
|
|
345
|
+
if (currentHash !== input.expectedExecutableSha256) throw new Error('pinned executable SHA-256 mismatch');
|
|
346
|
+
return artifact;
|
|
347
|
+
},
|
|
348
|
+
spawnImpl,
|
|
349
|
+
};
|
|
350
|
+
const resolved = await resolveOpenCode(options);
|
|
351
|
+
assert.equal(resolved, binary);
|
|
352
|
+
assert.equal(calls.length, 1);
|
|
353
|
+
assert.equal(calls[0].releaseVersion, packageMetadata.version);
|
|
354
|
+
assert.equal(calls[0].expectedArchiveSha256, assets[target].archiveSha256);
|
|
355
|
+
assert.equal(calls[0].expectedExecutableSha256, assets[target].executableSha256);
|
|
356
|
+
assert.equal(calls[0].expectedChannel, `ctg.${OPENCODE_VERSION}`);
|
|
357
|
+
// Production ensureService resolves and identifies with one shared options
|
|
358
|
+
// object; automatic selection identity is kept separate from the explicit
|
|
359
|
+
// developer-preview ownedRuntime selector.
|
|
360
|
+
const identity = await runtimeIdentity(resolved, options);
|
|
361
|
+
assert.equal(identity.kind, 'ctg-owned');
|
|
362
|
+
assert.equal(identity.patchSha256, CTG_RUNTIME_PATCH_SHA256);
|
|
363
|
+
assert.equal(options.ownedRuntime, undefined);
|
|
364
|
+
|
|
365
|
+
// A second resolution with the same options cannot trust the mutable
|
|
366
|
+
// adjacent SHA256SUMS as the release digest; the pinned manifest digest is
|
|
367
|
+
// checked again before returning the cached executable.
|
|
368
|
+
const tamperedBytes = Buffer.from('tampered executable');
|
|
369
|
+
const tamperedHash = createHash('sha256').update(tamperedBytes).digest('hex');
|
|
370
|
+
await writeFile(binary, tamperedBytes, { mode: 0o755 });
|
|
371
|
+
await writeFile(path.join(artifact, 'SHA256SUMS'), `${tamperedHash} cli-${target}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
|
|
372
|
+
await assert.rejects(resolveOpenCode(options), /pinned executable SHA-256 mismatch/);
|
|
373
|
+
assert.equal(calls.length, 2);
|
|
374
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('present but invalid runtime release manifest fails closed instead of selecting package CLI', async () => {
|
|
378
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-invalid-release-manifest-'));
|
|
379
|
+
const manifestPath = path.join(root, 'runtime-release.json');
|
|
380
|
+
try {
|
|
381
|
+
await writeFile(manifestPath, JSON.stringify({ version: 1, releaseVersion: '1.2.3', assets: {} }));
|
|
382
|
+
await assert.rejects(resolveOpenCode({ runtimeReleaseManifestPath: manifestPath }), /Invalid or incompatible CTG Carthagent runtime release manifest/);
|
|
383
|
+
await assert.rejects(readRuntimeReleaseManifest(manifestPath), /Invalid or incompatible CTG Carthagent runtime release manifest/);
|
|
384
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('incomplete release platform matrix fails closed without acquisition', async () => {
|
|
388
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-unsupported-release-target-'));
|
|
389
|
+
const manifestPath = path.join(root, 'runtime-release.json');
|
|
390
|
+
const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
391
|
+
const keys = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'];
|
|
392
|
+
const assets = Object.fromEntries(keys.map((key) => [key, {
|
|
393
|
+
name: `opencode-${key}.tar.gz`, archiveSha256: 'a'.repeat(64), executableSha256: 'b'.repeat(64),
|
|
394
|
+
}]));
|
|
395
|
+
delete assets[`${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`];
|
|
396
|
+
try {
|
|
397
|
+
await writeFile(manifestPath, JSON.stringify({
|
|
398
|
+
version: 1, releaseVersion: packageMetadata.version,
|
|
399
|
+
compatibilityVersion: OPENCODE_VERSION, runtimeVersion: CTG_RUNTIME_VERSION,
|
|
400
|
+
sourceRevision: CTG_RUNTIME_SOURCE, patchSha256: CTG_RUNTIME_PATCH_SHA256,
|
|
401
|
+
lockSha256: CTG_RUNTIME_LOCK_SHA256, assets,
|
|
402
|
+
}));
|
|
403
|
+
let acquired = false;
|
|
404
|
+
await assert.rejects(resolveOpenCode({
|
|
405
|
+
runtimeReleaseManifestPath: manifestPath,
|
|
406
|
+
acquireOwnedRuntime: async () => { acquired = true; throw new Error('must not acquire'); },
|
|
407
|
+
}), /Invalid or incompatible CTG Carthagent runtime release manifest/);
|
|
408
|
+
assert.equal(acquired, false);
|
|
409
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('missing runtime release manifest keeps the developer-preview package resolver available', async () => {
|
|
413
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-missing-release-manifest-'));
|
|
414
|
+
const packageRoot = path.join(root, 'node_modules', 'carthagent');
|
|
415
|
+
const dependencyRoot = path.join(root, 'node_modules', '@opencode', 'cli');
|
|
416
|
+
try {
|
|
417
|
+
await mkdir(path.join(packageRoot, 'lib', 'opencode'), { recursive: true });
|
|
418
|
+
await mkdir(path.join(dependencyRoot, 'bin'), { recursive: true });
|
|
419
|
+
for (const name of ['runtime.mjs', 'runtime-artifact.mjs', 'runtime-github-transport.mjs']) {
|
|
420
|
+
await writeFile(path.join(packageRoot, 'lib', 'opencode', name), await readFile(new URL(`../lib/opencode/${name}`, import.meta.url), 'utf8'));
|
|
421
|
+
}
|
|
422
|
+
await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'carthagent', version: '1.0.0', type: 'module' }));
|
|
423
|
+
await writeFile(path.join(dependencyRoot, 'package.json'), JSON.stringify({ name: '@opencode/cli', version: OPENCODE_VERSION, bin: { opencode: './bin/opencode' } }));
|
|
424
|
+
const binary = path.join(dependencyRoot, 'bin', 'opencode');
|
|
425
|
+
await writeFile(binary, 'preview fixture');
|
|
426
|
+
const { pathToFileURL } = await import('node:url');
|
|
427
|
+
const imported = await import(`${pathToFileURL(path.join(packageRoot, 'lib/opencode/runtime.mjs')).href}?missing=${Date.now()}`);
|
|
428
|
+
const spawnImpl = (_command, _args, options) => spawn(process.execPath, ['-e', `process.stdout.write('opencode v${OPENCODE_VERSION}')`], options);
|
|
429
|
+
assert.equal(await realpath(await imported.resolveOpenCode({ spawnImpl })), await realpath(binary));
|
|
430
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
431
|
+
});
|
|
432
|
+
|
|
186
433
|
test('session API helpers use the v2 envelope, prompt payload, and endpoint contracts', async () => {
|
|
187
434
|
const original = globalThis.fetch;
|
|
188
435
|
const calls = [];
|
|
@@ -227,10 +474,16 @@ test('native TUI attaches to the ensured authenticated service', () => {
|
|
|
227
474
|
binary: '/ctg/opencode',
|
|
228
475
|
paths: { root: '/ctg/profile' },
|
|
229
476
|
};
|
|
230
|
-
launchTui(service, '/work', { sessionID: 'ses_123', spawnImpl: (...args) => (captured = args) });
|
|
477
|
+
launchTui(service, '/work', { sessionID: 'ses_123', theme: 'nebula', glyphs: 'ascii', env: { CTG_RUNTIME_GITHUB_TOKEN: 'fake-token', ctg_runtime_github_token: 'fake-lowercase-token' }, spawnImpl: (...args) => (captured = args) });
|
|
231
478
|
assert.deepEqual(captured[0], '/ctg/opencode');
|
|
232
479
|
assert.deepEqual(captured[1], ['--server', 'http://127.0.0.1:4567', '/work', '--session', 'ses_123']);
|
|
233
480
|
assert.equal(captured[2].env.OPENCODE_PASSWORD, 'ctg-test-password');
|
|
481
|
+
assert.deepEqual(JSON.parse(captured[2].env.OPENCODE_CLI_CONFIG_CONTENT).theme, { name: 'nebula', mode: 'system' });
|
|
482
|
+
assert.equal(captured[2].env.OPENCODE_CONFIG_CONTENT, undefined);
|
|
483
|
+
assert.equal(captured[2].env.CTG_RUNTIME_GITHUB_TOKEN, undefined);
|
|
484
|
+
assert.equal(captured[2].env.ctg_runtime_github_token, undefined);
|
|
485
|
+
assert.equal(captured[2].env.CTG_TUI_GLYPHS, 'ascii');
|
|
486
|
+
assert.equal(captured[2].env.CTG_NATIVE_GLYPHS, undefined);
|
|
234
487
|
assert.equal(captured[2].cwd, '/work');
|
|
235
488
|
});
|
|
236
489
|
|
|
@@ -250,6 +503,198 @@ test('a live registered process is retained when health inspection fails', async
|
|
|
250
503
|
} finally { globalThis.fetch = original; await rm(root, { recursive: true, force: true }); }
|
|
251
504
|
});
|
|
252
505
|
|
|
506
|
+
test('runtime identity binds compatibility services to the exact executable path and digest', async () => {
|
|
507
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-runtime-identity-'));
|
|
508
|
+
const first = path.join(root, 'first');
|
|
509
|
+
const second = path.join(root, 'second');
|
|
510
|
+
try {
|
|
511
|
+
await writeFile(first, 'same compatibility version');
|
|
512
|
+
await writeFile(second, 'same compatibility version, different executable bytes');
|
|
513
|
+
const firstIdentity = await runtimeIdentity(first, { binary: first });
|
|
514
|
+
const secondIdentity = await runtimeIdentity(second, { binary: second });
|
|
515
|
+
assert.equal(firstIdentity.kind, 'upstream-compatible');
|
|
516
|
+
assert.equal(firstIdentity.version, OPENCODE_VERSION);
|
|
517
|
+
assert.notEqual(firstIdentity.binaryPath, secondIdentity.binaryPath);
|
|
518
|
+
assert.notEqual(firstIdentity.binarySha256, secondIdentity.binarySha256);
|
|
519
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('PATH-selected commands resolve to a concrete executable identity', async () => {
|
|
523
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-path-identity-'));
|
|
524
|
+
const bin = path.join(root, 'bin');
|
|
525
|
+
const executable = path.join(bin, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
526
|
+
try {
|
|
527
|
+
await mkdir(bin, { recursive: true });
|
|
528
|
+
await writeFile(executable, 'PATH resolution fixture', { mode: 0o755 });
|
|
529
|
+
const env = { ...process.env };
|
|
530
|
+
for (const key of Object.keys(env)) if (key.toLowerCase() === 'path') delete env[key];
|
|
531
|
+
env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`;
|
|
532
|
+
const invocations = [];
|
|
533
|
+
const spawnImpl = (command, args, options) => {
|
|
534
|
+
invocations.push({ command, args });
|
|
535
|
+
return spawn(process.execPath, ['-e', `process.stdout.write('opencode v${OPENCODE_VERSION}')`], options);
|
|
536
|
+
};
|
|
537
|
+
const resolved = await resolveOpenCode({ binary: 'opencode', env, spawnImpl });
|
|
538
|
+
const canonicalExecutable = await realpath(executable);
|
|
539
|
+
assert.equal(resolved, canonicalExecutable);
|
|
540
|
+
assert.deepEqual(invocations, [{ command: 'opencode', args: ['--version'] }]);
|
|
541
|
+
const identity = await runtimeIdentity('opencode', { binary: 'opencode', env });
|
|
542
|
+
assert.equal(identity.binaryPath, canonicalExecutable);
|
|
543
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('Windows PATH lookup does not append PATHEXT to an already suffixed executable', async () => {
|
|
547
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-win-path-'));
|
|
548
|
+
const bin = path.join(root, 'bin');
|
|
549
|
+
try {
|
|
550
|
+
await mkdir(bin);
|
|
551
|
+
const executable = path.join(bin, 'foo.exe');
|
|
552
|
+
await writeFile(executable, 'fixture');
|
|
553
|
+
const extensionless = path.join(bin, 'foo.COM');
|
|
554
|
+
await writeFile(extensionless, 'fixture');
|
|
555
|
+
assert.equal(resolvePathCommand('foo.exe', { PATH: bin, PATHEXT: '.COM;.EXE;.CMD' }, 'win32'), executable);
|
|
556
|
+
assert.equal(resolvePathCommand('foo', { PATH: bin, PATHEXT: '.COM;.EXE;.CMD' }, 'win32'), extensionless);
|
|
557
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('ensureService rejects normal/owned selector switches without stopping the registered process', async () => {
|
|
561
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-runtime-selector-switch-'));
|
|
562
|
+
const artifact = path.join(root, 'owned-artifact');
|
|
563
|
+
const binary = path.join(root, 'upstream-opencode');
|
|
564
|
+
const ownedPlatform = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
|
|
565
|
+
const artifactPlatform = ownedPlatform;
|
|
566
|
+
const ownedBinary = path.join(artifact, `cli-${artifactPlatform}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
567
|
+
const script = path.join(root, 'fixture-runtime.cjs');
|
|
568
|
+
const originalFetch = globalThis.fetch;
|
|
569
|
+
let registeredProcess;
|
|
570
|
+
try {
|
|
571
|
+
await writeFile(script, `if (process.argv[3] === '--version') console.log(process.argv[2].includes('owned-artifact') ? 'opencode v${CTG_RUNTIME_VERSION}' : 'opencode v2.0.32');\n`);
|
|
572
|
+
await writeFile(binary, 'upstream executable fixture');
|
|
573
|
+
await mkdir(path.dirname(ownedBinary), { recursive: true });
|
|
574
|
+
await writeFile(ownedBinary, 'owned executable fixture');
|
|
575
|
+
const { createHash } = await import('node:crypto');
|
|
576
|
+
const ownedHash = createHash('sha256').update(await readFile(ownedBinary)).digest('hex');
|
|
577
|
+
await writeFile(path.join(artifact, 'SHA256SUMS'), `${ownedHash} cli-${artifactPlatform}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
|
|
578
|
+
await writeFile(path.join(artifact, 'ctg-runtime.json'), JSON.stringify({
|
|
579
|
+
owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
|
|
580
|
+
sourceRevision: CTG_RUNTIME_SOURCE, lockSha256: CTG_RUNTIME_LOCK_SHA256, patchSha256: CTG_RUNTIME_PATCH_SHA256,
|
|
581
|
+
compatibilityVersion: OPENCODE_VERSION, runtimeVersion: CTG_RUNTIME_VERSION, channel: 'ctg.2.0.32', platform: ownedPlatform, webUI: false,
|
|
582
|
+
}));
|
|
583
|
+
const spawnImpl = (command, args, options) => spawn(process.execPath, [script, command, ...args], options);
|
|
584
|
+
const upstream = await runtimeIdentity(binary, { binary });
|
|
585
|
+
const owned = await runtimeIdentity(ownedBinary, { ownedRuntime: artifact });
|
|
586
|
+
const paths = runtimePaths({ root: path.join(root, 'profile') });
|
|
587
|
+
await mkdir(path.dirname(paths.serviceFile), { recursive: true });
|
|
588
|
+
registeredProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
|
589
|
+
await new Promise((resolve, reject) => {
|
|
590
|
+
registeredProcess.once('spawn', resolve);
|
|
591
|
+
registeredProcess.once('error', reject);
|
|
592
|
+
});
|
|
593
|
+
const registered = { id: 'existing', pid: registeredProcess.pid, url: 'http://127.0.0.1:4567', password: 'fixture', version: upstream.version, runtimeIdentity: upstream };
|
|
594
|
+
await writeFile(paths.serviceFile, JSON.stringify(registered));
|
|
595
|
+
globalThis.fetch = async () => new Response(JSON.stringify({ data: { version: upstream.version, pid: registeredProcess.pid } }));
|
|
596
|
+
for (const selected of [
|
|
597
|
+
{ root: paths.root, ownedRuntime: artifact, spawnImpl },
|
|
598
|
+
{ root: paths.root, binary: path.join(root, 'same-version-different-binary'), spawnImpl },
|
|
599
|
+
]) {
|
|
600
|
+
if (selected.binary) await writeFile(selected.binary, 'different upstream executable fixture');
|
|
601
|
+
await assert.rejects(ensureService(selected), /already running runtime .* selected .* active service was left running/);
|
|
602
|
+
assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), registered);
|
|
603
|
+
}
|
|
604
|
+
const ownedRecord = { ...registered, version: owned.version, runtimeIdentity: owned };
|
|
605
|
+
await writeFile(paths.serviceFile, JSON.stringify(ownedRecord));
|
|
606
|
+
globalThis.fetch = async () => new Response(JSON.stringify({ data: { version: owned.version, pid: registeredProcess.pid, capabilities: { shellOrigin: true } } }));
|
|
607
|
+
await assert.rejects(ensureService({ root: paths.root, ownedRuntime: artifact, binary, spawnImpl }), /already running runtime .* selected .* active service was left running/);
|
|
608
|
+
assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), ownedRecord);
|
|
609
|
+
await writeFile(paths.serviceFile, JSON.stringify(ownedRecord));
|
|
610
|
+
globalThis.fetch = async () => new Response(JSON.stringify({ data: { version: owned.version, pid: registeredProcess.pid, capabilities: { shellOrigin: true } } }));
|
|
611
|
+
await assert.rejects(ensureService({ root: paths.root, binary, spawnImpl }), /already running runtime .* selected .* active service was left running/);
|
|
612
|
+
assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), ownedRecord);
|
|
613
|
+
|
|
614
|
+
// A selector mismatch must leave the registered process alive.
|
|
615
|
+
assert.equal(registeredProcess.exitCode, null);
|
|
616
|
+
assert.deepEqual(JSON.parse(await readFile(paths.serviceFile, 'utf8')), ownedRecord);
|
|
617
|
+
} finally {
|
|
618
|
+
globalThis.fetch = originalFetch;
|
|
619
|
+
if (registeredProcess?.exitCode === null) registeredProcess.kill('SIGKILL');
|
|
620
|
+
await rm(root, { recursive: true, force: true });
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('owned runtime upgrades restart the authenticated service and preserve the profile', async () => {
|
|
625
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-owned-upgrade-'));
|
|
626
|
+
const artifact = path.join(root, 'artifact');
|
|
627
|
+
const target = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
|
|
628
|
+
const executable = `cli-${target}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}`;
|
|
629
|
+
const binary = path.join(artifact, executable);
|
|
630
|
+
const script = path.join(root, 'server.cjs');
|
|
631
|
+
const options = { root: path.join(root, 'profile'), ownedRuntime: artifact,
|
|
632
|
+
spawnImpl: (command, args, opts) => spawn(process.execPath, [script, ...args], opts), startupTimeout: 5000 };
|
|
633
|
+
try {
|
|
634
|
+
await mkdir(path.dirname(binary), { recursive: true });
|
|
635
|
+
await writeFile(script, `
|
|
636
|
+
if (process.argv[2] === '--version') { console.log('opencode v${CTG_RUNTIME_VERSION}'); process.exit(0); }
|
|
637
|
+
const http = require('node:http');
|
|
638
|
+
const server = http.createServer((req, res) => {
|
|
639
|
+
if (req.headers.authorization !== 'Basic ' + Buffer.from('opencode:' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64')) { res.writeHead(401); res.end(); return; }
|
|
640
|
+
res.setHeader('content-type', 'application/json');
|
|
641
|
+
res.end(JSON.stringify({version:'${CTG_RUNTIME_VERSION}',pid:process.pid,capabilities:{shellOrigin:true}}));
|
|
642
|
+
});
|
|
643
|
+
server.listen(0, '127.0.0.1', () => console.log('server listening on http://127.0.0.1:' + server.address().port));
|
|
644
|
+
`);
|
|
645
|
+
await writeFile(path.join(artifact, 'ctg-runtime.json'), JSON.stringify({
|
|
646
|
+
owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
|
|
647
|
+
sourceRevision: CTG_RUNTIME_SOURCE, lockSha256: CTG_RUNTIME_LOCK_SHA256, patchSha256: CTG_RUNTIME_PATCH_SHA256,
|
|
648
|
+
compatibilityVersion: OPENCODE_VERSION, runtimeVersion: CTG_RUNTIME_VERSION, channel: 'ctg.2.0.32', platform: target, webUI: false,
|
|
649
|
+
}));
|
|
650
|
+
const { createHash } = await import('node:crypto');
|
|
651
|
+
const install = async (bytes) => {
|
|
652
|
+
await writeFile(binary, bytes);
|
|
653
|
+
await writeFile(path.join(artifact, 'SHA256SUMS'), `${createHash('sha256').update(bytes).digest('hex')} ${executable}\n`);
|
|
654
|
+
};
|
|
655
|
+
await install('owned runtime revision one');
|
|
656
|
+
const first = await ensureService(options);
|
|
657
|
+
assert.equal((await ensureService(options)).pid, first.pid, 'unchanged runtime reuses the service');
|
|
658
|
+
const marker = path.join(first.paths.data, 'preserved-session');
|
|
659
|
+
await writeFile(marker, 'session and credential data must remain');
|
|
660
|
+
await install('owned runtime revision two');
|
|
661
|
+
const second = await ensureService(options);
|
|
662
|
+
assert.notEqual(second.pid, first.pid, 'new runtime replaces the old service without manual stop');
|
|
663
|
+
assert.equal((await ensureService(options)).pid, second.pid);
|
|
664
|
+
assert.equal(await readFile(marker, 'utf8'), 'session and credential data must remain');
|
|
665
|
+
const staleLauncher = { ...second.info, launcherVersion: '0.0.0-old' };
|
|
666
|
+
await writeFile(second.paths.serviceFile, JSON.stringify(staleLauncher));
|
|
667
|
+
const third = await ensureService(options);
|
|
668
|
+
assert.notEqual(third.pid, second.pid, 'launcher/plugin update restarts even when runtime bytes match');
|
|
669
|
+
assert.equal(await readFile(marker, 'utf8'), 'session and credential data must remain');
|
|
670
|
+
await writeFile(third.paths.serviceFile, JSON.stringify({ ...third.info, password: 'incorrect' }));
|
|
671
|
+
await install('owned runtime revision three');
|
|
672
|
+
await assert.rejects(ensureService(options), /left running|unhealthy|cannot be verified/);
|
|
673
|
+
assert.doesNotThrow(() => process.kill(third.pid, 0), 'unverified process must never be signalled');
|
|
674
|
+
await writeFile(third.paths.serviceFile, JSON.stringify(third.info));
|
|
675
|
+
} finally {
|
|
676
|
+
await stopService(options).catch(() => {});
|
|
677
|
+
await rm(root, { recursive: true, force: true });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test('dead mismatched registration is cleaned before a replacement service starts', async () => {
|
|
682
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-dead-registration-'));
|
|
683
|
+
const paths = runtimePaths({ root });
|
|
684
|
+
const script = path.join(root, 'server.cjs');
|
|
685
|
+
try {
|
|
686
|
+
await writeFile(script, `if (process.argv[2] === '--version') { console.log('2.0.32'); process.exit(0); }\nconst http=require('node:http');const s=http.createServer((q,r)=>{if(q.url==='/api/info'){r.setHeader('content-type','application/json');r.end(JSON.stringify({version:'2.0.32',pid:process.pid}));return}r.statusCode=404;r.end()});s.listen(Number(process.argv[process.argv.indexOf('--port')+1]),'127.0.0.1',()=>console.log('server listening on http://127.0.0.1:'+s.address().port));`);
|
|
687
|
+
await mkdir(path.dirname(paths.serviceFile), { recursive: true });
|
|
688
|
+
await writeFile(paths.serviceFile, JSON.stringify({ id: 'dead-legacy', pid: 2_000_000_000, url: 'http://127.0.0.1:1', version: '2.0.32' }));
|
|
689
|
+
const spawnImpl = (command, args, options) => spawn(process.execPath, [script, ...args], options);
|
|
690
|
+
const service = await ensureService({ root, binary: process.execPath, spawnImpl, startupTimeout: 5000 });
|
|
691
|
+
assert.equal(service.info.runtimeIdentity.kind, 'upstream-compatible');
|
|
692
|
+
assert.equal(service.info.pid, service.pid);
|
|
693
|
+
assert.notEqual(service.pid, 2_000_000_000);
|
|
694
|
+
await stopService({ root });
|
|
695
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
696
|
+
});
|
|
697
|
+
|
|
253
698
|
test('ensure and stop serialize on the same lifecycle lock', async () => {
|
|
254
699
|
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-lifecycle-race-'));
|
|
255
700
|
const script = path.join(root, 'fake-opencode.cjs');
|
|
@@ -260,6 +705,7 @@ test('ensure and stop serialize on the same lifecycle lock', async () => {
|
|
|
260
705
|
const options = { root, binary: process.execPath, spawnImpl, startupTimeout: 5000, stopTimeout: 3000 };
|
|
261
706
|
const [ensured, stopped] = await Promise.all([ensureService(options), stopService(options)]);
|
|
262
707
|
assert.equal(typeof stopped, 'boolean');
|
|
708
|
+
assert.ok((await readFile(path.join(runtimePaths({ root }).configDir, 'themes', 'carthage.json'), 'utf8')).includes('opencode.ai/theme.json'), 'production service startup provisions the native Carthage theme before host scan');
|
|
263
709
|
if (ensured?.pid) {
|
|
264
710
|
// It either remains the correctly registered live service or stop removed it.
|
|
265
711
|
const current = await readFile(runtimePaths({ root }).serviceFile, 'utf8').then(JSON.parse).catch(() => undefined);
|
|
@@ -274,6 +720,40 @@ test('an explicitly selected unusable OpenCode binary does not fall back', async
|
|
|
274
720
|
await assert.rejects(resolveOpenCode({ binary: '/missing/ctg-opencode' }), /ENOENT|not found/i);
|
|
275
721
|
});
|
|
276
722
|
|
|
723
|
+
test('owned runtime resolver requires pinned manifest, native platform, checksum, and detached version', async t => {
|
|
724
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-owned-runtime-'));
|
|
725
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
726
|
+
const platform = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
|
|
727
|
+
const artifactPlatform = platform;
|
|
728
|
+
const executable = path.join(root, `cli-${artifactPlatform}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
729
|
+
await mkdir(path.dirname(executable), { recursive: true });
|
|
730
|
+
const fake = path.join(root, 'fixture-runtime.cjs');
|
|
731
|
+
await writeFile(fake, `if (process.argv[2] === '--version') console.log('opencode v${CTG_RUNTIME_VERSION}'); else process.exit(1);\n`);
|
|
732
|
+
await writeFile(executable, 'fixture');
|
|
733
|
+
const { createHash } = await import('node:crypto');
|
|
734
|
+
const checksum = createHash('sha256').update(await readFile(executable)).digest('hex');
|
|
735
|
+
const manifest = {
|
|
736
|
+
owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
|
|
737
|
+
sourceRevision: CTG_RUNTIME_SOURCE, lockSha256: CTG_RUNTIME_LOCK_SHA256, patchSha256: CTG_RUNTIME_PATCH_SHA256,
|
|
738
|
+
compatibilityVersion: OPENCODE_VERSION, runtimeVersion: CTG_RUNTIME_VERSION,
|
|
739
|
+
channel: 'ctg.2.0.32', platform, webUI: false,
|
|
740
|
+
};
|
|
741
|
+
await writeFile(path.join(root, 'ctg-runtime.json'), `${JSON.stringify(manifest)}\n`);
|
|
742
|
+
await writeFile(path.join(root, 'SHA256SUMS'), `${checksum} cli-${artifactPlatform}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
|
|
743
|
+
const spawnImpl = (command, args, options) => spawn(process.execPath, [fake, ...args], options);
|
|
744
|
+
assert.equal(await resolveOwnedOpenCode(root, { spawnImpl }), executable);
|
|
745
|
+
await writeFile(executable, 'modified', { mode: 0o755 });
|
|
746
|
+
await assert.rejects(resolveOwnedOpenCode(root, { spawnImpl }), /SHA-256 mismatch/);
|
|
747
|
+
await writeFile(executable, 'fixture');
|
|
748
|
+
await writeFile(path.join(root, 'SHA256SUMS'), `${checksum} cli-${artifactPlatform}/bin/not-opencode\n`);
|
|
749
|
+
await assert.rejects(resolveOwnedOpenCode(root, { spawnImpl }), /checksum does not list/);
|
|
750
|
+
await writeFile(path.join(root, 'SHA256SUMS'), `${checksum} cli-${artifactPlatform}/bin/${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}\n`);
|
|
751
|
+
await writeFile(path.join(root, 'ctg-runtime.json'), `${JSON.stringify({ ...manifest, sourceRevision: 'unpinned' })}\n`);
|
|
752
|
+
await assert.rejects(resolveOwnedOpenCode(root, { spawnImpl }), /not compatible/);
|
|
753
|
+
await writeFile(path.join(root, 'ctg-runtime.json'), `${JSON.stringify({ ...manifest, platform: 'other-platform' })}\n`);
|
|
754
|
+
await assert.rejects(resolveOwnedOpenCode(root, { spawnImpl }), /does not match this host/);
|
|
755
|
+
});
|
|
756
|
+
|
|
277
757
|
test('headless runner passes workspace environment and returns assistant content and status', async () => {
|
|
278
758
|
const original = globalThis.fetch;
|
|
279
759
|
const calls = [];
|
|
@@ -453,3 +933,61 @@ test('headless reports timeout distinctly when no terminal outcome can be observ
|
|
|
453
933
|
assert.equal(result.budgetReason, 'timed-out');
|
|
454
934
|
} finally { globalThis.fetch = original; }
|
|
455
935
|
});
|
|
936
|
+
|
|
937
|
+
test('accepted timeout does not inherit the previous session outcome and keeps the admitted turn', async () => {
|
|
938
|
+
const original = globalThis.fetch;
|
|
939
|
+
try {
|
|
940
|
+
globalThis.fetch = async (url, init = {}) => {
|
|
941
|
+
const pathname = new URL(url).pathname;
|
|
942
|
+
if (pathname.endsWith('/environment')) return new Response(null, { status: 204 });
|
|
943
|
+
if (pathname.endsWith('/prompt')) return new Response('{"data":{"id":"inbox_new","messageID":"user-new"}}');
|
|
944
|
+
if (pathname.endsWith('/wait')) return pendingUntilAbort(init.signal);
|
|
945
|
+
if (pathname.endsWith('/interrupt')) return new Response(null, { status: 204 });
|
|
946
|
+
if (pathname === '/api/session/ses_previous') return new Response('{"data":{"id":"ses_previous","outcome":"succeeded"}}');
|
|
947
|
+
if (pathname.endsWith('/message')) return new Response(JSON.stringify({ data: [
|
|
948
|
+
{ id: 'assistant-old', type: 'assistant', finish: 'stop', parts: [{ type: 'text', text: 'stale prior result' }], time: { created: 1 } },
|
|
949
|
+
{ id: 'user-new', type: 'user', time: { created: 2 } },
|
|
950
|
+
{ id: 'assistant-new', type: 'assistant', finish: 'tool-calls', parts: [{ type: 'tool', callID: 'tool-new' }], time: { created: 3 } },
|
|
951
|
+
] }));
|
|
952
|
+
throw new Error(`Unexpected endpoint ${pathname}`);
|
|
953
|
+
};
|
|
954
|
+
const controller = new AbortController();
|
|
955
|
+
const resultPromise = runHeadless({ url: 'http://127.0.0.1:3333', headers: {} }, '/workspace', 'new turn', { sessionID: 'ses_previous', signal: controller.signal });
|
|
956
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
957
|
+
controller.abort(new Error('test timeout/cancel'));
|
|
958
|
+
const result = await resultPromise;
|
|
959
|
+
assert.equal(result.session.id, 'ses_previous');
|
|
960
|
+
assert.equal(result.outcome, 'interrupted');
|
|
961
|
+
assert.equal(result.terminal, false, 'prior successful session state is not terminal for this admission');
|
|
962
|
+
assert.deepEqual(result.messages.map(message => message.id), ['user-new', 'assistant-new']);
|
|
963
|
+
assert.notEqual(result.result?.text, 'stale prior result', 'previous assistant response is never reused');
|
|
964
|
+
} finally { globalThis.fetch = original; }
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
test('runtime liveness treats an unreaped Linux zombie as stopped but preserves live processes', { skip: process.platform !== 'linux' }, async t => {
|
|
969
|
+
const { once } = await import('node:events');
|
|
970
|
+
const child = spawn('python3', ['-c', `import os,time
|
|
971
|
+
pid=os.fork()
|
|
972
|
+
if pid == 0: os._exit(0)
|
|
973
|
+
print(pid,flush=True)
|
|
974
|
+
time.sleep(30)`]);
|
|
975
|
+
t.after(() => child.kill());
|
|
976
|
+
let output = '';
|
|
977
|
+
const zombie = await new Promise((resolvePID, reject) => {
|
|
978
|
+
child.stdout.on('data', chunk => { output += chunk; if (output.includes('\n')) resolvePID(Number(output.trim())); });
|
|
979
|
+
child.once('error', reject);
|
|
980
|
+
});
|
|
981
|
+
for (let i = 0; i < 100; i++) {
|
|
982
|
+
const status = await readFile(`/proc/${zombie}/stat`, 'utf8');
|
|
983
|
+
if (/\) Z /.test(status)) break;
|
|
984
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, 10));
|
|
985
|
+
}
|
|
986
|
+
assert.match(await readFile(`/proc/${zombie}/stat`, 'utf8'), /\) Z /);
|
|
987
|
+
assert.equal(processAlive(process.pid), true);
|
|
988
|
+
assert.equal(processAlive(child.pid), true);
|
|
989
|
+
assert.equal(processAlive(zombie), false);
|
|
990
|
+
const exited = once(child, 'exit');
|
|
991
|
+
child.kill();
|
|
992
|
+
await exited;
|
|
993
|
+
});
|