carthagent 1.0.7 → 1.1.0-preview.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -79
- package/bin/ctg.mjs +14 -732
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +314 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +177 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +8 -4
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +298 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +197 -0
- package/plugins/carthagent/src/server/delivery.ts +217 -106
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +58 -30
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +39 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +492 -0
- package/plugins/carthagent/src/ui/client.ts +125 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +232 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +31 -0
- package/plugins/carthagent/src/ui/native-review.mjs +154 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +122 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
- package/plugins/carthagent/src/ui/voyages.tsx +127 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +60 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +258 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +53 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +426 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +267 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +75 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +395 -0
- package/tests/native-voyages-ui.test.mjs +277 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +546 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +230 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +36 -0
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +100 -18
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +83 -61
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { Context } from '@opencode/plugin/promise/plugin';
|
|
6
|
+
import { CloudClient, cloudControlUrl } from '../../../../lib/cloud/client.mjs';
|
|
7
|
+
import { atomicWrite, runtimePaths, withFileLock } from '../../../../lib/opencode/runtime.mjs';
|
|
8
|
+
import { Voyages, voyagesSlots, voyagesAppearance, type VoyagesProfile, type VoyagesItem, type VoyagesQuote, type VoyagesPending } from '../voyages-rpc';
|
|
9
|
+
|
|
10
|
+
type Port = Pick<Context, 'integration'>;
|
|
11
|
+
type Call = import('@opencode/plugin/promise/rpc').RpcCallContext<typeof Voyages.methods.snapshot>;
|
|
12
|
+
type CloudPort = { request(path: string, options: { method: string; accessToken: string; signal: AbortSignal; body?: unknown }): Promise<unknown> };
|
|
13
|
+
type Pending = VoyagesPending & { key: string };
|
|
14
|
+
type State = { quotes: VoyagesQuote[]; pending?: Pending };
|
|
15
|
+
class Failure extends Error {
|
|
16
|
+
constructor(readonly code: string, readonly status = 0, readonly uncertain = false) { super(code); }
|
|
17
|
+
}
|
|
18
|
+
const codes = new Set(('authentication_required invalid_token expired_token revoked_token reauthentication_required insufficient_scope milestone_locked purchase_required insufficient_coins profile_not_found not_found not_available version_conflict already_owned quote_expired terms_changed quote_consumed key_conflict rate_limited quote_limit voyages_unavailable internal_error invalid_body unknown_field version_required invalid_name invalid_config invalid_item invalid_id invalid_key wallet_limit').split(' '));
|
|
19
|
+
const noDebit = new Set(('invalid_body unknown_field invalid_id invalid_key milestone_locked insufficient_coins not_found not_available already_owned quote_expired terms_changed authentication_required invalid_token expired_token revoked_token reauthentication_required insufficient_scope').split(' '));
|
|
20
|
+
type Journal = { get(file: string): Promise<unknown>; set(file: string, state: State): Promise<void> };
|
|
21
|
+
const fileJournal: Journal = {
|
|
22
|
+
async get(file) {
|
|
23
|
+
let handle;
|
|
24
|
+
try { handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); }
|
|
25
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; }
|
|
26
|
+
try {
|
|
27
|
+
const details = await handle.stat();
|
|
28
|
+
check(details.isFile() && details.size <= 65536);
|
|
29
|
+
const bytes = Buffer.alloc(65537);
|
|
30
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
31
|
+
check(bytesRead <= 65536 && bytesRead === details.size);
|
|
32
|
+
return JSON.parse(bytes.toString('utf8', 0, bytesRead));
|
|
33
|
+
} finally { await handle.close(); }
|
|
34
|
+
},
|
|
35
|
+
async set(file, state) {
|
|
36
|
+
const contents = JSON.stringify(state); check(Buffer.byteLength(contents) <= 65536);
|
|
37
|
+
await atomicWrite(file, contents, 0o600);
|
|
38
|
+
// Persist the rename as well as the file bytes on POSIX filesystems.
|
|
39
|
+
if (process.platform !== 'win32') {
|
|
40
|
+
const directory = await open(join(file, '..'), 'r');
|
|
41
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
function check(ok: unknown): asserts ok { if (!ok) throw new Failure('invalid_response'); }
|
|
46
|
+
function obj(value: unknown): Record<string, unknown> { check(value && typeof value === 'object' && !Array.isArray(value)); return value as Record<string, unknown>; }
|
|
47
|
+
function str(value: unknown, max = 128): string { check(typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value)); return value; }
|
|
48
|
+
function int(value: unknown, min = 0): number { check(typeof value === 'number' && Number.isSafeInteger(value) && value >= min); return value; }
|
|
49
|
+
function num(value: unknown): number { check(typeof value === 'number' && Number.isFinite(value) && value >= 0); return value; }
|
|
50
|
+
function key(value: unknown): string { const result = str(value, 64); check(/^[a-zA-Z0-9_-]+$/.test(result)); return result; }
|
|
51
|
+
function fullItem(value: unknown): string { const result = str(value); const parts = result.split(':'); const [slot, item] = parts; check(parts.length === 2 && voyagesSlots.includes(slot as typeof voyagesSlots[number]) && item); key(item); return result; }
|
|
52
|
+
function choice(value: unknown, choices: readonly string[]): string { check(choices.includes(value as string)); return value as string; }
|
|
53
|
+
function profile(value: unknown): VoyagesProfile {
|
|
54
|
+
const p = obj(value), c = obj(p.config);
|
|
55
|
+
const config = Object.fromEntries(voyagesSlots.map(slot => [slot, key(c[slot])])) as VoyagesProfile['config'];
|
|
56
|
+
config.skin = choice(c.skin, voyagesAppearance.skin);
|
|
57
|
+
config.hair = choice(c.hair, voyagesAppearance.hair);
|
|
58
|
+
config.pattern = choice(c.pattern, voyagesAppearance.pattern);
|
|
59
|
+
check(Array.isArray(p.owned) && p.owned.length <= 10000);
|
|
60
|
+
const owned = p.owned.map(fullItem); check(new Set(owned).size === owned.length);
|
|
61
|
+
return { version: int(p.version, 1), name: str(p.name, 20), shipName: str(p.shipName, 28), coins: int(p.coins), totalXp: int(p.totalXp), level: int(p.level, 1), xpIntoLevel: int(p.xpIntoLevel), xpForLevel: int(p.xpForLevel, 1), km: num(p.km), weeklyKm: num(p.weeklyKm), config, owned };
|
|
62
|
+
}
|
|
63
|
+
function item(value: unknown, owned: ReadonlySet<string>): VoyagesItem {
|
|
64
|
+
const v = obj(value);
|
|
65
|
+
check(typeof v.requiresPurchase === 'boolean' && typeof v.available === 'boolean' && typeof v.eligible === 'boolean');
|
|
66
|
+
const id = fullItem(v.item), itemKey = key(v.itemKey);
|
|
67
|
+
check(id === `${v.slot}:${itemKey}`);
|
|
68
|
+
// Retired paid equipment remains owned; a missing offer price is not zero.
|
|
69
|
+
check(v.price !== null || owned.has(id));
|
|
70
|
+
check(v.reason === null || typeof v.reason === 'string' && v.reason.length <= 128 && !/[\u0000-\u001f\u007f]/.test(v.reason));
|
|
71
|
+
return { item: id, name: str(v.name), slot: v.slot as VoyagesItem['slot'], itemKey, price: v.price === null ? null : int(v.price), requiresPurchase: v.requiresPurchase, available: v.available, eligible: v.eligible, reason: v.reason as string | null };
|
|
72
|
+
}
|
|
73
|
+
function quote(value: unknown): VoyagesQuote {
|
|
74
|
+
const q = obj(value);
|
|
75
|
+
const iso = str(q.expiresAt, 24);
|
|
76
|
+
check(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(iso));
|
|
77
|
+
const epoch = Date.parse(iso);
|
|
78
|
+
check(Number.isFinite(epoch) && epoch > 0 && epoch <= 8.64e15);
|
|
79
|
+
check(new Date(epoch).toISOString().slice(0, 19) === iso.slice(0, 19));
|
|
80
|
+
return { id: str(q.id), item: fullItem(q.item), price: int(q.price), expiresAt: new Date(epoch).toISOString() };
|
|
81
|
+
}
|
|
82
|
+
function safe(error: unknown, uncertain = false): Failure {
|
|
83
|
+
if (error instanceof Failure) return new Failure(error.code, error.status, uncertain || error.uncertain);
|
|
84
|
+
const e = error as { code?: string; status?: number } | null;
|
|
85
|
+
const status = Number.isInteger(e?.status) && e!.status! >= 400 && e!.status! <= 599 ? e!.status! : 0;
|
|
86
|
+
return new Failure(codes.has(e?.code ?? '') ? e!.code! : status ? 'request_rejected' : 'request_failed', status, uncertain);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** No credentials enter RPC DTOs. Native resolve owns serialized OAuth refresh. */
|
|
90
|
+
export function createVoyagesRpcHandlers(ctx: Port, options: { cloudClient?: CloudPort; baseUrl?: string; now?: () => number; profileRoot?: string; journal?: Journal } = {}) {
|
|
91
|
+
const baseUrl = (options.baseUrl ?? cloudControlUrl()).replace(/\/+$/, '');
|
|
92
|
+
const cloud: CloudPort = options.cloudClient ?? new CloudClient({ baseUrl });
|
|
93
|
+
const now = options.now ?? Date.now;
|
|
94
|
+
// Native plugin storage is not relied on for cross-workspace/profile recovery.
|
|
95
|
+
// This journal contains purchase terms/keys only, never authentication tokens.
|
|
96
|
+
const directory = join(runtimePaths({ root: options.profileRoot }).state, 'carthagent', 'voyages');
|
|
97
|
+
const journal = options.journal ?? fileJournal;
|
|
98
|
+
async function access() {
|
|
99
|
+
const active = await ctx.integration.connection.active('experiential-labs');
|
|
100
|
+
if (!active || active.type !== 'credential' || active.method !== 'oauth') throw new Failure('authentication_required', 401);
|
|
101
|
+
let credential;
|
|
102
|
+
try { credential = await ctx.integration.connection.resolve(active); } catch { throw new Failure('authentication_required', 401); }
|
|
103
|
+
if (credential?.type !== 'oauth' || credential.methodID !== 'carthagent-device' || !credential.access) throw new Failure('authentication_required', 401);
|
|
104
|
+
const scope = join(directory, createHash('sha256').update(JSON.stringify([baseUrl, active.id])).digest('hex'));
|
|
105
|
+
return { id: active.id, token: credential.access, scope };
|
|
106
|
+
}
|
|
107
|
+
type Access = Awaited<ReturnType<typeof access>>;
|
|
108
|
+
async function current(a: Access, signal: AbortSignal) {
|
|
109
|
+
signal.throwIfAborted();
|
|
110
|
+
const active = await ctx.integration.connection.active('experiential-labs');
|
|
111
|
+
if (!active || active.type !== 'credential' || active.id !== a.id || active.method !== 'oauth') throw new Failure('account_changed', 401);
|
|
112
|
+
}
|
|
113
|
+
async function request(a: Access, path: string, signal: AbortSignal, method = 'GET', body?: unknown) {
|
|
114
|
+
await current(a, signal);
|
|
115
|
+
const result = await cloud.request(`/v1/voyages/${path}`, { method, accessToken: a.token, signal, ...(body === undefined ? {} : { body }) });
|
|
116
|
+
await current(a, signal);
|
|
117
|
+
return obj(result);
|
|
118
|
+
}
|
|
119
|
+
async function read(a: Access): Promise<State> {
|
|
120
|
+
// Always read fresh after acquiring the profile-wide cross-process lock.
|
|
121
|
+
const raw = await journal.get(`${a.scope}.json`);
|
|
122
|
+
if (raw === undefined) return { quotes: [] };
|
|
123
|
+
const s = obj(raw); check(Array.isArray(s.quotes) && s.quotes.length <= 20);
|
|
124
|
+
const quotes = s.quotes.map(quote).filter(q => Date.parse(q.expiresAt) > now());
|
|
125
|
+
if (s.pending === undefined) return { quotes };
|
|
126
|
+
const p = obj(s.pending);
|
|
127
|
+
return { quotes, pending: { quoteId: str(p.quoteId), key: str(p.key), item: fullItem(p.item), price: int(p.price) } };
|
|
128
|
+
}
|
|
129
|
+
const write = (a: Access, state: State) => journal.set(`${a.scope}.json`, state);
|
|
130
|
+
function run<T>(fn: (input: Record<string, unknown>, call: Call, a: Access) => Promise<T>) {
|
|
131
|
+
return async (input: unknown, call: Call) => {
|
|
132
|
+
try {
|
|
133
|
+
call.signal.throwIfAborted();
|
|
134
|
+
const a = await access();
|
|
135
|
+
return await withFileLock(`${a.scope}.lock`, async () => { await current(a, call.signal); return fn(obj(input), call, a); });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const e = safe(error);
|
|
138
|
+
return call.error('voyagesError', e.code, { code: e.code, status: e.status, uncertain: e.uncertain });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function buy(a: Access, state: State, call: Call, replay: boolean) {
|
|
143
|
+
const pending = state.pending!;
|
|
144
|
+
try {
|
|
145
|
+
const r = await request(a, 'purchases', call.signal, 'POST', { quoteId: pending.quoteId, key: pending.key });
|
|
146
|
+
const receipt = { receiptId: str(r.receiptId), item: fullItem(r.item), price: int(r.price) };
|
|
147
|
+
check(receipt.item === pending.item && receipt.price === pending.price);
|
|
148
|
+
await write(a, { quotes: [] });
|
|
149
|
+
await current(a, call.signal);
|
|
150
|
+
return receipt;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const e = safe(error);
|
|
153
|
+
// Backend checks exact committed replay under the player lock BEFORE
|
|
154
|
+
// expiry/debit. Thus 409 quote_expired proves this pair never committed.
|
|
155
|
+
// Auth, key conflicts and all other replay errors remain uncertain.
|
|
156
|
+
if ((!replay && e.status >= 400 && e.status < 500 && noDebit.has(e.code)) || (replay && e.status === 409 && e.code === 'quote_expired')) {
|
|
157
|
+
try { await write(a, { quotes: [] }); } catch { throw new Failure('storage_failed', 0, true); }
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
throw safe(error, true);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function versioned(a: Access, path: string, input: Record<string, unknown>, call: Call, method: string) {
|
|
164
|
+
try { return { profile: profile((await request(a, path, call.signal, method, input)).profile) }; }
|
|
165
|
+
catch (error) {
|
|
166
|
+
const e = safe(error);
|
|
167
|
+
// Never retry a versioned write. Unknown outcomes require profile reconcile.
|
|
168
|
+
throw safe(error, e.status === 0 || e.status >= 500);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function selection(input: Record<string, unknown>) {
|
|
172
|
+
choice(input.slot, voyagesSlots);
|
|
173
|
+
key(input.item);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
snapshot: run(async (_input, call, a) => {
|
|
177
|
+
const bootstrap = await request(a, 'bootstrap', call.signal);
|
|
178
|
+
check(bootstrap.signedIn === true);
|
|
179
|
+
const p = profile(bootstrap.profile);
|
|
180
|
+
const shop = await request(a, 'shop', call.signal);
|
|
181
|
+
check(Array.isArray(shop.items) && shop.items.length <= 10000);
|
|
182
|
+
const owned = new Set(p.owned);
|
|
183
|
+
const items = shop.items.map(value => item(value, owned)); check(new Set(items.map(i => i.item)).size === items.length);
|
|
184
|
+
const state = await read(a); await current(a, call.signal);
|
|
185
|
+
const pending = state.pending;
|
|
186
|
+
return { profile: p, items, ...(pending ? { pending: { quoteId: pending.quoteId, item: pending.item, price: pending.price } } : {}) };
|
|
187
|
+
}),
|
|
188
|
+
customize: run(async (input, call, a) => versioned(a, 'profile', input, call, 'PATCH')),
|
|
189
|
+
equip: run(async (input, call, a) => { selection(input); return versioned(a, 'equip', input, call, 'POST'); }),
|
|
190
|
+
quote: run(async (input, call, a) => {
|
|
191
|
+
selection(input);
|
|
192
|
+
const state = await read(a);
|
|
193
|
+
if (state.pending) throw new Failure('purchase_pending', 0, true);
|
|
194
|
+
const q = quote((await request(a, 'quotes', call.signal, 'POST', input)).quote);
|
|
195
|
+
check(q.item === `${input.slot}:${input.item}` && Date.parse(q.expiresAt) > now() && Date.parse(q.expiresAt) <= now() + 125000);
|
|
196
|
+
await write(a, { quotes: [...state.quotes.filter(v => v.id !== q.id), q].slice(-20) });
|
|
197
|
+
await current(a, call.signal);
|
|
198
|
+
return { quote: q };
|
|
199
|
+
}),
|
|
200
|
+
purchase: run(async (input, call, a) => {
|
|
201
|
+
const state = await read(a);
|
|
202
|
+
if (state.pending) throw new Failure('purchase_pending', 0, true);
|
|
203
|
+
const q = state.quotes.find(q => q.id === input.quoteId);
|
|
204
|
+
if (!q) throw new Failure('quote_required');
|
|
205
|
+
await current(a, call.signal);
|
|
206
|
+
state.pending = { quoteId: q.id, key: randomUUID(), item: q.item, price: q.price };
|
|
207
|
+
// A single durable record contains both the authoritative terms and pair.
|
|
208
|
+
// Do not send anything if persistence fails or cancellation was observed.
|
|
209
|
+
await write(a, state);
|
|
210
|
+
return buy(a, state, call, false);
|
|
211
|
+
}),
|
|
212
|
+
retryPurchase: run(async (input, call, a) => {
|
|
213
|
+
const state = await read(a);
|
|
214
|
+
if (!state.pending) throw new Failure('no_pending_purchase');
|
|
215
|
+
if (input.quoteId !== state.pending.quoteId) throw new Failure('pending_changed', 0, true);
|
|
216
|
+
return buy(a, state, call, true);
|
|
217
|
+
}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function registerVoyagesRpc(ctx: Context) {
|
|
222
|
+
return ctx.rpc.register(Voyages, createVoyagesRpcHandlers(ctx));
|
|
223
|
+
}
|
|
@@ -46,8 +46,13 @@ export async function setupNativeTestPlugin(context: Parameters<typeof registerS
|
|
|
46
46
|
const registration = await context.rpc.register(Carthagent, {
|
|
47
47
|
health: async () => ({ protocol: 1, hostVersion: context.app.version, directory: context.location.directory }),
|
|
48
48
|
echo: async input => ({ text: (input as { text: string }).text }),
|
|
49
|
+
budgetStatus: async () => { throw new Error('Use TestDiagnostics.state for budgets in the isolated delivery test plugin.'); },
|
|
49
50
|
authStart: async () => { throw new Error('Device authorization is not available in the isolated delivery test plugin.'); },
|
|
50
51
|
authCancel: async () => ({ cancelled: false }),
|
|
52
|
+
shipAccount: async () => { throw new Error('Ship account access is not available in the isolated delivery test plugin.'); },
|
|
53
|
+
shipUsage: async () => { throw new Error('Ship usage access is not available in the isolated delivery test plugin.'); },
|
|
54
|
+
shipBilling: async () => { throw new Error('Ship billing is not available in the isolated delivery test plugin.'); },
|
|
55
|
+
shipLogout: async () => { throw new Error('Ship credential changes are not available in the isolated delivery test plugin.'); },
|
|
51
56
|
});
|
|
52
57
|
cleanups.push(async () => registration.dispose());
|
|
53
58
|
const diagnostics = await context.rpc.register(TestDiagnostics, {
|