carthagent 0.0.0-stage → 1.0.7
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/LICENSE +21 -0
- package/README.md +219 -2
- package/bin/ctg.mjs +736 -0
- package/docs/budgets.md +72 -0
- package/docs/compaction.md +34 -0
- package/docs/context-efficiency-plan.md +24 -0
- package/docs/context-efficiency-review.md +27 -0
- package/docs/context-efficiency.md +49 -0
- package/docs/deepseek-jev-benchmark-plan.md +70 -0
- package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
- package/docs/deepseek-jev-benchmark-restart.md +9 -0
- package/docs/deepseek-jev-benchmark-results.md +77 -0
- package/docs/deepseek-jev-benchmark-running.md +60 -0
- package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
- package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
- package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
- package/docs/deepseek-jev-http400-diagnosis.md +45 -0
- package/docs/delivery-review-evidence.md +53 -0
- package/docs/delivery-timeout-repair-plan.md +51 -0
- package/docs/evaluation-readiness.md +31 -0
- package/docs/evaluation.md +70 -0
- package/docs/extending.md +354 -0
- package/docs/glm-pilot-execution.md +9 -0
- package/docs/glm-pilot-plan.md +15 -0
- package/docs/glm-pilot-results.md +71 -0
- package/docs/graph-context-optimization.md +15 -0
- package/docs/jev-repository-pilot-plan.md +25 -0
- package/docs/jev-repository-pilot-protocol.md +39 -0
- package/docs/jev-repository-pilot-restart.md +7 -0
- package/docs/jev-repository-pilot-results.md +79 -0
- package/docs/jev.md +32 -0
- package/docs/model-registry.md +25 -0
- package/docs/native-compatibility.md +117 -0
- package/docs/opencode-archive.md +9 -0
- package/docs/opencode-migration.md +115 -0
- package/docs/opencode-runtime.md +94 -0
- package/docs/opencode-workspace.md +31 -0
- package/docs/owned-runtime-workflow-registration.md +17 -0
- package/docs/plan-advisor-evaluation.md +38 -0
- package/docs/planning-startup-fix-plan.md +23 -0
- package/docs/planning-startup-fix-review.md +24 -0
- package/docs/planning.md +118 -0
- package/docs/production-merge-review.md +23 -0
- package/docs/quality.md +35 -0
- package/docs/repository-benchmark-design.md +111 -0
- package/docs/review-assurance.md +9 -0
- package/docs/review-experiment-results.md +59 -0
- package/docs/tested-planning-roadmap.md +58 -0
- package/docs/tested-planning.md +58 -0
- package/docs/tui-pickers.md +9 -0
- package/docs/webdriver.md +83 -0
- package/docs/workflow-modes.md +26 -0
- package/docs/workflow.d2 +17 -0
- package/docs/workflow.svg +149 -0
- package/docs/workspace-coordination.md +9 -0
- package/evaluate.mjs +249 -0
- package/extensions/codegraph.ts +36 -0
- package/extensions/delivery.ts +819 -0
- package/extensions/experiential.ts +21 -0
- package/extensions/subscriptions.ts +21 -0
- package/guidance/profiles/authenticated-web.json +24 -0
- package/guidance/profiles/data-visualization.json +25 -0
- package/guidance/profiles/database-change.json +24 -0
- package/guidance/profiles/external-api.json +24 -0
- package/guidance/profiles/transactional-data.json +25 -0
- package/guidance/profiles/web-application.json +26 -0
- package/lib/acceptance-review.mjs +4 -0
- package/lib/browser-bin.mjs +160 -0
- package/lib/browser-errors.mjs +54 -0
- package/lib/browser.mjs +219 -0
- package/lib/budget.mjs +37 -0
- package/lib/cdp.mjs +221 -0
- package/lib/cloud/client.mjs +275 -0
- package/lib/cloud/control-plane.mjs +350 -0
- package/lib/cloud/gateway.mjs +380 -0
- package/lib/cloud/http.mjs +135 -0
- package/lib/cloud/ledger.mjs +215 -0
- package/lib/codegraph-query.mjs +60 -0
- package/lib/codegraph.mjs +378 -0
- package/lib/context-archive.mjs +18 -0
- package/lib/delivery-context.mjs +132 -0
- package/lib/delivery-guidance.mjs +9 -0
- package/lib/delivery-review.mjs +94 -0
- package/lib/delivery.mjs +610 -0
- package/lib/engine.mjs +137 -0
- package/lib/guidance.mjs +71 -0
- package/lib/jev-advisor.mjs +47 -0
- package/lib/opencode/archive.mjs +122 -0
- package/lib/opencode/compatibility.mjs +19 -0
- package/lib/opencode/credentials.mjs +205 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
- package/lib/opencode/evaluation.mjs +242 -0
- package/lib/opencode/file-lock.mjs +86 -0
- package/lib/opencode/headless.mjs +200 -0
- package/lib/opencode/review.mjs +213 -0
- package/lib/opencode/runtime.mjs +246 -0
- package/lib/opencode/service.mjs +249 -0
- package/lib/opencode/session-options.mjs +60 -0
- package/lib/opencode/workspace.mjs +133 -0
- package/lib/openrouter-jev.mjs +37 -0
- package/lib/outcome-contract.mjs +21 -0
- package/lib/plan-advisor.mjs +38 -0
- package/lib/planning-access.mjs +7 -0
- package/lib/planning-advice.mjs +28 -0
- package/lib/planning-discovery.mjs +14 -0
- package/lib/planning-guide.mjs +34 -0
- package/lib/planning-view.mjs +57 -0
- package/lib/planning.mjs +155 -0
- package/lib/probe-diagnostics.mjs +13 -0
- package/lib/providers/experiential.mjs +292 -0
- package/lib/providers/model-catalog.json +700 -0
- package/lib/providers/model-catalog.mjs +7 -0
- package/lib/providers/names.mjs +13 -0
- package/lib/providers/subscription-models.mjs +483 -0
- package/lib/reports.mjs +80 -0
- package/lib/review-state.mjs +49 -0
- package/lib/review.mjs +114 -0
- package/lib/scenarios.mjs +99 -0
- package/lib/ship-auth.mjs +44 -0
- package/lib/ship-jev.mjs +59 -0
- package/lib/ship-telemetry.mjs +183 -0
- package/lib/tui/ansi.mjs +255 -0
- package/lib/tui/app.mjs +2445 -0
- package/lib/tui/auth.mjs +249 -0
- package/lib/tui/carthage.mjs +109 -0
- package/lib/tui/clipboard.mjs +122 -0
- package/lib/tui/d2.mjs +431 -0
- package/lib/tui/feed.mjs +467 -0
- package/lib/tui/framing.mjs +80 -0
- package/lib/tui/glyphs.mjs +172 -0
- package/lib/tui/hash-worker.mjs +11 -0
- package/lib/tui/mock-engine.mjs +222 -0
- package/lib/tui/models.mjs +73 -0
- package/lib/tui/picker.mjs +18 -0
- package/lib/tui/theme.mjs +663 -0
- package/lib/tui/title.mjs +7 -0
- package/lib/update-channel.mjs +62 -0
- package/lib/update.mjs +230 -0
- package/lib/webdriver.mjs +82 -0
- package/lib/workflow.mjs +88 -0
- package/lib/workspace.mjs +39 -0
- package/metadata.json +8 -0
- package/package.json +94 -4
- package/plugins/carthagent/index.ts +3 -0
- package/plugins/carthagent/package.json +28 -0
- package/plugins/carthagent/src/archive-rpc.ts +46 -0
- package/plugins/carthagent/src/bootstrap.ts +38 -0
- package/plugins/carthagent/src/index.ts +62 -0
- package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
- package/plugins/carthagent/src/rpc.ts +76 -0
- package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
- package/plugins/carthagent/src/server/budgets.ts +21 -0
- package/plugins/carthagent/src/server/codegraph.ts +46 -0
- package/plugins/carthagent/src/server/delivery.ts +488 -0
- package/plugins/carthagent/src/server/device-auth.ts +91 -0
- package/plugins/carthagent/src/server/native-delivery.ts +26 -0
- package/plugins/carthagent/src/server/orchestration.ts +56 -0
- package/plugins/carthagent/src/server/policy.ts +24 -0
- package/plugins/carthagent/src/server/providers.ts +424 -0
- package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
- package/plugins/carthagent/src/server/session-controls.ts +82 -0
- package/plugins/carthagent/src/server/telemetry.ts +92 -0
- package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
- package/plugins/carthagent/src/session-rpc.ts +31 -0
- package/plugins/carthagent/src/testing.ts +85 -0
- package/plugins/carthagent/src/workspace-rpc.ts +133 -0
- package/plugins/carthagent/test-plugin/index.ts +4 -0
- package/plugins/carthagent/test-plugin/package.json +1 -0
- package/plugins/carthagent/tsconfig.json +19 -0
- package/plugins/carthagent/tui.ts +1 -0
- package/prompts/delivery.md +7 -0
- package/public/index.html +983 -0
- package/scenarios/cli/grade_cli.py +69 -0
- package/scenarios/cli/scenario.json +21 -0
- package/scenarios/game/baseline/minecraft.py +193 -0
- package/scenarios/game/context.md +1 -0
- package/scenarios/game/grade_minecraft_clone.py +603 -0
- package/scenarios/game/requirements.txt +2 -0
- package/scenarios/game/scenario.json +33 -0
- package/server.mjs +341 -0
- package/skills/game-development/SKILL.md +36 -0
- package/skills/game-development/assets/ursina_starter.py +98 -0
- package/skills/game-development/scripts/verify_ursina.py +80 -0
- package/skills/software-delivery/SKILL.md +50 -0
- package/skills/typescript-delivery/SKILL.md +69 -0
- package/tests/acceptance-review.test.mjs +13 -0
- package/tests/auth.test.mjs +234 -0
- package/tests/bounded-summary.test.mjs +68 -0
- package/tests/browser-bin.test.mjs +91 -0
- package/tests/browser.test.mjs +96 -0
- package/tests/budget-engine.test.mjs +110 -0
- package/tests/budget.test.mjs +23 -0
- package/tests/cli.test.mjs +245 -0
- package/tests/clipboard.test.mjs +79 -0
- package/tests/cloud-client.test.mjs +129 -0
- package/tests/cloud-control-plane.test.mjs +144 -0
- package/tests/cloud-gateway.test.mjs +147 -0
- package/tests/cloud-http.test.mjs +102 -0
- package/tests/cloud-ledger.test.mjs +75 -0
- package/tests/codegraph-query.test.mjs +66 -0
- package/tests/codegraph.test.mjs +142 -0
- package/tests/delivery-context.test.mjs +66 -0
- package/tests/delivery-review.test.mjs +139 -0
- package/tests/delivery.test.mjs +343 -0
- package/tests/engine-api.mjs +24 -0
- package/tests/experiential-extension.test.mjs +37 -0
- package/tests/experiential.test.mjs +300 -0
- package/tests/extension.test.mjs +921 -0
- package/tests/fixtures/archive-plugin/index.ts +10 -0
- package/tests/guidance.test.mjs +78 -0
- package/tests/model-registry.test.mjs +68 -0
- package/tests/models.test.mjs +70 -0
- package/tests/openai-idempotency.test.mjs +99 -0
- package/tests/opencode-archive-rpc.test.mjs +82 -0
- package/tests/opencode-delivery.test.mjs +231 -0
- package/tests/opencode-providers.test.mjs +414 -0
- package/tests/opencode-runtime.test.mjs +455 -0
- package/tests/opencode-session-controls.test.mjs +115 -0
- package/tests/opencode-session-options.test.mjs +26 -0
- package/tests/opencode-workspace.test.mjs +339 -0
- package/tests/outcome-contract.test.mjs +17 -0
- package/tests/picker.test.mjs +34 -0
- package/tests/plan-advisor.test.mjs +88 -0
- package/tests/plan-review-loop.test.mjs +53 -0
- package/tests/planning-advice.test.mjs +27 -0
- package/tests/planning-contract.test.mjs +65 -0
- package/tests/planning-startup.test.mjs +38 -0
- package/tests/planning-view.test.mjs +33 -0
- package/tests/planning.test.mjs +223 -0
- package/tests/probe-diagnostics.test.mjs +21 -0
- package/tests/pull-request-rpc.test.mjs +366 -0
- package/tests/review-state.test.mjs +32 -0
- package/tests/review.test.mjs +127 -0
- package/tests/server.test.mjs +252 -0
- package/tests/ship-jev.test.mjs +77 -0
- package/tests/ship-telemetry.test.mjs +333 -0
- package/tests/standard-workflow.test.mjs +28 -0
- package/tests/subscription-models.test.mjs +416 -0
- package/tests/subscriptions-extension.test.mjs +27 -0
- package/tests/terminal-title.test.mjs +13 -0
- package/tests/test_grade_minecraft_clone.py +199 -0
- package/tests/test_starter.py +78 -0
- package/tests/theme.test.mjs +175 -0
- package/tests/tui-copy-paste.test.mjs +54 -0
- package/tests/tui.test.mjs +808 -0
- package/tests/update-channels.test.mjs +129 -0
- package/tests/update.test.mjs +175 -0
- package/tests/webdriver.test.mjs +31 -0
- package/tests/workflow.test.mjs +47 -0
- package/tests/workspace-rpc.test.mjs +236 -0
- package/tests/workspace.test.mjs +24 -0
- package/tools/benchmark/advisor.test.mjs +7 -0
- package/tools/benchmark/advisor.ts +27 -0
- package/tools/benchmark/campaign.mjs +45 -0
- package/tools/benchmark/diagnose-request.mjs +40 -0
- package/tools/benchmark/eval-common.mjs +32 -0
- package/tools/benchmark/eval-ledger.mjs +22 -0
- package/tools/benchmark/eval-shop.mjs +26 -0
- package/tools/benchmark/eval-voxel.mjs +20 -0
- package/tools/benchmark/evaluate.mjs +24 -0
- package/tools/benchmark/evaluators.test.mjs +13 -0
- package/tools/benchmark/fixtures/shop/server.mjs +18 -0
- package/tools/benchmark/fixtures/shop/ui.html +8 -0
- package/tools/benchmark/freeze.mjs +30 -0
- package/tools/benchmark/gateway.mjs +91 -0
- package/tools/benchmark/gateway.test.mjs +38 -0
- package/tools/benchmark/generator.mjs +11 -0
- package/tools/benchmark/generator.test.mjs +26 -0
- package/tools/benchmark/guard.test.mjs +133 -0
- package/tools/benchmark/guard.ts +136 -0
- package/tools/benchmark/native-plugin/index.ts +24 -0
- package/tools/benchmark/planning-startup.test.mjs +47 -0
- package/tools/benchmark/preflight-live.mjs +31 -0
- package/tools/benchmark/preflight.mjs +22 -0
- package/tools/benchmark/replay-diagnostic.mjs +23 -0
- package/tools/benchmark/runner.mjs +141 -0
- package/tools/benchmark/runner.test.mjs +93 -0
- package/tools/benchmark/runtime.mjs +23 -0
- package/tools/benchmark/selftest.mjs +47 -0
- package/tools/benchmark/smoke-jev.mjs +5 -0
- package/tools/benchmark/tasks.mjs +20 -0
- package/tools/benchmark/transport.mjs +65 -0
- package/tools/benchmark/transport.test.mjs +44 -0
- package/tools/benchmark/upstream-error.mjs +18 -0
- package/tools/benchmark/upstream-error.test.mjs +10 -0
- package/tools/browser-check.mjs +109 -0
- package/tools/check-syntax.mjs +27 -0
- package/tools/evaluate-plan-advisor.mjs +20 -0
- package/tools/native-fixture.mjs +194 -0
- package/tools/native-pty-smoke.py +108 -0
- package/tools/native-report-plugin/index.ts +50 -0
- package/tools/native-report-plugin/package.json +7 -0
- package/tools/patch-compaction.mjs +24 -0
- package/tools/patch-model-catalog.mjs +12 -0
- package/tools/patch-print-shutdown.mjs +39 -0
- package/tools/repo-pilot/evaluator.cjs +32 -0
- package/tools/repo-pilot/reference.mjs +43 -0
- package/tools/repo-pilot/runner.mjs +85 -0
- package/tools/repo-pilot/sandbox-entry.mjs +12 -0
- package/tools/repo-pilot/tasks.mjs +20 -0
- package/tools/repo-pilot/type-evaluator.cjs +6 -0
- package/tools/test-native-controls.mjs +84 -0
- package/tools/test-native-delivery-host.mjs +307 -0
- package/tools/test-native-evaluation.mjs +46 -0
- package/tools/test-native-host.mjs +385 -0
- package/tools/test-native-outcomes.mjs +73 -0
- package/tools/test-native-package.mjs +70 -0
- package/tools/test-native-pty.mjs +20 -0
- package/tools/test-native-report.mjs +88 -0
- package/tools/test-native-swe-host.mjs +132 -0
- package/tools/test-native-swe.mjs +122 -0
- package/tools/test-picker-pty.py +45 -0
- package/tools/test-python.mjs +29 -0
- package/tools/test-webdriver.mjs +54 -0
- package/tools/verify-release-version.mjs +37 -0
- package/tools/webdriver-check.mjs +52 -0
- package/vendor/agent/bounded-summary.mjs +88 -0
- package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
- package/vendor/agent/chunks/anthropic.js +75 -0
- package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
- package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
- package/vendor/agent/chunks/chord-context.js +96 -0
- package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
- package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
- package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
- package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
- package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
- package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
- package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
- package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
- package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
- package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
- package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
- package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
- package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
- package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
- package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
- package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
- package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
- package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
- package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
- package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
- package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
- package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
- package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
- package/vendor/agent/chunks/github-copilot.js +2 -0
- package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
- package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
- package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
- package/vendor/agent/chunks/image-resize-worker.js +2 -0
- package/vendor/agent/chunks/kimi-coding.js +2 -0
- package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
- package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
- package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
- package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
- package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
- package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
- package/vendor/agent/chunks/openai-codex.js +75 -0
- package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
- package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
- package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
- package/vendor/agent/chunks/openrouter.js +75 -0
- package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
- package/vendor/agent/chunks/radius.js +75 -0
- package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
- package/vendor/agent/chunks/xai.js +2 -0
- package/vendor/agent/cli.js +3 -0
- package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/index.js +2 -0
- package/vendor/agent/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/package.json +14 -0
- package/vendor/agent/rpc-entry.js +3 -0
- package/vendor/agent/theme/dark.json +91 -0
- package/vendor/agent/theme/light.json +90 -0
- package/vendor/agent/theme/theme-schema.json +356 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createHmac } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { CloudLedger } from '../lib/cloud/ledger.mjs';
|
|
8
|
+
import { CLOUD_PLAN, CloudControlPlane, verifyStripeSignature } from '../lib/cloud/control-plane.mjs';
|
|
9
|
+
|
|
10
|
+
test('CLI session client-name migration upgrades an existing SQLite schema', () => {
|
|
11
|
+
const db = new DatabaseSync(':memory:');
|
|
12
|
+
db.exec(`CREATE TABLE cli_sessions (
|
|
13
|
+
id TEXT PRIMARY KEY, account_id TEXT NOT NULL, refresh_token_hash TEXT NOT NULL UNIQUE,
|
|
14
|
+
scopes TEXT NOT NULL, expires_at INTEGER NOT NULL, revoked_at TEXT, created_at TEXT NOT NULL, last_used_at TEXT
|
|
15
|
+
)`);
|
|
16
|
+
db.exec(readFileSync(fileURLToPath(new URL('../migrations/003_cloud_cli_session_client_name.sql', import.meta.url)), 'utf8'));
|
|
17
|
+
db.prepare(`INSERT INTO cli_sessions
|
|
18
|
+
(id, account_id, refresh_token_hash, scopes, expires_at, created_at)
|
|
19
|
+
VALUES ('session', 'account', 'hash', 'account:read', 1, '2025-01-01T00:00:00.000Z')`).run();
|
|
20
|
+
assert.equal(db.prepare('SELECT client_name FROM cli_sessions').get().client_name, 'Carthagent CLI');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function fixture(overrides = {}) {
|
|
24
|
+
let id = 0;
|
|
25
|
+
let random = 0;
|
|
26
|
+
const now = new Date('2026-01-01T00:00:00.000Z');
|
|
27
|
+
const ledger = new CloudLedger({ id: () => `ledger-${++id}`, clock: () => now });
|
|
28
|
+
const control = new CloudControlPlane({
|
|
29
|
+
ledger, clock: () => now, id: () => `control-${++id}`,
|
|
30
|
+
random: size => Buffer.alloc(size, ++random), tokenSecret: 'test-token-secret',
|
|
31
|
+
publicUrl: 'https://cloud.test', ...overrides,
|
|
32
|
+
});
|
|
33
|
+
return { control, ledger, now };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('device authorization approves once, grants trial once, and issues scoped rotating tokens', () => {
|
|
37
|
+
const { control, ledger } = fixture();
|
|
38
|
+
const account = control.createUserAccount({ email: 'user@example.com' });
|
|
39
|
+
const device = control.createDeviceAuthorization({ clientName: 'laptop' });
|
|
40
|
+
assert.equal(device.verificationUri, 'https://cloud.test/activate');
|
|
41
|
+
assert.throws(() => control.pollDeviceToken({ deviceCode: device.deviceCode }), /authorization_pending/);
|
|
42
|
+
assert.deepEqual(control.approveDeviceAuthorization({ userCode: device.userCode, accountId: account.accountId }), { approved: true, clientName: 'laptop' });
|
|
43
|
+
assert.equal(ledger.available(account.accountId), CLOUD_PLAN.trialNanoUsd);
|
|
44
|
+
const tokens = control.pollDeviceToken({ deviceCode: device.deviceCode });
|
|
45
|
+
const claims = control.verifyAccessToken(tokens.accessToken, 'gateway:invoke');
|
|
46
|
+
assert.equal(control.listSessions(account.accountId)[0].client_name, 'laptop');
|
|
47
|
+
assert.equal(control.listSessions(account.accountId)[0].last_used_at, control.nowIso());
|
|
48
|
+
assert.equal(claims.sub, account.accountId);
|
|
49
|
+
assert.throws(() => control.pollDeviceToken({ deviceCode: device.deviceCode }), /invalid_grant/);
|
|
50
|
+
const refreshed = control.refreshSession({ refreshToken: tokens.refreshToken });
|
|
51
|
+
assert.notEqual(refreshed.refreshToken, tokens.refreshToken);
|
|
52
|
+
assert.throws(() => control.refreshSession({ refreshToken: tokens.refreshToken }), /invalid_grant/);
|
|
53
|
+
control.revokeSession({ sessionId: claims.sid });
|
|
54
|
+
assert.throws(() => control.verifyAccessToken(refreshed.accessToken), /revoked_token/);
|
|
55
|
+
assert.equal(control.claimTrial(account.accountId).claimed, false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('device denial does not create a session or grant', () => {
|
|
59
|
+
const { control, ledger } = fixture();
|
|
60
|
+
const account = control.createUserAccount({ email: 'deny@example.com' });
|
|
61
|
+
const device = control.createDeviceAuthorization();
|
|
62
|
+
control.approveDeviceAuthorization({ userCode: device.userCode, accountId: account.accountId, approve: false });
|
|
63
|
+
assert.throws(() => control.pollDeviceToken({ deviceCode: device.deviceCode }), /access_denied/);
|
|
64
|
+
assert.equal(ledger.available(account.accountId), 0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('account details expose content-free grants, sessions, usage, and account-scoped revocation', () => {
|
|
68
|
+
const { control, ledger } = fixture();
|
|
69
|
+
const account = control.createUserAccount({ email: 'account@example.com' });
|
|
70
|
+
control.claimTrial(account.accountId);
|
|
71
|
+
const one = control.issueSession(account.accountId, undefined, 'laptop');
|
|
72
|
+
const two = control.issueSession(account.accountId, undefined, 'desktop');
|
|
73
|
+
const secondClaims = control.verifyAccessToken(two.accessToken);
|
|
74
|
+
ledger.appendEntry({ accountId: account.accountId, kind: 'charge', amountNanoUsd: -10, idempotencyKey: 'usage-1', gatewayRequestId: 'request-1', gatewayAttemptId: 'attempt-1', metadata: { alias: 'code', inputTokens: 2, outputTokens: 3, status: 'completed' } });
|
|
75
|
+
const details = control.account(account.accountId);
|
|
76
|
+
assert.equal(details.email, 'account@example.com');
|
|
77
|
+
assert.equal(details.grants[0].source, 'verified_trial');
|
|
78
|
+
assert.deepEqual(details.sessions.map(session => session.client_name).sort(), ['desktop', 'laptop']);
|
|
79
|
+
assert.deepEqual(control.usage(account.accountId), [{ createdAt: control.nowIso(), alias: 'code', inputTokens: 2, outputTokens: 3, status: 'completed', chargeNanoUsd: 10, gatewayRequestId: 'request-1', gatewayAttemptId: 'attempt-1' }]);
|
|
80
|
+
assert.deepEqual(control.revokeAccountSession({ accountId: account.accountId, sessionId: secondClaims.sid }), { revoked: true, sessionId: secondClaims.sid });
|
|
81
|
+
assert.throws(() => control.verifyAccessToken(two.accessToken), /revoked_token/);
|
|
82
|
+
assert.doesNotThrow(() => control.verifyAccessToken(one.accessToken));
|
|
83
|
+
assert.throws(() => control.revokeAccountSession({ accountId: 'other', sessionId: secondClaims.sid }), /session_not_found/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('Stripe signatures are verified with timestamp tolerance', () => {
|
|
87
|
+
const payload = '{"id":"evt_1"}';
|
|
88
|
+
const timestamp = 1_767_225_600;
|
|
89
|
+
const secret = 'whsec_test';
|
|
90
|
+
const digest = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
|
|
91
|
+
assert.equal(verifyStripeSignature(payload, `t=${timestamp},v1=${digest}`, secret, { now: timestamp * 1000 }), true);
|
|
92
|
+
assert.throws(() => verifyStripeSignature(payload, `t=${timestamp},v1=bad`, secret, { now: timestamp * 1000 }), /Invalid Stripe signature/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('authoritative invoice webhook grants Builder credit exactly once', () => {
|
|
96
|
+
const secret = 'whsec_test';
|
|
97
|
+
const { control, ledger } = fixture({ stripeWebhookSecret: secret, stripePriceId: 'price_builder' });
|
|
98
|
+
const account = control.createUserAccount({ email: 'builder@example.com' });
|
|
99
|
+
const event = {
|
|
100
|
+
id: 'evt_invoice_1', type: 'invoice.paid', data: { object: {
|
|
101
|
+
id: 'in_1', customer: 'cus_1', subscription: 'sub_1', period_start: 1_767_225_600,
|
|
102
|
+
period_end: 1_769_904_000, metadata: { accountId: account.accountId },
|
|
103
|
+
} },
|
|
104
|
+
};
|
|
105
|
+
const payload = JSON.stringify(event);
|
|
106
|
+
const timestamp = 1_767_225_600;
|
|
107
|
+
const signature = `t=${timestamp},v1=${createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex')}`;
|
|
108
|
+
assert.deepEqual(control.handleStripeWebhook({ payload, signature }), { duplicate: false, processed: true });
|
|
109
|
+
assert.deepEqual(control.handleStripeWebhook({ payload, signature }), { duplicate: true });
|
|
110
|
+
assert.equal(ledger.available(account.accountId), CLOUD_PLAN.builderMonthlyNanoUsd);
|
|
111
|
+
assert.equal(control.subscription(account.accountId).status, 'active');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('Checkout resolves plan selectors against the configured catalog', async () => {
|
|
115
|
+
const calls = [];
|
|
116
|
+
const stripe = {
|
|
117
|
+
createCheckoutSession: async input => { calls.push(input.line_items); return { id: 'cs_1', url: 'https://checkout.test' }; },
|
|
118
|
+
createPortalSession: async () => ({ id: 'bps_1', url: 'https://portal.test' }),
|
|
119
|
+
};
|
|
120
|
+
const { control } = fixture({ stripe, stripePriceId: 'price_builder', planCatalog: { pro: 'price_pro' } });
|
|
121
|
+
const account = control.createUserAccount({ email: 'tier@example.com' });
|
|
122
|
+
await control.createCheckout({ accountId: account.accountId, successUrl: 'https://app/s', cancelUrl: 'https://app/c' });
|
|
123
|
+
await control.createCheckout({ accountId: account.accountId, successUrl: 'https://app/s', cancelUrl: 'https://app/c', priceId: 'pro' });
|
|
124
|
+
await control.createCheckout({ accountId: account.accountId, successUrl: 'https://app/s', cancelUrl: 'https://app/c', priceId: 'price_pro' });
|
|
125
|
+
assert.deepEqual(calls, [[{ price: 'price_builder', quantity: 1 }], [{ price: 'price_pro', quantity: 1 }], [{ price: 'price_pro', quantity: 1 }]]);
|
|
126
|
+
await assert.rejects(control.createCheckout({ accountId: account.accountId, priceId: 'price_nope' }), /billing_not_configured/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('Checkout and portal use server-owned plan and customer references', async () => {
|
|
130
|
+
const calls = [];
|
|
131
|
+
const stripe = {
|
|
132
|
+
createCheckoutSession: async input => { calls.push(['checkout', input]); return { id: 'cs_1', url: 'https://checkout.test' }; },
|
|
133
|
+
createPortalSession: async input => { calls.push(['portal', input]); return { id: 'bps_1', url: 'https://portal.test' }; },
|
|
134
|
+
};
|
|
135
|
+
const { control } = fixture({ stripe, stripePriceId: 'price_server_owned' });
|
|
136
|
+
const account = control.createUserAccount({ email: 'pay@example.com' });
|
|
137
|
+
const checkout = await control.createCheckout({ accountId: account.accountId, successUrl: 'https://app/success', cancelUrl: 'https://app/cancel' });
|
|
138
|
+
assert.equal(checkout.url, 'https://checkout.test');
|
|
139
|
+
assert.equal(calls[0][1].line_items[0].price, 'price_server_owned');
|
|
140
|
+
control.upsertSubscription(account.accountId, { id: 'sub_1', customer: 'cus_1', status: 'active' });
|
|
141
|
+
const portal = await control.createPortal({ accountId: account.accountId, returnUrl: 'https://app/account' });
|
|
142
|
+
assert.equal(portal.url, 'https://portal.test');
|
|
143
|
+
assert.equal(calls[1][1].customer, 'cus_1');
|
|
144
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CloudLedger } from '../lib/cloud/ledger.mjs';
|
|
4
|
+
import { CloudControlPlane } from '../lib/cloud/control-plane.mjs';
|
|
5
|
+
import {
|
|
6
|
+
CloudGatewayAuthority, ExperientialGatewayClient, GatewayDispatchError,
|
|
7
|
+
boundedGatewayRequest, customerChargeNanoUsd, tokenChargeNanoUsd,
|
|
8
|
+
} from '../lib/cloud/gateway.mjs';
|
|
9
|
+
|
|
10
|
+
function fixture({ upstream, settlementSource } = {}) {
|
|
11
|
+
let id = 0;
|
|
12
|
+
const now = new Date('2026-01-01T00:00:00.000Z');
|
|
13
|
+
const ledger = new CloudLedger({ id: () => `ledger-${++id}`, clock: () => now });
|
|
14
|
+
const control = new CloudControlPlane({ ledger, id: () => `control-${++id}`, clock: () => now, tokenSecret: 'secret' });
|
|
15
|
+
const account = control.createUserAccount({ email: 'gateway@example.com' });
|
|
16
|
+
const gateway = new CloudGatewayAuthority({ control, ledger, id: () => `gateway-${++id}`, clock: () => now, upstream, settlementSource });
|
|
17
|
+
gateway.publishPricing({ id: 'price-v1', inputNanoUsdPerMillionTokens: 1_000_000, outputNanoUsdPerMillionTokens: 2_000_000, fixedRequestNanoUsd: 50 });
|
|
18
|
+
gateway.publishAlias({
|
|
19
|
+
alias: 'carthagent-code', displayName: 'Carthagent Code', upstreamAlias: 'experiential-code', pricingVersionId: 'price-v1', revisionId: 'code-v1',
|
|
20
|
+
maxInputTokens: 100_000, defaultMaxOutputTokens: 100, maxOutputTokens: 1_000, maximumAttempts: 2,
|
|
21
|
+
});
|
|
22
|
+
return { ledger, control, account, gateway };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const upstreamResponse = ({ requestId = 'exp-request-1', body = { usage: { prompt_tokens: 10, completion_tokens: 5 } }, status = 200 } = {}) => new Response(JSON.stringify(body), {
|
|
26
|
+
status,
|
|
27
|
+
headers: { 'content-type': 'application/json', 'x-request-id': requestId },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('pricing rounds each token leg up in integer nano-USD', () => {
|
|
31
|
+
assert.equal(tokenChargeNanoUsd(1, 1), 1);
|
|
32
|
+
assert.equal(tokenChargeNanoUsd(1_000_001, 1_000_000), 1_000_001);
|
|
33
|
+
assert.equal(customerChargeNanoUsd({ input_nano_usd_per_million_tokens: 1_000_000, output_nano_usd_per_million_tokens: 2_000_000, fixed_request_nano_usd: 50 }, [{ inputTokens: 10, outputTokens: 5 }]), 70);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('request bounding applies a server output cap and rewrites only the model alias', () => {
|
|
37
|
+
const bounded = boundedGatewayRequest('chat.completions', { model: 'carthagent-code', messages: [{ role: 'user', content: 'hello' }] }, {
|
|
38
|
+
alias: 'carthagent-code', upstream_alias: 'experiential-code', max_input_tokens: 10_000,
|
|
39
|
+
default_max_output_tokens: 128, max_output_tokens: 256,
|
|
40
|
+
});
|
|
41
|
+
assert.equal(bounded.request.model, 'experiential-code');
|
|
42
|
+
assert.equal(bounded.request.max_tokens, 128);
|
|
43
|
+
assert.throws(() => boundedGatewayRequest('responses', { model: 'carthagent-code', input: 'hello', max_output_tokens: 300 }, {
|
|
44
|
+
alias: 'carthagent-code', upstream_alias: 'experiential-code', max_input_tokens: 10_000,
|
|
45
|
+
default_max_output_tokens: 128, max_output_tokens: 256,
|
|
46
|
+
}), /max_output_tokens_exceeded/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('aliases and prices are immutable versions while the current revision may advance', () => {
|
|
50
|
+
const { gateway, ledger } = fixture();
|
|
51
|
+
assert.deepEqual(gateway.models(), [{ id: 'carthagent-code', name: 'Carthagent Code' }]);
|
|
52
|
+
assert.throws(() => gateway.publishPricing({ id: 'price-v1', inputNanoUsdPerMillionTokens: 2, outputNanoUsdPerMillionTokens: 2 }), /pricing_version_immutable_conflict/);
|
|
53
|
+
gateway.publishPricing({ id: 'price-v2', inputNanoUsdPerMillionTokens: 2_000_000, outputNanoUsdPerMillionTokens: 3_000_000 });
|
|
54
|
+
gateway.publishAlias({
|
|
55
|
+
alias: 'carthagent-code', displayName: 'Carthagent Code', upstreamAlias: 'experiential-code-v2', pricingVersionId: 'price-v2', revisionId: 'code-v2',
|
|
56
|
+
maxInputTokens: 100_000, defaultMaxOutputTokens: 100, maxOutputTokens: 1_000,
|
|
57
|
+
});
|
|
58
|
+
assert.equal(gateway.resolveAlias('carthagent-code').id, 'code-v2');
|
|
59
|
+
assert.equal(gateway.db.prepare('SELECT COUNT(*) AS count FROM cloud_model_alias_revisions').get().count, 2);
|
|
60
|
+
ledger.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('authority reserves before dispatch and settles only from authoritative request and attempt facts', async () => {
|
|
64
|
+
const events = [];
|
|
65
|
+
const upstream = { dispatch: async input => { events.push(['dispatch', input.body.model]); return upstreamResponse(); } };
|
|
66
|
+
const settlementSource = { resolve: async () => ({
|
|
67
|
+
status: 'terminal', requestId: 'exp-request-1', terminalState: 'completed', attempts: [
|
|
68
|
+
{ attemptId: 'exp-attempt-1', state: 'failed', inputTokens: 100, outputTokens: 0 },
|
|
69
|
+
{ attemptId: 'exp-attempt-2', state: 'completed', inputTokens: 100, outputTokens: 50 },
|
|
70
|
+
],
|
|
71
|
+
}) };
|
|
72
|
+
const { gateway, ledger, account } = fixture({ upstream, settlementSource });
|
|
73
|
+
ledger.grant({ accountId: account.accountId, amountNanoUsd: 10_000_000, idempotencyKey: 'grant' });
|
|
74
|
+
const before = ledger.available(account.accountId);
|
|
75
|
+
const handle = await gateway.invoke({ accountId: account.accountId, endpoint: 'chat.completions', operationKey: 'op-1', body: { model: 'carthagent-code', messages: [{ role: 'user', content: 'hello' }], max_tokens: 100 } });
|
|
76
|
+
assert.equal(events.length, 1);
|
|
77
|
+
assert.ok(ledger.available(account.accountId) < before);
|
|
78
|
+
assert.equal(handle.revision.id, 'code-v1');
|
|
79
|
+
const settled = await gateway.finalize(handle, { responseBody: Buffer.from(await handle.response.text()) });
|
|
80
|
+
assert.equal(settled.state, 'settled');
|
|
81
|
+
assert.equal(settled.gateway_request_id, 'exp-request-1');
|
|
82
|
+
assert.equal(settled.attempts.length, 2);
|
|
83
|
+
assert.equal(settled.customer_charge_nano_usd, 350);
|
|
84
|
+
assert.equal(ledger.entries(account.accountId).find(entry => entry.kind === 'charge').gateway_attempt_id, 'exp-attempt-2');
|
|
85
|
+
ledger.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('known pre-dispatch failures release the full reservation', async () => {
|
|
89
|
+
const upstream = { dispatch: async () => { throw new GatewayDispatchError('offline', { outcome: 'not_dispatched' }); } };
|
|
90
|
+
const { gateway, ledger, account } = fixture({ upstream });
|
|
91
|
+
ledger.grant({ accountId: account.accountId, amountNanoUsd: 1_000_000, idempotencyKey: 'grant' });
|
|
92
|
+
const before = ledger.available(account.accountId);
|
|
93
|
+
await assert.rejects(gateway.invoke({ accountId: account.accountId, endpoint: 'responses', operationKey: 'known-failure', body: { model: 'carthagent-code', input: 'hello' } }), /offline/);
|
|
94
|
+
assert.equal(ledger.available(account.accountId), before);
|
|
95
|
+
assert.equal(gateway.db.prepare('SELECT state FROM cloud_gateway_requests WHERE operation_key = ?').get('known-failure').state, 'released_not_dispatched');
|
|
96
|
+
ledger.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('unknown transport and unknown settlement outcomes fail closed with credit held', async () => {
|
|
100
|
+
const upstream = { dispatch: async () => { throw new GatewayDispatchError('timeout', { outcome: 'unknown' }); } };
|
|
101
|
+
const { gateway, ledger, account } = fixture({ upstream });
|
|
102
|
+
ledger.grant({ accountId: account.accountId, amountNanoUsd: 1_000_000, idempotencyKey: 'grant' });
|
|
103
|
+
const before = ledger.available(account.accountId);
|
|
104
|
+
await assert.rejects(gateway.invoke({ accountId: account.accountId, endpoint: 'responses', operationKey: 'ambiguous', body: { model: 'carthagent-code', input: 'hello' } }), /timeout/);
|
|
105
|
+
assert.ok(ledger.available(account.accountId) < before);
|
|
106
|
+
assert.equal(gateway.db.prepare('SELECT state FROM cloud_gateway_requests WHERE operation_key = ?').get('ambiguous').state, 'reconciliation_required');
|
|
107
|
+
assert.equal(ledger.entries(account.accountId).some(entry => entry.kind === 'reservation_release'), false);
|
|
108
|
+
ledger.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('missing authoritative attempt identity leaves a successful HTTP result in reconciliation', async () => {
|
|
112
|
+
const upstream = { dispatch: async () => upstreamResponse() };
|
|
113
|
+
const settlementSource = { resolve: async () => ({ status: 'pending' }) };
|
|
114
|
+
const { gateway, ledger, account } = fixture({ upstream, settlementSource });
|
|
115
|
+
ledger.grant({ accountId: account.accountId, amountNanoUsd: 1_000_000, idempotencyKey: 'grant' });
|
|
116
|
+
const handle = await gateway.invoke({ accountId: account.accountId, endpoint: 'responses', operationKey: 'pending', body: { model: 'carthagent-code', input: 'hello' } });
|
|
117
|
+
const result = await gateway.finalize(handle, { responseBody: Buffer.from(await handle.response.text()) });
|
|
118
|
+
assert.equal(result.state, 'reconciliation_required');
|
|
119
|
+
assert.equal(ledger.entries(account.accountId).some(entry => entry.kind === 'charge'), false);
|
|
120
|
+
ledger.close();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a later reconciliation settles against the request frozen pricing revision', async () => {
|
|
124
|
+
const upstream = { dispatch: async () => upstreamResponse() };
|
|
125
|
+
const settlementSource = { resolve: async () => ({ status: 'pending' }) };
|
|
126
|
+
const { gateway, ledger, account } = fixture({ upstream, settlementSource });
|
|
127
|
+
ledger.grant({ accountId: account.accountId, amountNanoUsd: 1_000_000, idempotencyKey: 'grant' });
|
|
128
|
+
const handle = await gateway.invoke({ accountId: account.accountId, endpoint: 'responses', operationKey: 'late', body: { model: 'carthagent-code', input: 'hello', max_output_tokens: 10 } });
|
|
129
|
+
await gateway.finalize(handle, { responseBody: Buffer.from(await handle.response.text()) });
|
|
130
|
+
gateway.publishPricing({ id: 'price-v2', inputNanoUsdPerMillionTokens: 9_000_000, outputNanoUsdPerMillionTokens: 9_000_000 });
|
|
131
|
+
gateway.publishAlias({ alias: 'carthagent-code', displayName: 'Carthagent Code', upstreamAlias: 'new-upstream', pricingVersionId: 'price-v2', revisionId: 'code-v2', maxInputTokens: 100_000, defaultMaxOutputTokens: 100, maxOutputTokens: 1_000 });
|
|
132
|
+
const reconciled = await gateway.reconcile(handle.id, { status: 'terminal', requestId: 'exp-request-1', terminalState: 'completed', attempts: [{ attemptId: 'late-attempt', state: 'completed', inputTokens: 10, outputTokens: 5 }] });
|
|
133
|
+
assert.equal(reconciled.state, 'settled');
|
|
134
|
+
assert.equal(reconciled.pricing_version_id, 'price-v1');
|
|
135
|
+
assert.equal(reconciled.customer_charge_nano_usd, 70);
|
|
136
|
+
ledger.close();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('Experiential client authenticates and sends stable gateway correlation headers', async () => {
|
|
140
|
+
let seen;
|
|
141
|
+
const client = new ExperientialGatewayClient({ baseUrl: 'https://gateway.test/v1/', apiKey: 'test-key', fetchImpl: async (url, options) => { seen = { url, options }; return upstreamResponse(); } });
|
|
142
|
+
await client.dispatch({ endpoint: 'chat.completions', body: { model: 'alias' }, operationKey: 'operation-1', clientRequestId: 'client-1' });
|
|
143
|
+
assert.equal(seen.url, 'https://gateway.test/v1/chat/completions');
|
|
144
|
+
assert.equal(seen.options.headers.authorization, 'Bearer test-key');
|
|
145
|
+
assert.equal(seen.options.headers['idempotency-key'], 'operation-1');
|
|
146
|
+
assert.equal(seen.options.headers['x-client-request-id'], 'client-1');
|
|
147
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { CloudLedger } from '../lib/cloud/ledger.mjs';
|
|
5
|
+
import { CloudControlPlane } from '../lib/cloud/control-plane.mjs';
|
|
6
|
+
import { cloudRouter } from '../lib/cloud/http.mjs';
|
|
7
|
+
import { CloudGatewayAuthority } from '../lib/cloud/gateway.mjs';
|
|
8
|
+
|
|
9
|
+
async function fixture(t, { withGateway = false } = {}) {
|
|
10
|
+
let id = 0;
|
|
11
|
+
const now = new Date('2026-01-01T00:00:00.000Z');
|
|
12
|
+
const ledger = new CloudLedger({ id: () => `l-${++id}`, clock: () => now });
|
|
13
|
+
const control = new CloudControlPlane({ ledger, id: () => `c-${++id}`, clock: () => now, random: size => Buffer.alloc(size, 7), tokenSecret: 'secret', publicUrl: 'https://cloud.test' });
|
|
14
|
+
const account = control.createUserAccount({ email: 'api@example.com' });
|
|
15
|
+
let gateway = null;
|
|
16
|
+
if (withGateway) {
|
|
17
|
+
gateway = new CloudGatewayAuthority({
|
|
18
|
+
control, ledger, id: () => `g-${++id}`, clock: () => now,
|
|
19
|
+
upstream: { dispatch: async () => new Response(JSON.stringify({ usage: { prompt_tokens: 12, completion_tokens: 3 } }), { status: 200, headers: { 'content-type': 'application/json', 'x-request-id': 'exp-http-request' } }) },
|
|
20
|
+
settlementSource: { resolve: async () => ({ status: 'terminal', requestId: 'exp-http-request', terminalState: 'completed', attempts: [{ attemptId: 'exp-http-attempt', state: 'completed', inputTokens: 12, outputTokens: 3 }] }) },
|
|
21
|
+
});
|
|
22
|
+
gateway.publishPricing({ id: 'http-price', inputNanoUsdPerMillionTokens: 1_000_000, outputNanoUsdPerMillionTokens: 2_000_000 });
|
|
23
|
+
gateway.publishAlias({ alias: 'carthagent-code', displayName: 'Carthagent Code', upstreamAlias: 'upstream-code', pricingVersionId: 'http-price', revisionId: 'http-revision', maxInputTokens: 100_000, defaultMaxOutputTokens: 100, maxOutputTokens: 1_000 });
|
|
24
|
+
}
|
|
25
|
+
const app = express();
|
|
26
|
+
app.use(cloudRouter(control, { gateway, authenticateBrowser: async req => req.get('x-test-account') === account.accountId ? account : null }));
|
|
27
|
+
const server = app.listen(0, '127.0.0.1');
|
|
28
|
+
await new Promise(resolve => server.once('listening', resolve));
|
|
29
|
+
t.after(() => { server.close(); ledger.close(); });
|
|
30
|
+
return { base: `http://127.0.0.1:${server.address().port}`, account, control, gateway, ledger };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const json = body => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
34
|
+
|
|
35
|
+
test('HTTP device flow returns OAuth-style pending, token, account, and revoke responses', async t => {
|
|
36
|
+
const { base, account } = await fixture(t);
|
|
37
|
+
const device = await (await fetch(`${base}/v1/device/authorizations`, json({ clientName: 'test cli' }))).json();
|
|
38
|
+
const pending = await fetch(`${base}/v1/device/token`, json({ deviceCode: device.deviceCode }));
|
|
39
|
+
assert.equal(pending.status, 428);
|
|
40
|
+
const approved = await fetch(`${base}/v1/device/authorizations/${device.userCode}/approve`, {
|
|
41
|
+
...json({ approve: true }), headers: { ...json({}).headers, 'x-test-account': account.accountId },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(approved.status, 200);
|
|
44
|
+
const tokens = await (await fetch(`${base}/v1/device/token`, json({ deviceCode: device.deviceCode }))).json();
|
|
45
|
+
const accountResponse = await fetch(`${base}/v1/account`, { headers: { authorization: `Bearer ${tokens.accessToken}` } });
|
|
46
|
+
assert.equal(accountResponse.status, 200);
|
|
47
|
+
const accountBody = await accountResponse.json();
|
|
48
|
+
assert.equal(accountBody.balance.availableNanoUsd, 1_000_000_000);
|
|
49
|
+
assert.equal(accountBody.email, 'api@example.com');
|
|
50
|
+
assert.equal(accountBody.grants[0].source, 'verified_trial');
|
|
51
|
+
const balance = await (await fetch(`${base}/v1/account/balance`, { headers: { authorization: `Bearer ${tokens.accessToken}` } })).json();
|
|
52
|
+
assert.equal(balance.grants[0].remainingNanoUsd, 1_000_000_000);
|
|
53
|
+
const sessions = await (await fetch(`${base}/v1/account/sessions`, { headers: { authorization: `Bearer ${tokens.accessToken}` } })).json();
|
|
54
|
+
assert.equal(sessions.currentSessionId, sessions.data[0].id);
|
|
55
|
+
assert.equal(sessions.data[0].client_name, 'test cli');
|
|
56
|
+
assert.equal((await fetch(`${base}/v1/token/revoke`, { method: 'POST', headers: { authorization: `Bearer ${tokens.accessToken}` } })).status, 200);
|
|
57
|
+
assert.equal((await fetch(`${base}/v1/account`, { headers: { authorization: `Bearer ${tokens.accessToken}` } })).status, 401);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('HTTP gateway exposes authenticated aliases and proxies a settled OpenAI-compatible request', async t => {
|
|
61
|
+
const { base, account, control, ledger } = await fixture(t, { withGateway: true });
|
|
62
|
+
ledger.grant({ accountId: account.accountId, amountNanoUsd: 1_000_000, idempotencyKey: 'http-credit' });
|
|
63
|
+
const tokens = control.issueSession(account.accountId);
|
|
64
|
+
const headers = { authorization: `Bearer ${tokens.accessToken}`, 'content-type': 'application/json', 'idempotency-key': 'http-operation' };
|
|
65
|
+
const modelsResponse = await fetch(`${base}/v1/models`, { headers });
|
|
66
|
+
assert.equal(modelsResponse.status, 200);
|
|
67
|
+
assert.deepEqual((await modelsResponse.json()).data.map(model => model.id), ['carthagent-code']);
|
|
68
|
+
const response = await fetch(`${base}/v1/chat/completions`, { method: 'POST', headers, body: JSON.stringify({ model: 'carthagent-code', messages: [{ role: 'user', content: 'hello' }], max_tokens: 10 }) });
|
|
69
|
+
assert.equal(response.status, 200);
|
|
70
|
+
assert.equal(response.headers.get('x-request-id'), 'exp-http-request');
|
|
71
|
+
assert.deepEqual(await response.json(), { usage: { prompt_tokens: 12, completion_tokens: 3 } });
|
|
72
|
+
const usage = await (await fetch(`${base}/v1/account/usage`, { headers })).json();
|
|
73
|
+
assert.deepEqual(usage.data.map(item => ({ alias: item.alias, inputTokens: item.inputTokens, outputTokens: item.outputTokens })), [{ alias: 'carthagent-code', inputTokens: 12, outputTokens: 3 }]);
|
|
74
|
+
const request = ledger.db.prepare('SELECT * FROM cloud_gateway_requests WHERE operation_key = ?').get('http-operation');
|
|
75
|
+
assert.equal(request.state, 'settled');
|
|
76
|
+
assert.equal(request.gateway_request_id, 'exp-http-request');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('HTTP account sessions can be listed and revoked only by their account', async t => {
|
|
80
|
+
const { base, account, control } = await fixture(t);
|
|
81
|
+
const current = control.issueSession(account.accountId, undefined, 'current');
|
|
82
|
+
control.random = size => Buffer.alloc(size, 8);
|
|
83
|
+
const other = control.issueSession(account.accountId, undefined, 'other device');
|
|
84
|
+
const otherClaims = control.verifyAccessToken(other.accessToken);
|
|
85
|
+
const headers = { authorization: `Bearer ${current.accessToken}` };
|
|
86
|
+
const response = await fetch(`${base}/v1/account/sessions/${otherClaims.sid}`, { method: 'DELETE', headers });
|
|
87
|
+
assert.equal(response.status, 200);
|
|
88
|
+
assert.throws(() => control.verifyAccessToken(other.accessToken), /revoked_token/);
|
|
89
|
+
const missing = await fetch(`${base}/v1/account/sessions/not-found`, { method: 'DELETE', headers });
|
|
90
|
+
assert.equal(missing.status, 404);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('HTTP gateway rejects exhausted credit before upstream dispatch', async t => {
|
|
94
|
+
const { base, account, control } = await fixture(t, { withGateway: true });
|
|
95
|
+
const tokens = control.issueSession(account.accountId);
|
|
96
|
+
const response = await fetch(`${base}/v1/responses`, {
|
|
97
|
+
method: 'POST', headers: { authorization: `Bearer ${tokens.accessToken}`, 'content-type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({ model: 'carthagent-code', input: 'hello' }),
|
|
99
|
+
});
|
|
100
|
+
assert.equal(response.status, 429);
|
|
101
|
+
assert.equal((await response.json()).error.code, 'insufficient_quota');
|
|
102
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CloudLedger, nanoUsd, validateFinancialMetadata } from '../lib/cloud/ledger.mjs';
|
|
4
|
+
|
|
5
|
+
const NANO = 1_000_000_000;
|
|
6
|
+
function ledger() {
|
|
7
|
+
let next = 0;
|
|
8
|
+
return new CloudLedger({ id: () => `id-${++next}`, clock: () => new Date('2026-01-01T00:00:00.000Z') });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test('ledger uses safe integer nano-USD and rejects customer-content metadata', () => {
|
|
12
|
+
assert.equal(nanoUsd(NANO), NANO);
|
|
13
|
+
assert.throws(() => nanoUsd(1.2), /safe integer/);
|
|
14
|
+
assert.throws(() => validateFinancialMetadata({ prompt: 'secret' }), /customer content/);
|
|
15
|
+
assert.throws(() => validateFinancialMetadata({ toolArguments: 'secret' }), /customer content/);
|
|
16
|
+
assert.deepEqual(validateFinancialMetadata({ alias: 'carthagent-fast', inputTokens: 12, success: true }), { alias: 'carthagent-fast', inputTokens: 12, success: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('grant and reservation are idempotent and reserve before dispatch', () => {
|
|
20
|
+
const store = ledger();
|
|
21
|
+
store.grant({ accountId: 'acct', amountNanoUsd: 10 * NANO, idempotencyKey: 'grant:1', source: 'trial' });
|
|
22
|
+
store.grant({ accountId: 'acct', amountNanoUsd: 10 * NANO, idempotencyKey: 'grant:1', source: 'trial' });
|
|
23
|
+
assert.equal(store.available('acct'), 10 * NANO);
|
|
24
|
+
const reservation = store.reserve({ accountId: 'acct', maximumNanoUsd: 3 * NANO, idempotencyKey: 'request:1' });
|
|
25
|
+
assert.equal(store.reserve({ accountId: 'acct', maximumNanoUsd: 3 * NANO, idempotencyKey: 'request:1' }).id, reservation.id);
|
|
26
|
+
assert.equal(store.available('acct'), 7 * NANO);
|
|
27
|
+
assert.throws(() => store.reserve({ accountId: 'acct', maximumNanoUsd: 8 * NANO, idempotencyKey: 'request:2' }), /insufficient_credit/);
|
|
28
|
+
store.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('settlement charges measured usage and releases unused reservation', () => {
|
|
32
|
+
const store = ledger();
|
|
33
|
+
store.grant({ accountId: 'acct', amountNanoUsd: 10 * NANO, idempotencyKey: 'grant:1' });
|
|
34
|
+
const reservation = store.reserve({ accountId: 'acct', maximumNanoUsd: 4 * NANO, idempotencyKey: 'reserve:1' });
|
|
35
|
+
const charge = store.settle({
|
|
36
|
+
reservationId: reservation.id,
|
|
37
|
+
actualNanoUsd: 1_500_000_000,
|
|
38
|
+
requestId: 'xpl-request-1',
|
|
39
|
+
attemptId: 'xpl-attempt-1',
|
|
40
|
+
idempotencyKey: 'settle:1',
|
|
41
|
+
metadata: { alias: 'carthagent-code', inputTokens: 100, outputTokens: 20, status: 'ok' },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(charge.gateway_request_id, 'xpl-request-1');
|
|
44
|
+
assert.equal(store.available('acct'), 8_500_000_000);
|
|
45
|
+
const entries = store.entries('acct');
|
|
46
|
+
assert.deepEqual(entries.map(entry => entry.kind), ['credit_grant', 'reservation', 'charge', 'reservation_release']);
|
|
47
|
+
assert.equal(entries.find(entry => entry.kind === 'charge').metadata.alias, 'carthagent-code');
|
|
48
|
+
assert.equal(JSON.stringify(entries).includes('prompt'), false);
|
|
49
|
+
assert.equal(store.settle({ reservationId: reservation.id, actualNanoUsd: 1_500_000_000, requestId: 'xpl-request-1', idempotencyKey: 'settle:1' }).id, charge.id);
|
|
50
|
+
store.close();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('failed dispatch releases the complete reservation', () => {
|
|
54
|
+
const store = ledger();
|
|
55
|
+
store.grant({ accountId: 'acct', amountNanoUsd: 2 * NANO, idempotencyKey: 'grant:1' });
|
|
56
|
+
const reservation = store.reserve({ accountId: 'acct', maximumNanoUsd: NANO, idempotencyKey: 'reserve:1' });
|
|
57
|
+
const release = store.release({ reservationId: reservation.id, idempotencyKey: 'release:1' });
|
|
58
|
+
assert.equal(release.amount_nano_usd, NANO);
|
|
59
|
+
assert.equal(store.available('acct'), 2 * NANO);
|
|
60
|
+
assert.equal(store.release({ reservationId: reservation.id, idempotencyKey: 'release:1' }).id, release.id);
|
|
61
|
+
store.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('earliest-expiring grants are consumed first', () => {
|
|
65
|
+
let now = new Date('2026-01-01T00:00:00.000Z');
|
|
66
|
+
let next = 0;
|
|
67
|
+
const store = new CloudLedger({ id: () => `id-${++next}`, clock: () => now });
|
|
68
|
+
store.grant({ accountId: 'acct', amountNanoUsd: NANO, idempotencyKey: 'later', expiresAt: '2026-03-01T00:00:00.000Z' });
|
|
69
|
+
store.grant({ accountId: 'acct', amountNanoUsd: NANO, idempotencyKey: 'sooner', expiresAt: '2026-02-01T00:00:00.000Z' });
|
|
70
|
+
const reservation = store.reserve({ accountId: 'acct', maximumNanoUsd: 1_500_000_000, idempotencyKey: 'reserve' });
|
|
71
|
+
store.settle({ reservationId: reservation.id, actualNanoUsd: 1_500_000_000, requestId: 'request', idempotencyKey: 'settle' });
|
|
72
|
+
now = new Date('2026-02-15T00:00:00.000Z');
|
|
73
|
+
assert.equal(store.available('acct'), 500_000_000);
|
|
74
|
+
store.close();
|
|
75
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createCodeIndex } from '../lib/codegraph.mjs';
|
|
7
|
+
import { queryCodeIndex, boundedCodeNavResult } from '../lib/codegraph-query.mjs';
|
|
8
|
+
import { buildEngineArgs } from '../lib/engine.mjs';
|
|
9
|
+
import { needsImplementation } from '../lib/planning-access.mjs';
|
|
10
|
+
|
|
11
|
+
test('navigation is allowed before approval but unknown tools stay locked; scan flag forwards', () => {
|
|
12
|
+
assert.equal(needsImplementation('code_nav'), false);
|
|
13
|
+
assert.equal(needsImplementation('untrusted_graph'), true);
|
|
14
|
+
const args = buildEngineArgs({ codegraphMaxFiles: '123' });
|
|
15
|
+
assert.equal(args[args.indexOf('--codegraph-max-files') + 1], '123');
|
|
16
|
+
for (const value of ['-1', '0', '1.2', 'x']) assert.throws(() => buildEngineArgs({ codegraphMaxFiles: value }), /positive/);
|
|
17
|
+
});
|
|
18
|
+
test('graph pages enumerate all candidates; filters disambiguate call-site scope', async t => {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), 'graph-pages-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
20
|
+
writeFileSync(join(dir, 'a.ts'), 'class A { run() { save(); save(); } }');
|
|
21
|
+
writeFileSync(join(dir, 'b.ts'), 'class B { run() { save(); } }');
|
|
22
|
+
const index = await createCodeIndex(dir);
|
|
23
|
+
const first = queryCodeIndex(index, { op: 'callers', name: 'save', limit: 1 });
|
|
24
|
+
assert.equal(first.total, 3); assert.equal(first.truncated, true); assert.equal(first.nextOffset, 1);
|
|
25
|
+
assert.match(first.semantics, /not resolved/);
|
|
26
|
+
const rest = queryCodeIndex(index, { op: 'callers', name: 'save', offset: 1, limit: 2 });
|
|
27
|
+
assert.equal(rest.returned, 2); assert.equal(rest.nextOffset, null);
|
|
28
|
+
const scoped = queryCodeIndex(index, { op: 'callers', name: 'save', path: 'b.ts', container: 'B' });
|
|
29
|
+
assert.equal(scoped.total, 1); assert.equal(scoped.results[0].file, 'b.ts');
|
|
30
|
+
assert.equal(queryCodeIndex(index, { op: 'definition', name: 'run', container: 'A' }).total, 1);
|
|
31
|
+
assert.equal(queryCodeIndex(index, { op: 'definition', name: 'run', path: join(dir, 'a.ts') }).total, 1);
|
|
32
|
+
assert.throws(() => queryCodeIndex(index, { op: 'symbols', name: 'run', path: dir }), /directory/);
|
|
33
|
+
assert.match(queryCodeIndex(index, { op: 'symbols', name: 'absent' }).next, /find\/ls/);
|
|
34
|
+
});
|
|
35
|
+
test('coverage counts remain stable across refreshes and disclose capped indexing', async t => {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), 'graph-coverage-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
37
|
+
for (const file of ['a', 'b', 'c']) writeFileSync(join(dir, file + '.ts'), `function ${file}() {}`);
|
|
38
|
+
const index = await createCodeIndex(dir, { maxFiles: 1 });
|
|
39
|
+
const before = index.stats(); await index.refresh(); await index.refresh();
|
|
40
|
+
assert.equal(index.stats().skipped, 2); assert.equal(index.stats().discovered, 3);
|
|
41
|
+
assert.equal(index.stats().complete, false); assert.equal(index.stats().parsed, before.parsed);
|
|
42
|
+
assert.equal(queryCodeIndex(index, { op: 'symbols', name: 'a' }).coverage.complete, false);
|
|
43
|
+
});
|
|
44
|
+
test('unreadable directories mark coverage incomplete', { skip: process.platform === 'win32' || process.getuid?.() === 0 }, async t => {
|
|
45
|
+
const dir = mkdtempSync(join(tmpdir(), 'graph-unreadable-'));
|
|
46
|
+
writeFileSync(join(dir, 'a.ts'), 'function a() {}');
|
|
47
|
+
mkdirSync(join(dir, 'private')); writeFileSync(join(dir, 'private', 'b.ts'), 'function hidden() {}');
|
|
48
|
+
chmodSync(join(dir, 'private'), 0o000);
|
|
49
|
+
t.after(() => { chmodSync(join(dir, 'private'), 0o755); rmSync(dir, { recursive: true, force: true }); });
|
|
50
|
+
const index = await createCodeIndex(dir);
|
|
51
|
+
assert.equal(index.stats().unreadable, 1); assert.equal(index.stats().complete, false);
|
|
52
|
+
assert.deepEqual(queryCodeIndex(index, { op: 'definition', name: 'hidden' }).coverage, { files: 1, discovered: 1, skipped: 0, unreadable: 1, parseErrors: 0, complete: false });
|
|
53
|
+
chmodSync(join(dir, 'private'), 0o755); await index.refresh();
|
|
54
|
+
assert.equal(index.stats().unreadable, 0); assert.equal(index.stats().complete, true);
|
|
55
|
+
});
|
|
56
|
+
test('oversized graph records keep a parseable envelope and pagination', async t => {
|
|
57
|
+
const dir = mkdtempSync(join(tmpdir(), 'graph-oversized-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
58
|
+
writeFileSync(join(dir, 'big.ts'), `function ${'x'.repeat(30000)}() {}\nfunction xsmall() {}`);
|
|
59
|
+
const index = await createCodeIndex(dir);
|
|
60
|
+
const serialized = boundedCodeNavResult(index, { op: 'symbols', name: 'x' });
|
|
61
|
+
assert.ok(Buffer.byteLength(serialized) <= 23000);
|
|
62
|
+
const page = JSON.parse(serialized);
|
|
63
|
+
assert.equal(page.total, 2); assert.equal(page.returned, 1); assert.equal(page.nextOffset, 1);
|
|
64
|
+
assert.match(page.clipped, /shortened/); assert.match(page.results[0].name, /30000 chars/);
|
|
65
|
+
assert.equal(JSON.parse(boundedCodeNavResult(index, { op: 'symbols', name: 'x', offset: 1 })).results[0].name, 'xsmall');
|
|
66
|
+
});
|