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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Real-host independent review receipts must bind to exact Git source. */
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { writeFile, readFile, readdir } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { nativeFixture, streamText } from './native-fixture.mjs';
|
|
8
|
+
|
|
9
|
+
let reviewRequests = 0;
|
|
10
|
+
const host = await nativeFixture({
|
|
11
|
+
plugin: process.argv[2],
|
|
12
|
+
pluginOptions: { reviewModel: { providerID: 'ctg-fixture', id: 'fixture' } },
|
|
13
|
+
respond(body, res) {
|
|
14
|
+
const text = JSON.stringify(body.messages);
|
|
15
|
+
const snapshot = text.match(/Snapshot(?: SHA-256)?: ([a-f0-9]+)/)?.[1];
|
|
16
|
+
const head = text.match(/HEAD: ([a-f0-9]+)/)?.[1];
|
|
17
|
+
if (!snapshot || !head || !text.includes('REVIEW_JSON:')) return streamText(res);
|
|
18
|
+
reviewRequests++;
|
|
19
|
+
streamText(res, `Fixture source review complete.\nREVIEW_JSON: ${JSON.stringify({ version: 1, snapshot, head, findings: [] })}\nVERDICT: APPROVE`);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const identity = host.runtimeIdentity;
|
|
23
|
+
try {
|
|
24
|
+
const git = (...args) => execFileSync('git', args, { cwd: host.cwd, encoding: 'utf8' });
|
|
25
|
+
git('init', '--initial-branch=main');
|
|
26
|
+
await writeFile(join(host.cwd, 'fixture.txt'), 'review baseline\n');
|
|
27
|
+
git('add', 'fixture.txt');
|
|
28
|
+
git('-c', 'user.name=CTG Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'Review fixture');
|
|
29
|
+
await host.active();
|
|
30
|
+
const worker = await host.client.session.create({ location: host.location, model: host.model, title: 'Implementation session' }, host.request());
|
|
31
|
+
const rpc = async method => (await host.client.rpc.call({ rpcID: 'carthagent.workspace', method,
|
|
32
|
+
input: { sessionID: worker.id }, location: host.location }, host.request())).output;
|
|
33
|
+
const before = await rpc('review');
|
|
34
|
+
assert.equal(before.status, 'unreviewed');
|
|
35
|
+
const result = await rpc('startReview');
|
|
36
|
+
assert.equal(result.status, 'approved');
|
|
37
|
+
assert.notEqual(result.sessionID, worker.id);
|
|
38
|
+
const reviewer = await host.client.session.get({ sessionID: result.sessionID }, host.request());
|
|
39
|
+
assert.equal(reviewer.parentID, undefined);
|
|
40
|
+
assert.equal(reviewer.location.directory, host.cwd);
|
|
41
|
+
assert.equal((await rpc('review')).status, 'approved');
|
|
42
|
+
const count = reviewRequests;
|
|
43
|
+
await assert.rejects(rpc('startReview'));
|
|
44
|
+
assert.equal(reviewRequests, count, 'Already approved source must not launch another review');
|
|
45
|
+
await host.stop();
|
|
46
|
+
await host.start();
|
|
47
|
+
await host.active();
|
|
48
|
+
assert.equal((await rpc('review')).status, 'approved', 'Review receipt survives service restart');
|
|
49
|
+
await writeFile(join(host.cwd, 'fixture.txt'), 'changed source\n');
|
|
50
|
+
assert.equal((await rpc('review')).status, 'stale', 'Any source change invalidates approval');
|
|
51
|
+
assert.equal((await rpc('startReview')).status, 'approved');
|
|
52
|
+
await writeFile(join(host.cwd, 'fixture.txt'), 'third source revision\n');
|
|
53
|
+
assert.equal((await rpc('startReview')).status, 'approved');
|
|
54
|
+
await host.stop();
|
|
55
|
+
await host.start();
|
|
56
|
+
await host.active();
|
|
57
|
+
await writeFile(join(host.cwd, 'fixture.txt'), 'fourth source revision\n');
|
|
58
|
+
const admissions = reviewRequests;
|
|
59
|
+
await assert.rejects(rpc('startReview'), error => error.type === 'reviewFailed' && /round limit/i.test(error.message));
|
|
60
|
+
assert.equal(reviewRequests, admissions, 'Restart and changed source must not bypass the three-review admission limit');
|
|
61
|
+
console.log(JSON.stringify({ host: identity.runtimeVersion, compatibilityVersion: identity.compatibilityVersion,
|
|
62
|
+
...(identity.binarySha256 ? { binarySha256: identity.binarySha256 } : {}),
|
|
63
|
+
independentReview: 'passed', persistedReceipt: 'passed', staleInvalidation: 'passed', persistentAdmissionLimit: 'passed' }));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(host.output);
|
|
66
|
+
const reviews = join(host.cwd, '.harness/reviews');
|
|
67
|
+
for (const entry of await readdir(reviews).catch(() => [])) {
|
|
68
|
+
if (entry.endsWith('.json')) console.error(await readFile(join(reviews, entry), 'utf8'));
|
|
69
|
+
}
|
|
70
|
+
const inventory = await host.client.session.list({ location: host.location }, host.request());
|
|
71
|
+
for (const session of inventory.data ?? []) console.error(JSON.stringify({ title: session.title, messages: await host.client.session.context({ sessionID: session.id }, host.request()) }));
|
|
72
|
+
throw error;
|
|
73
|
+
} finally {
|
|
74
|
+
await host.close();
|
|
75
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Exercise CTG's wrappers, including resumed failures, against the real host. */
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ensureService, stopService } from '../lib/opencode/service.mjs';
|
|
7
|
+
import { runHeadless } from '../lib/opencode/headless.mjs';
|
|
8
|
+
import { nativeFixture, streamText } from './native-fixture.mjs';
|
|
9
|
+
|
|
10
|
+
let stallStarted;
|
|
11
|
+
const stalled = new Promise(resolve => { stallStarted = resolve; });
|
|
12
|
+
const host = await nativeFixture({
|
|
13
|
+
plugin: process.argv[2],
|
|
14
|
+
respond(body, res) {
|
|
15
|
+
const text = JSON.stringify([...body.messages].reverse().find(message => message.role === 'user'));
|
|
16
|
+
if (text.includes('WRAPPER_STALL')) {
|
|
17
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
18
|
+
res.flushHeaders();
|
|
19
|
+
stallStarted();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (text.includes('WRAPPER_TOOL') && !body.messages.some(message => message.role === 'tool')) {
|
|
23
|
+
assert.ok(body.tools.some(tool => tool.function?.name === 'read'), 'Native host must advertise the requested read tool');
|
|
24
|
+
const chunk = (delta, finish_reason = null) => `data: ${JSON.stringify({
|
|
25
|
+
id: 'runtime-tool', object: 'chat.completion.chunk', created: 1, model: 'fixture',
|
|
26
|
+
choices: [{ index: 0, delta, finish_reason }],
|
|
27
|
+
})}\n\n`;
|
|
28
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
29
|
+
res.end(`${chunk({ role: 'assistant', tool_calls: [{ index: 0, id: 'wrapper-read', type: 'function',
|
|
30
|
+
function: { name: 'read', arguments: JSON.stringify({ path: join(host.cwd, 'wrapper-tool.txt') }) } }] })}${chunk({}, 'tool_calls')}data: [DONE]\n\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (text.includes('WRAPPER_FAILURE')) {
|
|
34
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
35
|
+
res.end(JSON.stringify({ error: { message: 'Offline wrapper rejection', type: 'invalid_request_error' } }));
|
|
36
|
+
} else streamText(res, text.includes('WRAPPER_TOOL') ? 'FINAL_AFTER_TOOL' : text.includes('WRAPPER_SECOND') ? 'SECOND_TURN' : 'FIRST_TURN');
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const identity = host.runtimeIdentity;
|
|
40
|
+
const options = { root: join(host.temp, 'runtime'), pluginPath: process.argv[2] ?? join(import.meta.dirname, '../plugins/carthagent') };
|
|
41
|
+
try {
|
|
42
|
+
options.profile = JSON.parse(await readFile(join(host.temp, 'config/opencode/opencode.json'), 'utf8'));
|
|
43
|
+
const service = await ensureService(options);
|
|
44
|
+
assert.equal(service.info.version, identity.runtimeVersion, 'Owned service must run the fixture-identified native binary');
|
|
45
|
+
const reused = await ensureService(options);
|
|
46
|
+
assert.equal(reused.info.pid, service.info.pid);
|
|
47
|
+
assert.equal(reused.url, service.url);
|
|
48
|
+
const common = { timeout: 30_000, env: { PATH: process.env.PATH }, session: {
|
|
49
|
+
model: host.model, metadata: { 'carthagent.options': { guide: false } },
|
|
50
|
+
} };
|
|
51
|
+
const first = await runHeadless(service, host.cwd, 'WRAPPER_FIRST', common);
|
|
52
|
+
assert.equal(first.result?.text, 'FIRST_TURN');
|
|
53
|
+
assert.equal(first.outcome, 'succeeded');
|
|
54
|
+
assert.equal(first.terminal, true);
|
|
55
|
+
assert.equal(first.budgetReason, undefined, 'A successful terminal outcome has no budget failure reason');
|
|
56
|
+
const second = await runHeadless(service, host.cwd, 'WRAPPER_SECOND', { ...common, sessionID: first.session.id });
|
|
57
|
+
assert.equal(second.result?.text, 'SECOND_TURN', 'Resume must return the current turn');
|
|
58
|
+
await writeFile(join(host.cwd, 'wrapper-tool.txt'), 'Read this through the native tool.\n');
|
|
59
|
+
const toolTurn = await runHeadless(service, host.cwd, 'WRAPPER_TOOL', common);
|
|
60
|
+
assert.equal(toolTurn.outcome, 'succeeded');
|
|
61
|
+
const readTool = toolTurn.messages.flatMap(message => message.content ?? []).find(part => part.type === 'tool' && part.name === 'read');
|
|
62
|
+
assert.equal(readTool?.state.status, 'completed', 'The native read tool must really complete before the final answer');
|
|
63
|
+
assert.ok(readTool.state.content.some(part => part.type === 'text' && part.text.includes('Read this through the native tool.')));
|
|
64
|
+
assert.equal(toolTurn.result?.text, 'FINAL_AFTER_TOOL', 'Return the final assistant after tools, not the initial tool-call message');
|
|
65
|
+
let failure;
|
|
66
|
+
try { failure = await runHeadless(service, host.cwd, 'WRAPPER_FAILURE', { ...common, sessionID: first.session.id }); }
|
|
67
|
+
catch (error) { failure = { error }; }
|
|
68
|
+
assert.ok(failure.error || failure.result?.error || failure.result?.status === 'error' || failure.outcome === 'failed',
|
|
69
|
+
`Provider failure must not return a previous successful assistant: ${JSON.stringify(failure)}`);
|
|
70
|
+
const recovered = await runHeadless(service, host.cwd, 'WRAPPER_SECOND', { ...common, sessionID: first.session.id });
|
|
71
|
+
assert.equal(recovered.result?.text, 'SECOND_TURN');
|
|
72
|
+
const cancel = new AbortController();
|
|
73
|
+
const interrupted = runHeadless(service, host.cwd, 'WRAPPER_STALL', { ...common, sessionID: first.session.id, signal: cancel.signal });
|
|
74
|
+
let admissionTimer;
|
|
75
|
+
try {
|
|
76
|
+
await Promise.race([stalled, new Promise((_, reject) => {
|
|
77
|
+
admissionTimer = setTimeout(() => reject(Error('Wrapper cancellation fixture did not reach provider')), 10_000);
|
|
78
|
+
})]);
|
|
79
|
+
const neighbor = await runHeadless(service, host.cwd, 'WRAPPER_SECOND', common);
|
|
80
|
+
assert.equal(neighbor.result?.text, 'SECOND_TURN', 'A stalled session must not block its neighbor');
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(admissionTimer);
|
|
83
|
+
cancel.abort(Error('Offline wrapper cancellation'));
|
|
84
|
+
}
|
|
85
|
+
const cancelled = await interrupted;
|
|
86
|
+
assert.equal(cancelled.reason, 'aborted');
|
|
87
|
+
assert.equal(cancelled.outcome, 'interrupted');
|
|
88
|
+
assert.notEqual(cancelled.result?.text, 'SECOND_TURN', 'An interrupted resumed turn must not reuse a prior answer');
|
|
89
|
+
const afterCancel = await runHeadless(service, host.cwd, 'WRAPPER_SECOND', { ...common, sessionID: first.session.id });
|
|
90
|
+
assert.equal(afterCancel.result?.text, 'SECOND_TURN');
|
|
91
|
+
let preAborted;
|
|
92
|
+
try {
|
|
93
|
+
preAborted = await runHeadless(service, host.cwd, 'MUST_NOT_BE_ADMITTED', {
|
|
94
|
+
...common, sessionID: first.session.id, signal: AbortSignal.abort(Error('Already cancelled')),
|
|
95
|
+
});
|
|
96
|
+
} catch (error) { preAborted = { error }; }
|
|
97
|
+
assert.ok(preAborted.error || preAborted.outcome === 'interrupted', 'A pre-aborted invocation cannot inherit an earlier successful outcome');
|
|
98
|
+
assert.equal(preAborted.result, undefined, 'A pre-aborted invocation has no current-turn assistant response');
|
|
99
|
+
let unroutable;
|
|
100
|
+
try {
|
|
101
|
+
unroutable = await runHeadless(service, host.cwd, 'UNAVAILABLE_MODEL', {
|
|
102
|
+
...common, session: { model: { providerID: 'absent-fixture', id: 'missing' } },
|
|
103
|
+
});
|
|
104
|
+
} catch (error) { unroutable = { error }; }
|
|
105
|
+
assert.ok(unroutable.error || unroutable.outcome === 'failed' || unroutable.session?.outcome === 'failed',
|
|
106
|
+
`A failure without an assistant must expose a failed terminal outcome: ${JSON.stringify(unroutable)}`);
|
|
107
|
+
assert.equal(await stopService(options), true);
|
|
108
|
+
const restarted = await ensureService(options);
|
|
109
|
+
assert.notEqual(restarted.info.id, service.info.id, 'Restart registers a fresh owned service identity');
|
|
110
|
+
const resumed = await runHeadless(restarted, host.cwd, 'WRAPPER_SECOND', { ...common, sessionID: first.session.id });
|
|
111
|
+
assert.equal(resumed.session.id, first.session.id);
|
|
112
|
+
assert.equal(resumed.result?.text, 'SECOND_TURN', 'Session state remains usable after the owned service restarts');
|
|
113
|
+
assert.equal(await stopService(options), true);
|
|
114
|
+
console.log(JSON.stringify({ host: identity.runtimeVersion, compatibilityVersion: identity.compatibilityVersion,
|
|
115
|
+
...(identity.binarySha256 ? { binarySha256: identity.binarySha256 } : {}),
|
|
116
|
+
serviceReuse: 'passed', repeatedTurns: 'passed', finalAnswerAfterTool: 'passed', successMetadata: 'passed', failure: 'passed', recovery: 'passed', cancellation: 'passed', neighboringSession: 'passed', preAbortedInvocation: 'passed', serviceRestart: 'passed' }));
|
|
117
|
+
} finally {
|
|
118
|
+
try { await stopService(options); }
|
|
119
|
+
finally { await host.close(); }
|
|
120
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
|
+
import { once } from 'node:events';
|
|
5
|
+
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { OpenCode } from '@opencode/client';
|
|
12
|
+
import { provisionRuntime, resolveOpenCode, runtimePaths } from '../lib/opencode/runtime.mjs';
|
|
13
|
+
import { preflightNativeTestRuntimeArtifact } from './native-runtime-identity.mjs';
|
|
14
|
+
import { stopFixtureBrowser } from './native-fixture-browser-cleanup.mjs';
|
|
15
|
+
import { fixtureStopDiagnostics } from './native-fixture-stop-diagnostics.mjs';
|
|
16
|
+
|
|
17
|
+
const root = resolve(import.meta.dirname, '..');
|
|
18
|
+
const runtimeOptions = { runtimeCacheRoot: process.env.CTG_RUNTIME_TEST_CACHE };
|
|
19
|
+
if (process.env.CTG_TEST_RUNTIME_RELEASE_MANIFEST) runtimeOptions.runtimeReleaseManifestPath = process.env.CTG_TEST_RUNTIME_RELEASE_MANIFEST;
|
|
20
|
+
if (process.env.CTG_TEST_RUNTIME_ARTIFACT_ROOT) {
|
|
21
|
+
await preflightNativeTestRuntimeArtifact({ ownedRoot: process.env.CTG_TEST_RUNTIME_ARTIFACT_ROOT, expectedManifestPath: process.env.CTG_TEST_RUNTIME_RELEASE_MANIFEST });
|
|
22
|
+
runtimeOptions.acquireOwnedRuntime = async () => process.env.CTG_TEST_RUNTIME_ARTIFACT_ROOT;
|
|
23
|
+
}
|
|
24
|
+
const binary = await resolveOpenCode(runtimeOptions);
|
|
25
|
+
const temp = await mkdtemp(join(tmpdir(), 'ctg-ship-models-'));
|
|
26
|
+
const cwd = join(temp, 'project');
|
|
27
|
+
const config = join(temp, 'config/opencode');
|
|
28
|
+
const password = randomBytes(24).toString('hex');
|
|
29
|
+
const token = `fixture.${Buffer.from(JSON.stringify({ sub: 'offline-user', sid: 'offline-session', scope: 'gateway:invoke', exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url')}.fixture`;
|
|
30
|
+
let modelRequests = 0;
|
|
31
|
+
let verificationUri;
|
|
32
|
+
const cloud = createServer((req, res) => {
|
|
33
|
+
req.resume();
|
|
34
|
+
req.on('end', () => {
|
|
35
|
+
let status = 200, body;
|
|
36
|
+
if (req.url === '/v1/device/authorizations') {
|
|
37
|
+
body = { deviceCode: 'offline-device', userCode: 'OFFLINE', verificationUri, expiresIn: 120, interval: 1 };
|
|
38
|
+
} else if (req.url === '/v1/device/token') {
|
|
39
|
+
body = { accessToken: token, refreshToken: 'offline-refresh', expiresIn: 3600 };
|
|
40
|
+
} else if (req.url === '/v1/models' && req.headers.authorization === `Bearer ${token}`) {
|
|
41
|
+
modelRequests++;
|
|
42
|
+
body = { data: [{ id: 'ctg-ship-fixture' }] };
|
|
43
|
+
} else { status = 404; body = {}; }
|
|
44
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
45
|
+
res.end(JSON.stringify(body));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
let child;
|
|
49
|
+
let output = '';
|
|
50
|
+
try {
|
|
51
|
+
cloud.listen(0, '127.0.0.1');
|
|
52
|
+
await once(cloud, 'listening');
|
|
53
|
+
verificationUri = `http://127.0.0.1:${cloud.address().port}/verify-${randomBytes(16).toString('hex')}`;
|
|
54
|
+
await mkdir(cwd);
|
|
55
|
+
await mkdir(config, { recursive: true });
|
|
56
|
+
await writeFile(join(config, 'opencode.json'), JSON.stringify({ plugins: [
|
|
57
|
+
join(temp, 'deleted-pr-checkout/plugins/carthagent'),
|
|
58
|
+
{ package: join(temp, 'deleted-install/plugins/carthagent'), options: {} },
|
|
59
|
+
], update: 'disable' }));
|
|
60
|
+
await provisionRuntime({ root: temp, pluginPath: join(root, 'plugins/carthagent') });
|
|
61
|
+
const env = Object.fromEntries(['PATH', 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT'].filter(key => process.env[key]).map(key => [key, process.env[key]]));
|
|
62
|
+
Object.assign(env, {
|
|
63
|
+
HOME: join(temp, 'home'), USERPROFILE: join(temp, 'home'),
|
|
64
|
+
APPDATA: join(temp, 'appdata'), LOCALAPPDATA: join(temp, 'localappdata'),
|
|
65
|
+
XDG_CONFIG_HOME: join(temp, 'config'), XDG_DATA_HOME: join(temp, 'data'),
|
|
66
|
+
XDG_STATE_HOME: join(temp, 'state'), XDG_CACHE_HOME: join(temp, 'cache'),
|
|
67
|
+
OPENCODE_CONFIG_DIR: config, OPENCODE_CONFIG: join(config, 'opencode.json'),
|
|
68
|
+
OPENCODE_CONFIG_PROJECT_DISABLE: 'true', OPENCODE_DB: join(temp, 'database.sqlite'),
|
|
69
|
+
OPENCODE_SERVER_PASSWORD: password, CARTHAGENT_CLOUD_URL: `http://127.0.0.1:${cloud.address().port}`,
|
|
70
|
+
});
|
|
71
|
+
async function startHost() {
|
|
72
|
+
output = '';
|
|
73
|
+
child = spawn(binary, ['serve', '--hostname', '127.0.0.1', '--port', '0', '--print-logs'], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
74
|
+
const endpoint = await new Promise((resolveEndpoint, reject) => {
|
|
75
|
+
const timer = setTimeout(() => reject(Error('Native Ship fixture host startup timed out')), 30_000);
|
|
76
|
+
const collect = chunk => {
|
|
77
|
+
output = (output + String(chunk)).slice(-16384);
|
|
78
|
+
const found = output.match(/http:\/\/127\.0\.0\.1:\d+/)?.[0];
|
|
79
|
+
if (found) { clearTimeout(timer); resolveEndpoint(found); }
|
|
80
|
+
};
|
|
81
|
+
child.stdout.on('data', collect); child.stderr.on('data', collect);
|
|
82
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
83
|
+
child.once('exit', code => { clearTimeout(timer); reject(Error(`Native Ship fixture exited ${code}`)); });
|
|
84
|
+
});
|
|
85
|
+
const client = OpenCode.make({ baseUrl: endpoint, headers: { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` } });
|
|
86
|
+
return { client, endpoint };
|
|
87
|
+
}
|
|
88
|
+
const { client, endpoint } = await startHost();
|
|
89
|
+
const location = { directory: cwd };
|
|
90
|
+
const request = { signal: AbortSignal.timeout(30_000) };
|
|
91
|
+
const startedAt = Date.now();
|
|
92
|
+
while (true) {
|
|
93
|
+
const plugins = await client.plugin.list({ location }, request);
|
|
94
|
+
const plugin = plugins.data.find(item => item.id === 'carthagent');
|
|
95
|
+
if (plugin?.state.status === 'active') {
|
|
96
|
+
assert.equal(plugins.data.filter(item => item.id === 'carthagent').length, 1, 'Migrated profile must load exactly one Carthagent plugin');
|
|
97
|
+
assert.ok(!plugins.data.some(item => item.state.status === 'failed' || item.state.status === 'error'), 'Migrated profile must not leave stale or duplicate plugin failures');
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
assert.notEqual(plugin?.state.status, 'error', 'Carthagent plugin setup failed');
|
|
101
|
+
assert.ok(Date.now() - startedAt < 15000, 'Carthagent plugin did not become active');
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
103
|
+
}
|
|
104
|
+
const integrations = await client.integration.list({ location }, request);
|
|
105
|
+
console.log(JSON.stringify({ supportedIntegrations: integrations.data.filter(item => /anthropic|openai|codex|chatgpt|xai|google|gemini|experiential/.test(item.id)).map(item => ({ id: item.id, name: item.name, methods: item.methods.map(method => ({ type: method.type, id: method.id, label: method.label })) })) }));
|
|
106
|
+
const ship = integrations.data.find(item => item.id === 'experiential-labs');
|
|
107
|
+
assert.ok(ship, 'Ship must be connectable without a preexisting credential or EXPLABS_API_KEY');
|
|
108
|
+
console.log(JSON.stringify({ integrationName: ship.name, methods: ship.methods.map(method => ({ type: method.type, id: method.id })) }));
|
|
109
|
+
const started = await client.integration.oauth.connect({ integrationID: ship.id, methodID: 'carthagent-device', answer: {}, location }, request);
|
|
110
|
+
const deadline = Date.now() + 15000;
|
|
111
|
+
while (true) {
|
|
112
|
+
const status = (await client.integration.oauth.status({ integrationID: ship.id, attemptID: started.data.attemptID, location }, request)).data;
|
|
113
|
+
if (status.status === 'complete') break;
|
|
114
|
+
assert.ok(['pending', 'waiting'].includes(status.status), `OAuth completion failed: ${status.status}`);
|
|
115
|
+
assert.ok(Date.now() < deadline, 'OAuth completion timed out');
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
117
|
+
}
|
|
118
|
+
let models;
|
|
119
|
+
do {
|
|
120
|
+
models = (await client.model.list({ location }, request)).data.filter(model => model.providerID === ship.id);
|
|
121
|
+
if (models.some(model => model.id === 'ctg-ship-fixture')) break;
|
|
122
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
123
|
+
} while (Date.now() < deadline);
|
|
124
|
+
console.log(JSON.stringify({ modelRequests, models: models.map(model => ({ id: model.id, providerID: model.providerID, connected: model.connected, status: model.status })) }));
|
|
125
|
+
assert.ok(models.some(model => model.id === 'ctg-ship-fixture'), 'Successful Ship OAuth must publish discovered models in the real native model inventory without EXPLABS_API_KEY');
|
|
126
|
+
assert.equal(ship.name, 'Carthagent Ship', 'The connect picker must show the Ship name');
|
|
127
|
+
assert.deepEqual(integrations.data.map(item => item.id).sort(), ['anthropic', 'experiential-labs', 'google', 'openai', 'xai'], 'Login must contain only the curated major integrations');
|
|
128
|
+
const providers = (await client.provider.list({ location }, request)).data;
|
|
129
|
+
assert.ok(!providers.some(provider => /^opencode(?:-|$)/.test(provider.id)), 'The upstream provider must not be available');
|
|
130
|
+
const paths = runtimePaths({ root: temp });
|
|
131
|
+
const credentialBeforeStop = (await client.credential.list(request)).find(entry => entry.integrationID === ship.id);
|
|
132
|
+
assert.ok(credentialBeforeStop?.active, 'Ship must have a persisted active credential');
|
|
133
|
+
const info = await client.server.info(request);
|
|
134
|
+
assert.equal(info.pid, child.pid);
|
|
135
|
+
await mkdir(join(temp, 'state/opencode'), { recursive: true });
|
|
136
|
+
await writeFile(paths.serviceFile, JSON.stringify({ id: 'ship-model-regression', pid: child.pid, url: endpoint, password, runtimeIdentity: { version: info.version } }), { mode: 0o600 });
|
|
137
|
+
const closed = once(child, 'close');
|
|
138
|
+
const stopStartedAt = performance.now();
|
|
139
|
+
try {
|
|
140
|
+
const stopped = await promisify(execFile)(process.execPath, [join(root, 'bin/ctg.mjs'), 'native', 'service', 'stop'], { cwd, env: { ...env, CTG_OPENCODE_HOME: temp }, timeout: 15000 });
|
|
141
|
+
assert.match(stopped.stdout, /Stopped the registered native service/);
|
|
142
|
+
await closed;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
try {
|
|
145
|
+
console.error('Native Ship fixture service-stop failure evidence:', JSON.stringify(await fixtureStopDiagnostics({ child, output, elapsedMs: performance.now() - stopStartedAt })));
|
|
146
|
+
} catch {
|
|
147
|
+
console.error('Native Ship fixture service-stop failure evidence unavailable.');
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
const restarted = await startHost();
|
|
152
|
+
const credentialAfterStop = (await restarted.client.credential.list(request)).find(entry => entry.id === credentialBeforeStop.id);
|
|
153
|
+
assert.deepEqual(credentialAfterStop, credentialBeforeStop, 'Explicit service stop must preserve the active authenticated credential across restart');
|
|
154
|
+
assert.ok((await readFile(join(temp, 'database.sqlite'))).length > 0, 'Explicit service stop must preserve the native database');
|
|
155
|
+
console.log('Explicit native service stop preserved credentials and database.');
|
|
156
|
+
|
|
157
|
+
} finally {
|
|
158
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
159
|
+
const closed = once(child, 'close', { signal: AbortSignal.timeout(10_000) });
|
|
160
|
+
// No PTYs are spawned here; stop this exact server like the service-stop command does.
|
|
161
|
+
child.kill('SIGTERM');
|
|
162
|
+
const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 5000);
|
|
163
|
+
try { await closed; }
|
|
164
|
+
finally { clearTimeout(timer); }
|
|
165
|
+
}
|
|
166
|
+
cloud.closeAllConnections();
|
|
167
|
+
await new Promise(resolve => cloud.close(resolve));
|
|
168
|
+
if (process.platform === 'win32' && verificationUri) {
|
|
169
|
+
await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', stopFixtureBrowser], {
|
|
170
|
+
env: { ...process.env, CTG_NATIVE_FIXTURE_URL: verificationUri }, windowsHide: true, timeout: 20_000,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
await rm(temp, { recursive: true, force: true, maxRetries: 12, retryDelay: 250 });
|
|
174
|
+
await assert.rejects(access(temp), { code: 'ENOENT' });
|
|
175
|
+
console.log('Native Ship fixture shutdown and temporary-directory cleanup completed.');
|
|
176
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { latestReport } from '../lib/reports.mjs';
|
|
5
|
+
import { nativeFixture, streamText } from './native-fixture.mjs';
|
|
6
|
+
|
|
7
|
+
const requirement = 'fixture content is correct';
|
|
8
|
+
const design = {
|
|
9
|
+
components: [{ id: 'fixture', responsibility: 'Write the fixture output', excludes: ['production behavior'], layer: 'core', dependencies: [], externalDependencies: [], requirements: [requirement] }],
|
|
10
|
+
ports: [], risks: [],
|
|
11
|
+
scenarios: [
|
|
12
|
+
{ id: 'valid', requirement, kind: 'positive', path: ['fixture'], input: 'correct output', expected: 'check passes', assertion: 'exact UTF-8 file contents match', checks: ['fixture-check'] },
|
|
13
|
+
{ id: 'invalid', requirement, kind: 'failure', path: ['fixture'], input: 'broken output', expected: 'check fails', assertion: 'wrong contents trigger an assertion failure', checks: ['fixture-check'] },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
const plan = {
|
|
17
|
+
goal: 'Exercise native Ship from approval through a repaired verified handoff',
|
|
18
|
+
assumptions: [], artifacts: ['fixture.txt'], outputs: ['fixture.txt'],
|
|
19
|
+
steps: [{ id: 'fixture', title: 'Create and verify fixture output' }],
|
|
20
|
+
acceptance: [{ requirement, checks: ['fixture-check'] }],
|
|
21
|
+
checks: [{ id: 'fixture-check', kind: 'test', argv: ['node', '-e', 'require("node:assert/strict").equal(require("node:fs").readFileSync("fixture.txt", "utf8"), "fixture output\\n")'], timeoutSeconds: 5 }],
|
|
22
|
+
design,
|
|
23
|
+
};
|
|
24
|
+
const designReview = {
|
|
25
|
+
challenges: ['Does broken content actually fail the checker?'],
|
|
26
|
+
walkthroughs: design.scenarios.map(item => ({ scenario: item.id, trace: `${item.input} -> fixture -> ${item.expected}`, assertion: item.assertion })),
|
|
27
|
+
findings: [], limitations: ['Model-authored fixture review, not independent assurance.'],
|
|
28
|
+
};
|
|
29
|
+
const call = (name, input) => `await tools.carthagent[${JSON.stringify(name)}](${JSON.stringify(input)})`;
|
|
30
|
+
const finish = { status: 'verified', change: 'Created fixture.txt with the required exact UTF-8 content.', review: 'Exact content check and repaired failure inspected.', launch: 'Fixture check executed in this disposable workspace.', limitations: ['Fixture-only walkthrough; no production or visual assurance.'] };
|
|
31
|
+
const actions = [
|
|
32
|
+
{ code: `return ${call('delivery_plan', plan)}` },
|
|
33
|
+
{ code: `return ${call('delivery_finish', finish)}` },
|
|
34
|
+
{ code: `return ${call('delivery_design', { action: 'validate' })}` },
|
|
35
|
+
{ code: `const capture = String(${call('delivery_design', { action: 'inspect' })}); const captureId = JSON.parse(capture).captureId; if (!captureId) throw Error("Missing design capture"); return await tools.carthagent.delivery_design({ action: "review", captureId, review: ${JSON.stringify(designReview)} })` },
|
|
36
|
+
{ code: `return ${call('delivery_design', { action: 'approve' })}` },
|
|
37
|
+
{ shell: 'printf broken > fixture.txt' },
|
|
38
|
+
{ code: `return ${call('delivery_check', { id: 'all' })}` },
|
|
39
|
+
{ code: `return ${call('delivery_finish', finish)}` },
|
|
40
|
+
{ shell: "printf 'fixture output\\n' > fixture.txt" },
|
|
41
|
+
{ code: `return ${call('delivery_progress', { step: 'fixture', status: 'done' })}` },
|
|
42
|
+
{ code: `return ${call('delivery_check', { id: 'all' })}` },
|
|
43
|
+
{ code: `const capture = JSON.parse(String(${call('delivery_review', { action: 'inspect' })})); return await tools.carthagent.delivery_review({ action: "record", captureId: capture.id, coverage: [{ requirement: ${JSON.stringify(requirement)}, status: "demonstrated", assertions: "The real checker rejected broken content, then accepted the repaired exact UTF-8 contents." }], probes: ["Observed assertion failure before repair and a passing exact-content assertion afterward."], findings: [], limitations: ["Model-authored fixture review; not independent production assurance."] })` },
|
|
44
|
+
{ code: `return ${call('delivery_finish', finish)}` },
|
|
45
|
+
];
|
|
46
|
+
let stage = 0;
|
|
47
|
+
const host = await nativeFixture({
|
|
48
|
+
pluginOptions: { delivery: true, deliveryOptions: { workflow: 'strict' } },
|
|
49
|
+
respond(body, response) {
|
|
50
|
+
const lastUser = JSON.stringify([...body.messages].reverse().find(item => item.role === 'user'));
|
|
51
|
+
if (lastUser.includes('WALKTHROUGH_RESUME') || lastUser.includes('WALKTHROUGH_LITE')) return streamText(response, 'WALKTHROUGH_RESUMED');
|
|
52
|
+
const action = actions[stage++];
|
|
53
|
+
if (!action) return streamText(response, 'WALKTHROUGH_VERIFIED');
|
|
54
|
+
const name = action.shell ? 'shell' : 'execute';
|
|
55
|
+
assert.ok(body.tools.some(tool => tool.function?.name === name), `${name} must be admitted at walkthrough stage ${stage}`);
|
|
56
|
+
const delta = { role: 'assistant', tool_calls: [{ index: 0, id: `walkthrough-${stage}`, type: 'function', function: { name, arguments: JSON.stringify(action.shell ? { command: action.shell } : { code: action.code }) } }] };
|
|
57
|
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
58
|
+
response.end(`data: ${JSON.stringify({ id: `walkthrough-${stage}`, object: 'chat.completion.chunk', created: 1, model: 'fixture', choices: [{ index: 0, delta, finish_reason: 'tool_calls' }] })}\n\ndata: [DONE]\n\n`);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
const session = await host.client.session.create({ location: host.location, model: host.model, agent: 'ship', metadata: { 'carthagent.options': { workflow: 'strict' } }, title: 'Native Ship full lifecycle' }, host.request());
|
|
63
|
+
await host.active();
|
|
64
|
+
await host.client.session.prompt({ sessionID: session.id, text: 'Run the strict Ship fixture walkthrough.' }, host.request());
|
|
65
|
+
await host.client.session.wait({ sessionID: session.id }, host.request(90_000));
|
|
66
|
+
const report = latestReport(host.cwd);
|
|
67
|
+
assert.equal(report?.state.status, 'verified', JSON.stringify(await host.client.session.context({ sessionID: session.id }, host.request())));
|
|
68
|
+
assert.equal(report.state.workflowMode, 'strict');
|
|
69
|
+
assert.ok(report.state.planning.approval);
|
|
70
|
+
assert.ok(report.state.planning.started);
|
|
71
|
+
assert.equal(report.state.evidence['fixture-check'].passed, true);
|
|
72
|
+
assert.ok(report.state.reviewEvidence);
|
|
73
|
+
assert.equal(await readFile(`${host.cwd}/fixture.txt`, 'utf8'), 'fixture output\n');
|
|
74
|
+
const history = JSON.stringify(await host.client.message.list({ sessionID: session.id }, host.request()));
|
|
75
|
+
assert.match(history, /Cannot verify:/);
|
|
76
|
+
assert.match(history, /passed.{0,40}false/);
|
|
77
|
+
assert.equal(stage, actions.length + 1);
|
|
78
|
+
const runID = report.state.runId;
|
|
79
|
+
await host.stop();
|
|
80
|
+
const client = await host.start();
|
|
81
|
+
await host.active();
|
|
82
|
+
await client.session.prompt({ sessionID: session.id, text: 'WALKTHROUGH_RESUME inspect the saved handoff without changing files.' }, host.request());
|
|
83
|
+
await client.session.wait({ sessionID: session.id }, host.request());
|
|
84
|
+
assert.equal(latestReport(host.cwd).state.runId, runID);
|
|
85
|
+
assert.equal(latestReport(host.cwd).state.status, 'verified');
|
|
86
|
+
await client.session.switchAgent({ sessionID: session.id, agent: 'lite' }, host.request());
|
|
87
|
+
const requestsBefore = host.requests.length;
|
|
88
|
+
await client.session.prompt({ sessionID: session.id, text: 'WALKTHROUGH_LITE respond directly.' }, host.request());
|
|
89
|
+
await client.session.wait({ sessionID: session.id }, host.request());
|
|
90
|
+
const liteRequest = host.requests.slice(requestsBefore).at(-1);
|
|
91
|
+
assert.equal(liteRequest.tools.some(tool => tool.function?.name.startsWith('carthagent_delivery_')), false);
|
|
92
|
+
assert.equal((await client.session.get({ sessionID: session.id }, host.request())).agent, 'lite');
|
|
93
|
+
console.log(JSON.stringify({ runtime: host.runtimeIdentity.runtimeVersion, strictApproval: 'passed', failedCheckAndRepair: 'passed', reviewAndVerifiedFinish: 'passed', serviceRestartAndResume: 'passed', liteSwitch: 'passed', providerRequests: host.requests.length }));
|
|
94
|
+
} finally {
|
|
95
|
+
await host.close();
|
|
96
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Run real slash-command acceptance with a trusted owned runtime and loopback-only provider. */
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { preflightNativeTestRuntimeArtifact } from './native-runtime-identity.mjs';
|
|
10
|
+
|
|
11
|
+
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
12
|
+
const ownedRoot = process.env.CTG_OWNED_OPENCODE_RUNTIME;
|
|
13
|
+
const manifestPath = process.env.CTG_TEST_RUNTIME_RELEASE_MANIFEST;
|
|
14
|
+
assert.ok(ownedRoot, 'CTG_OWNED_OPENCODE_RUNTIME must select the explicit candidate artifact');
|
|
15
|
+
assert.ok(manifestPath, 'CTG_TEST_RUNTIME_RELEASE_MANIFEST must point at the trusted release manifest');
|
|
16
|
+
const preflight = await preflightNativeTestRuntimeArtifact({ ownedRoot, expectedManifestPath: manifestPath });
|
|
17
|
+
const binary = join(resolve(ownedRoot), `cli-${process.platform}-${process.arch}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
18
|
+
|
|
19
|
+
const artifact = process.env.CTG_SLASH_ACCEPTANCE_ARTIFACT ?? join(root, `ctg-slash-acceptance-${process.pid}.json`);
|
|
20
|
+
const temp = await mkdtemp(join(tmpdir(), 'ctg-slash-acceptance-'));
|
|
21
|
+
const fixturePath = join(temp, 'fixture.json');
|
|
22
|
+
const logsPath = process.env.CTG_SLASH_ACCEPTANCE_LOGS ?? join(temp, 'fixture.log');
|
|
23
|
+
let fixture;
|
|
24
|
+
let fixtureChild;
|
|
25
|
+
try {
|
|
26
|
+
const script = `
|
|
27
|
+
import { once } from 'node:events';
|
|
28
|
+
import { writeFile as writeFixtureFile } from 'node:fs/promises';
|
|
29
|
+
import { join as joinPath } from 'node:path';
|
|
30
|
+
import { nativeFixture } from './tools/native-fixture.mjs';
|
|
31
|
+
const plugin = process.argv[1];
|
|
32
|
+
const host = await nativeFixture({ plugin, verifyInventory: true,
|
|
33
|
+
env: { CTG_OWNED_OPENCODE_RUNTIME: process.env.CTG_OWNED_OPENCODE_RUNTIME,
|
|
34
|
+
CTG_TEST_RUNTIME_RELEASE_MANIFEST: process.env.CTG_TEST_RUNTIME_RELEASE_MANIFEST },
|
|
35
|
+
respond(body, response) {
|
|
36
|
+
const messages = body.messages ?? [];
|
|
37
|
+
const latest = [...messages].reverse().find(message => message.role === 'user');
|
|
38
|
+
const text = typeof latest?.content === 'string' ? latest.content : JSON.stringify(latest?.content ?? '');
|
|
39
|
+
globalThis.providerPrompts ??= [];
|
|
40
|
+
globalThis.providerPrompts.push(text);
|
|
41
|
+
void writeFixtureFile(joinPath(host.temp, 'provider-prompts.json'), JSON.stringify(globalThis.providerPrompts));
|
|
42
|
+
const reply = text.includes('RAW_SENTINEL') ? 'RAW_FIXTURE_REPLY'
|
|
43
|
+
: text.includes('framed task marker') ? 'GUIDE_FIXTURE_REPLY'
|
|
44
|
+
: 'FIXTURE_REPLY';
|
|
45
|
+
const chunk = (delta, finish_reason = null) => 'data: ' + JSON.stringify({ id:'ctg-slash-offline', object:'chat.completion.chunk', created:1, model:'fixture', choices:[{index:0,delta,finish_reason}] }) + '\\n\\n';
|
|
46
|
+
response.writeHead(200, { 'content-type':'text/event-stream', connection:'close' });
|
|
47
|
+
response.end(chunk({role:'assistant',content:reply}) + chunk({},'stop') + 'data: [DONE]\\n\\n');
|
|
48
|
+
}});
|
|
49
|
+
try {
|
|
50
|
+
const session = await host.client.session.create({ location:host.location, model:host.model, title:'slash command acceptance' }, host.request());
|
|
51
|
+
await host.active();
|
|
52
|
+
const info = { temp:host.temp, cwd:host.cwd, baseUrl:host.baseUrl, password:host.password,
|
|
53
|
+
sessionID:session.id, pluginReadiness:host.pluginObservation,
|
|
54
|
+
providerRequests:host.requests.length, providerLog:joinPath(host.temp, 'provider-prompts.json') };
|
|
55
|
+
process.stdout.write(JSON.stringify(info)+'\\n');
|
|
56
|
+
process.once('SIGTERM', async()=>{await host.close({preserveTemp:false});process.exit(0)});
|
|
57
|
+
await new Promise(()=>{});
|
|
58
|
+
} catch(error) { await host.close({preserveTemp:false}); throw error; }
|
|
59
|
+
`;
|
|
60
|
+
fixtureChild = spawn(process.execPath, ['--input-type=module', '-e', script, join(root, 'plugins/carthagent')], {
|
|
61
|
+
cwd: root,
|
|
62
|
+
env: {
|
|
63
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
64
|
+
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
65
|
+
CTG_OWNED_OPENCODE_RUNTIME: resolve(ownedRoot),
|
|
66
|
+
CTG_TEST_RUNTIME_RELEASE_MANIFEST: resolve(manifestPath),
|
|
67
|
+
NODE_OPTIONS: process.env.NODE_OPTIONS ?? '',
|
|
68
|
+
},
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
71
|
+
let stderr = '';
|
|
72
|
+
fixtureChild.stderr.setEncoding('utf8').on('data', chunk => { stderr = (stderr + chunk).slice(-12000); });
|
|
73
|
+
const line = await new Promise((resolveLine, reject) => {
|
|
74
|
+
let stdout = '';
|
|
75
|
+
const timer = setTimeout(() => reject(new Error(`native fixture startup timeout\n${stderr}`)), 60_000);
|
|
76
|
+
fixtureChild.stdout.setEncoding('utf8').on('data', chunk => {
|
|
77
|
+
stdout += chunk;
|
|
78
|
+
const newline = stdout.indexOf('\n');
|
|
79
|
+
if (newline !== -1) { clearTimeout(timer); resolveLine(stdout.slice(0, newline)); }
|
|
80
|
+
});
|
|
81
|
+
fixtureChild.once('exit', (code, signal) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
reject(new Error(`native fixture exited ${code ?? signal}: ${stderr}`));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
fixture = JSON.parse(line);
|
|
87
|
+
assert.equal(fixture.pluginReadiness?.pluginId, 'carthagent');
|
|
88
|
+
assert.equal(fixture.pluginReadiness?.observed, 'active');
|
|
89
|
+
await writeFile(fixturePath, JSON.stringify(fixture));
|
|
90
|
+
const python = process.env.PYTHON ?? 'python3';
|
|
91
|
+
const args = [join(root, 'tools/test-native-slash-commands.py'), '--binary', binary,
|
|
92
|
+
'--owned-root', resolve(ownedRoot), '--release-manifest', resolve(manifestPath),
|
|
93
|
+
'--fixture', fixturePath, '--artifact', artifact];
|
|
94
|
+
const result = spawnSync(python, args, {
|
|
95
|
+
cwd: root, encoding: 'utf8', timeout: 180_000,
|
|
96
|
+
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', LANG: process.env.LANG ?? 'C.UTF-8',
|
|
97
|
+
CTG_OWNED_OPENCODE_RUNTIME: resolve(ownedRoot),
|
|
98
|
+
CTG_TEST_RUNTIME_RELEASE_MANIFEST: resolve(manifestPath),
|
|
99
|
+
CTG_SLASH_ACCEPTANCE_LOGS: logsPath },
|
|
100
|
+
});
|
|
101
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
102
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
103
|
+
await writeFile(logsPath, JSON.stringify({ stdout: result.stdout, stderr: result.stderr,
|
|
104
|
+
status: result.status, signal: result.signal, error: result.error?.message ?? null }, null, 2) + '\n');
|
|
105
|
+
if (result.error) throw result.error;
|
|
106
|
+
const proof = JSON.parse(await readFile(artifact, 'utf8'));
|
|
107
|
+
if (proof.status === 'PARTIAL') {
|
|
108
|
+
process.stderr.write(`Slash acceptance partial; inspect retained proof and logs: ${artifact} · ${logsPath}\n`);
|
|
109
|
+
process.exitCode = 2;
|
|
110
|
+
} else {
|
|
111
|
+
assert.equal(result.status, 0, `slash acceptance failed; retained logs: ${logsPath}`);
|
|
112
|
+
assert.equal(proof.status, 'PASS');
|
|
113
|
+
}
|
|
114
|
+
assert.equal(proof.binarySha256, preflight.identity.binarySha256);
|
|
115
|
+
assert.equal(proof.scope, 'Linux POSIX PTY; actual native plugin slash submissions; offline loopback provider');
|
|
116
|
+
process.stdout.write(`Retained acceptance proof: ${artifact}\nRetained runner logs: ${logsPath}\n`);
|
|
117
|
+
} finally {
|
|
118
|
+
if (fixtureChild && fixtureChild.exitCode === null) {
|
|
119
|
+
fixtureChild.kill('SIGTERM');
|
|
120
|
+
await Promise.race([new Promise(resolveExit => fixtureChild.once('exit', resolveExit)), new Promise(resolveExit => setTimeout(resolveExit, 7000))]);
|
|
121
|
+
if (fixtureChild.exitCode === null) fixtureChild.kill('SIGKILL');
|
|
122
|
+
}
|
|
123
|
+
// Fixture files are outside the repository; proof and logs are retained.
|
|
124
|
+
await rm(temp, { recursive: true, force: true });
|
|
125
|
+
}
|