cartethyia 2.4.4 → 2.4.6
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/.env.example +4 -1
- package/README.md +18 -0
- package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
- package/dist/dashboard/assets/{BannedPage-TDue2X0p.js → BannedPage-QhvbJB9o.js} +1 -1
- package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-qaHXuiI5.js → CliToolDetailPage-Bcy150Jo.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BFuQBKez.js → CliToolsPage-DyNXefph.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +1 -0
- package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-BhglcqyR.js → CustomizationPage-D6jEU7nL.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-CeqROJrT.js → LoginPage-BJlMVN4U.js} +1 -1
- package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-Br3CXEau.js +1 -0
- package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-CRXSAITY.js → OverviewPage-CQxXbo2g.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CXiumA53.js +7 -0
- package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-BHW4Zt06.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-VOYC_lIa.js +1 -0
- package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DpOTnGuH.js +1 -0
- package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-BNIDLiU6.js +31 -0
- package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
- package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-B6LA4ewF.js → UsagePage-BAFaSDQV.js} +14 -14
- package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BETfaMYZ.js +1 -0
- package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
- package/dist/dashboard/assets/app-BLCvGdzb.css +2 -0
- package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
- package/dist/dashboard/assets/{app-BDJWMThu.js → app-Cjq7nrwu.js} +3 -3
- package/dist/dashboard/assets/app-Cjq7nrwu.js.gz +0 -0
- package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
- package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
- package/dist/dashboard/assets/{assert-Bwh6DJTK.js → assert-7J6_LVD3.js} +1 -1
- package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
- package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
- package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-BTyGk_DL.js} +1 -1
- package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-bujTWYM4.js} +1 -1
- package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
- package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
- package/dist/dashboard/assets/{card-BTdbRFFg.js → card-B0Bnx5CA.js} +1 -1
- package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
- package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
- package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
- package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
- package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-DE2ug812.js +1 -0
- package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-R_rArH8S.js +1 -0
- package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
- package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
- package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BWIP_EvW.js} +1 -1
- package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
- package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-BKcgVugF.js} +1 -1
- package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
- package/dist/dashboard/assets/entry-7dpgvpJ3.js +4 -0
- package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
- package/dist/dashboard/assets/entry-CcWkGR4a.js +1 -0
- package/dist/dashboard/assets/entry-CcWkGR4a.js.gz +0 -0
- package/dist/dashboard/assets/entry-kTiTFrvh.js +1 -0
- package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
- package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
- package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-Booc5mNe.js +1 -0
- package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
- package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
- package/dist/dashboard/assets/{index-BA_ni9Ix.js → index-D70pChfn.js} +2 -2
- package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1TCqM2D.js} +1 -1
- package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
- package/dist/dashboard/assets/{input-Bn4_kNav.js → input-B9oyU93y.js} +1 -1
- package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
- package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
- package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
- package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
- package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BArzXirQ.js} +1 -1
- package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-BVZzaoHF.js} +1 -1
- package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
- package/dist/dashboard/assets/{live-YlifPYox.js → live-EmuIayqp.js} +1 -1
- package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
- package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
- package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CVvHZ-qz.js → model-bans-CSPQ9xB0.js} +1 -1
- package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
- package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
- package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
- package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
- package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
- package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
- package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
- package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
- package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
- package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
- package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
- package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
- package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
- package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
- package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
- package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
- package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
- package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
- package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
- package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
- package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
- package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
- package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
- package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
- package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
- package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
- package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
- package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
- package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
- package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
- package/dist/dashboard/assets/{routing-jntw9-Ui.js → routing-BExgxxBU.js} +1 -1
- package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
- package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
- package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
- package/dist/dashboard/assets/{select-CGUuTQmj.js → select-C9mMoEpJ.js} +1 -1
- package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
- package/dist/dashboard/assets/settings-C4_Gn_wq.js +1 -0
- package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
- package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-Ca5HxI5i.js} +1 -1
- package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
- package/dist/dashboard/assets/{state-DHKdg-pm.js → state-CzUPfLXo.js} +1 -1
- package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
- package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-DOUIky_i.js} +1 -1
- package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/toast-C76WrMhc.js +2 -0
- package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
- package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
- package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-D87FZwH0.js} +1 -1
- package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
- package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +123 -0
- package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
- package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
- package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
- package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
- package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
- package/package.json +25 -30
- package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
- package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
- package/scripts/cli/command/admin.ts +65 -0
- package/scripts/cli/command/console-menu.ts +227 -0
- package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
- package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
- package/scripts/cli/command/start.ts +13 -0
- package/scripts/cli/command/tray.ts +165 -0
- package/scripts/cli/command/update.ts +23 -0
- package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
- package/scripts/cli/gateway/spawn.ts +101 -0
- package/scripts/cli/gateway/stop.ts +51 -0
- package/scripts/cli/main.ts +94 -0
- package/scripts/cli/support/bun.ts +27 -0
- package/scripts/cli/support/env.ts +99 -0
- package/scripts/cli/support/platform.ts +64 -0
- package/scripts/cli/support/services.ts +88 -0
- package/scripts/cli/support/startup-signal.ts +23 -0
- package/scripts/cli/support/version.ts +37 -0
- package/scripts/cli/tray/hidden-window.vbs +28 -0
- package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
- package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
- package/scripts/cli/ui/background.ts +49 -0
- package/src/boot/app.ts +3 -1
- package/src/boot/dependencies.ts +36 -13
- package/src/config/providers.ts +27 -1
- package/src/config/spec.ts +11 -0
- package/src/console/backup/routes.ts +8 -0
- package/src/console/cli-tools/contracts.ts +6 -6
- package/src/console/console-router.ts +5 -3
- package/src/console/dashboard-assets.ts +81 -11
- package/src/console/observability/contracts.ts +54 -22
- package/src/console/observability/routes.ts +31 -6
- package/src/console/observability/store.ts +68 -28
- package/src/console/providers/catalog/public-model-store.ts +91 -60
- package/src/console/providers/catalog/store.ts +5 -1
- package/src/console/quota/account-quota-tenant.ts +9 -6
- package/src/console/response-compression.ts +69 -2
- package/src/console/routing/pools/probe-verdict.ts +72 -0
- package/src/console/share/share-router.ts +7 -4
- package/src/console/share/share-stats.ts +12 -5
- package/src/console/shared/read-through-cache.ts +133 -7
- package/src/console/updates/repository-updates.ts +39 -34
- package/src/network/outbound-fetch.ts +113 -25
- package/src/network/tls/bun-client-hello.ts +112 -0
- package/src/observability/event-log.ts +29 -8
- package/src/observability/metrics.ts +40 -13
- package/src/observability/runtime-metrics.ts +7 -0
- package/src/persistence/schema.ts +40 -2
- package/src/protocol/response/codex.ts +9 -75
- package/src/protocol/tool-identity.ts +43 -9
- package/src/providers/authentication/jwt-validator.ts +130 -187
- package/src/providers/authentication/oauth-flow-store.ts +16 -9
- package/src/providers/default-registry.ts +4 -0
- package/src/providers/discovery/catalog/catalog-format.ts +20 -0
- package/src/providers/discovery/models-dev-catalog.ts +39 -22
- package/src/providers/discovery/probe-wire.ts +29 -5
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +3 -2
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +4 -0
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
- package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
- package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
- package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -2
- package/src/providers/integrations/buddy/codebuddy/shared.ts +106 -73
- package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
- package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
- package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
- package/src/providers/integrations/codex/codex.ts +5 -8
- package/src/providers/integrations/factory/factory-oauth.ts +7 -11
- package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
- package/src/providers/integrations/opencode/provider.ts +42 -3
- package/src/providers/integrations/qoder/qoder-oauth.ts +3 -9
- package/src/providers/integrations/shared/install-id.ts +29 -2
- package/src/providers/operations/client-versions.ts +11 -1
- package/src/providers/operations/model-catalog-cache.ts +18 -3
- package/src/providers/operations/provider-catalog-service.ts +76 -0
- package/src/transport/dispatch/attempt-finalize.ts +7 -1
- package/src/transport/dispatch/attempt-loop.ts +9 -7
- package/src/transport/dispatch/empty-stream.ts +17 -26
- package/src/transport/failure-policy.ts +20 -0
- package/src/transport/routing/route-catalog.ts +42 -3
- package/src/transport/surface/chat/encode.ts +18 -31
- package/src/transport/surface/messages/stream.ts +3 -2
- package/src/transport/translation/tool-repair.ts +37 -16
- package/src/workers/account-identity-sweep.ts +14 -12
- package/src/workers/daily-checkin.ts +62 -0
- package/src/workers/quota-refresh-sweep.ts +27 -2
- package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-vkZBxMVo.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-DDkHfwpV.js +0 -1
- package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
- package/dist/dashboard/assets/ProviderDetailPage-cNAD_6uN.js +0 -3
- package/dist/dashboard/assets/ProvidersPage-Cv5tdxlx.js +0 -1
- package/dist/dashboard/assets/ProxyPage-CWvMfzjj.js +0 -7
- package/dist/dashboard/assets/QuotaPage-IYQOOHR4.js +0 -1
- package/dist/dashboard/assets/SetupPage-BPDNo3Ek.js +0 -1
- package/dist/dashboard/assets/SortableList-Bol1ASmI.js +0 -1
- package/dist/dashboard/assets/StudioPage-BE5DJONZ.js +0 -31
- package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
- package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
- package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
- package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
- package/dist/dashboard/assets/entry-DTiWhIY-.js +0 -4
- package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
- package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
- package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
- package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
- package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
- package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
- package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
- package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
- package/scripts/cli/cli.ts +0 -766
- package/scripts/cli/version.ts +0 -66
- package/scripts/operations/probe-egress.ts +0 -335
- package/scripts/operations/restart.ts +0 -120
- package/scripts/shared/env.ts +0 -269
- /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
- /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
- /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
- /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
|
@@ -227,12 +227,19 @@ const TOP_IPS_LIMIT = 50;
|
|
|
227
227
|
const HOURS_WINDOW = 24;
|
|
228
228
|
|
|
229
229
|
/**
|
|
230
|
-
* Cache lifetime for one family's rollup.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
230
|
+
* Cache lifetime for one family's rollup.
|
|
231
|
+
*
|
|
232
|
+
* Matched to the share page's SSE tick, which was the point of the old 2 s
|
|
233
|
+
* value — but at 2 s the cache expired on every tick, so every single push was
|
|
234
|
+
* a full parse of the retained telemetry window. A share page's numbers move
|
|
235
|
+
* when a request does, not every 2 s; 10 s still reads as live, and cuts the
|
|
236
|
+
* parse rate by five.
|
|
237
|
+
*
|
|
238
|
+
* Declared here rather than imported from the router so this port does not
|
|
239
|
+
* depend on it. `SHARE_STATS_STREAM_INTERVAL_MS` must be raised with it, or
|
|
240
|
+
* the two drift apart and the cache expires between ticks for no reason.
|
|
234
241
|
*/
|
|
235
|
-
export const SHARE_STATS_CACHE_TTL_SECONDS =
|
|
242
|
+
export const SHARE_STATS_CACHE_TTL_SECONDS = 10;
|
|
236
243
|
|
|
237
244
|
/**
|
|
238
245
|
* Cache key for one family's rollup. Both inputs change the payload — `keyIds`
|
|
@@ -10,8 +10,26 @@
|
|
|
10
10
|
* A Redis that is slow or down must never fail a request: every cache step
|
|
11
11
|
* falls through to computing the answer, which is exactly what the request
|
|
12
12
|
* would have done before there was a cache.
|
|
13
|
+
*
|
|
14
|
+
* ## Validators
|
|
15
|
+
*
|
|
16
|
+
* The endpoints answer dashboard polls that repeat every few seconds while the
|
|
17
|
+
* underlying telemetry window changes every minute. Without a validator every
|
|
18
|
+
* poll ships the whole payload — 8.8 KiB to 150 KiB of it — even when nothing
|
|
19
|
+
* changed. An ETag turns that into a ~200-byte `304`.
|
|
20
|
+
*
|
|
21
|
+
* Strong, not weak: the hash covers the exact serialized bytes the client
|
|
22
|
+
* would otherwise have received, so a matching validator means byte-identical
|
|
23
|
+
* content. A weaker or unstable hash would answer `304` for changed data and
|
|
24
|
+
* leave the dashboard showing stale numbers, which is worse than the bytes it
|
|
25
|
+
* saves.
|
|
26
|
+
*
|
|
27
|
+
* The ETag is computed **once per write**, never per read: hashing a 150 KiB
|
|
28
|
+
* body on every poll would move the cost off the wire and onto the CPU.
|
|
13
29
|
*/
|
|
14
30
|
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
|
|
15
33
|
/** The slice of Redis this cache needs, so it can be tested without a server. */
|
|
16
34
|
export interface ReadThroughCacheClient {
|
|
17
35
|
get(key: string): Promise<string | null>;
|
|
@@ -25,25 +43,133 @@ export interface ReadThroughCacheClient {
|
|
|
25
43
|
*/
|
|
26
44
|
export const READ_THROUGH_DEFAULT_TTL_SECONDS = 10;
|
|
27
45
|
|
|
28
|
-
/**
|
|
29
|
-
export
|
|
46
|
+
/** The cached value plus the strong validator for the bytes it serializes to. */
|
|
47
|
+
export interface CachedResult<T> {
|
|
48
|
+
readonly value: T;
|
|
49
|
+
readonly etag: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Quoted per RFC 9110: an entity-tag is always `"…"`. */
|
|
53
|
+
function etagFor(bytes: string): string {
|
|
54
|
+
// SHA-256 over the exact serialized bytes. Not the FNV-1a in
|
|
55
|
+
// `payload-store.ts` — that one is module-private, and adding a second hash
|
|
56
|
+
// implementation would risk two different collision profiles for one
|
|
57
|
+
// purpose. SHA-256 is already used in `share-stats.ts` for cache identity.
|
|
58
|
+
return `"${createHash("sha256").update(bytes, "utf8").digest("hex").slice(0, 32)}"`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The sibling key holding the validator, so a cached body is never re-hashed. */
|
|
62
|
+
export function etagKey(key: string): string {
|
|
63
|
+
return `${key}:etag`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads through the cache and returns a strong validator alongside the value.
|
|
68
|
+
*
|
|
69
|
+
* The validator is computed once, at the moment the body is written, and read
|
|
70
|
+
* back from its own key: a hit never hashes the payload. On a validator miss
|
|
71
|
+
* with a body hit — a partial eviction, or a row written by an older build —
|
|
72
|
+
* the body is re-hashed rather than discarded, so the pair cannot disagree.
|
|
73
|
+
*
|
|
74
|
+
* With no cache configured (`client === undefined`) the value is computed and
|
|
75
|
+
* the validator is still returned, so conditional requests keep working for
|
|
76
|
+
* the length of the client's own polling loop; only the cross-instance sharing
|
|
77
|
+
* is lost.
|
|
78
|
+
*/
|
|
79
|
+
export async function cachedReadWithEtag<T>(
|
|
30
80
|
client: ReadThroughCacheClient | undefined,
|
|
31
81
|
key: string,
|
|
32
82
|
compute: () => Promise<T>,
|
|
33
83
|
ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
|
|
34
|
-
): Promise<T
|
|
35
|
-
if (client === undefined)
|
|
84
|
+
): Promise<CachedResult<T>> {
|
|
85
|
+
if (client === undefined) {
|
|
86
|
+
const fresh = await compute();
|
|
87
|
+
return { value: fresh, etag: etagFor(JSON.stringify(fresh) ?? "null") };
|
|
88
|
+
}
|
|
36
89
|
try {
|
|
37
90
|
const hit = await client.get(key);
|
|
38
|
-
if (hit !== null)
|
|
91
|
+
if (hit !== null) {
|
|
92
|
+
const value = JSON.parse(hit) as T;
|
|
93
|
+
try {
|
|
94
|
+
const stored = await client.get(etagKey(key));
|
|
95
|
+
return { value, etag: stored ?? etagFor(hit) };
|
|
96
|
+
} catch {
|
|
97
|
+
return { value, etag: etagFor(hit) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
39
100
|
} catch {
|
|
40
|
-
|
|
101
|
+
// A cache read must never fail a request; compute instead.
|
|
41
102
|
}
|
|
42
103
|
const value = await compute();
|
|
104
|
+
const bytes = JSON.stringify(value) ?? "null";
|
|
105
|
+
const etag = etagFor(bytes);
|
|
43
106
|
try {
|
|
44
|
-
await client.set(key,
|
|
107
|
+
await client.set(key, bytes, "EX", ttlSeconds);
|
|
108
|
+
await client.set(etagKey(key), etag, "EX", ttlSeconds);
|
|
45
109
|
} catch {
|
|
46
110
|
// A cache write must never fail a request that already has its answer.
|
|
47
111
|
}
|
|
112
|
+
return { value, etag };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `undefined` when the row was never cached, so a computed `null` is cacheable. */
|
|
116
|
+
export async function cachedRead<T>(
|
|
117
|
+
client: ReadThroughCacheClient | undefined,
|
|
118
|
+
key: string,
|
|
119
|
+
compute: () => Promise<T>,
|
|
120
|
+
ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
const { value } = await cachedReadWithEtag(client, key, compute, ttlSeconds);
|
|
48
123
|
return value;
|
|
49
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `cache-control` for a cached console read.
|
|
128
|
+
*
|
|
129
|
+
* `no-cache`, not `no-store`: the response may be stored, it simply must be
|
|
130
|
+
* revalidated, which is exactly what the ETag is for. `no-store` would forbid
|
|
131
|
+
* storing and so forbid the `304` this exists to enable.
|
|
132
|
+
*/
|
|
133
|
+
const CACHE_CONTROL = "no-cache";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Answers a conditional GET from a {@link CachedResult}.
|
|
137
|
+
*
|
|
138
|
+
* A match returns a bodiless `304`. An `If-None-Match` that does not match, or
|
|
139
|
+
* a request that sent none, returns the body with the validator attached so
|
|
140
|
+
* the next poll can be the cheap one.
|
|
141
|
+
*
|
|
142
|
+
* `If-None-Match` may carry several validators or the `*` form, so the header
|
|
143
|
+
* is parsed rather than compared as one string.
|
|
144
|
+
*/
|
|
145
|
+
export function conditionalResponse<T>(
|
|
146
|
+
ifNoneMatch: string | null,
|
|
147
|
+
result: CachedResult<T>,
|
|
148
|
+
body: unknown,
|
|
149
|
+
): Response {
|
|
150
|
+
if (matchesEtag(ifNoneMatch, result.etag)) {
|
|
151
|
+
return new Response(null, {
|
|
152
|
+
status: 304,
|
|
153
|
+
headers: { etag: result.etag, "cache-control": CACHE_CONTROL },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const response = Response.json(body);
|
|
157
|
+
response.headers.set("etag", result.etag);
|
|
158
|
+
response.headers.set("cache-control", CACHE_CONTROL);
|
|
159
|
+
return response;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True when an `If-None-Match` header names this validator. */
|
|
163
|
+
export function matchesEtag(header: string | null, etag: string): boolean {
|
|
164
|
+
if (header === null) return false;
|
|
165
|
+
const trimmed = header.trim();
|
|
166
|
+
if (trimmed === "*") return true;
|
|
167
|
+
for (const part of trimmed.split(",")) {
|
|
168
|
+
// The weak prefix is stripped: a weak validator still names the same
|
|
169
|
+
// representation, and the client that sent it asked to be told if the
|
|
170
|
+
// current one matches.
|
|
171
|
+
const candidate = part.trim().replace(/^W\//, "");
|
|
172
|
+
if (candidate === etag) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
@@ -1,34 +1,39 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public release discovery for the console's update indicator.
|
|
3
|
-
*
|
|
4
|
-
* The browser never talks to GitHub directly: a dashboard-side fetch would put
|
|
5
|
-
* every console user's GitHub rate limit behind one shared, unauthenticated
|
|
6
|
-
* origin, and a CORS or rate-limit failure would surface as a console error
|
|
7
|
-
* nobody can act on. The request is made here, server-side, and any failure
|
|
8
|
-
* degrades to "no update known" rather than an error the operator must read.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return null;
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Public release discovery for the console's update indicator.
|
|
3
|
+
*
|
|
4
|
+
* The browser never talks to GitHub directly: a dashboard-side fetch would put
|
|
5
|
+
* every console user's GitHub rate limit behind one shared, unauthenticated
|
|
6
|
+
* origin, and a CORS or rate-limit failure would surface as a console error
|
|
7
|
+
* nobody can act on. The request is made here, server-side, and any failure
|
|
8
|
+
* degrades to "no update known" rather than an error the operator must read.
|
|
9
|
+
*
|
|
10
|
+
* Releases are read from the ALTERNATIVE repository (`risunCodex`), not the
|
|
11
|
+
* primary: the primary repository is hidden from public view, so its releases
|
|
12
|
+
* endpoint answers 404 and the console would report "no update known" forever —
|
|
13
|
+
* a silent failure that reads exactly like "you are up to date".
|
|
14
|
+
*/
|
|
15
|
+
const REPOSITORY = "risunCodex/Cartethyia";
|
|
16
|
+
|
|
17
|
+
/** Timeout for one GitHub read. A hung read must not hold a console query open. */
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
export interface LatestRelease {
|
|
21
|
+
readonly tag_name: string;
|
|
22
|
+
readonly html_url: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Fetches the latest public release. Returns `null` when none is known. */
|
|
26
|
+
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(`https://api.github.com/repos/${REPOSITORY}/releases/latest`, {
|
|
29
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "Cartethyia-updater" },
|
|
30
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) return null;
|
|
33
|
+
const value = (await response.json()) as { readonly tag_name?: unknown; readonly html_url?: unknown };
|
|
34
|
+
if (typeof value.tag_name !== "string" || typeof value.html_url !== "string") return null;
|
|
35
|
+
return { tag_name: value.tag_name, html_url: value.html_url };
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { SsrfPolicy } from "../config/network";
|
|
|
9
9
|
import { isProxyAgentPair, type ProxyAgentPair } from "./pool/agent";
|
|
10
10
|
import { metrics } from "../observability/metrics";
|
|
11
11
|
import { trackNetworkCall } from "../observability/performance-metrics";
|
|
12
|
+
import { bunClientHelloOptions } from "./tls/bun-client-hello";
|
|
12
13
|
import { stripResponseHeaders } from "./response-headers";
|
|
13
14
|
// Validated fetch — single egress with ProxyAgent support
|
|
14
15
|
export interface ValidatedFetchOptions {
|
|
@@ -17,6 +18,14 @@ export interface ValidatedFetchOptions {
|
|
|
17
18
|
readonly maxRedirects?: number;
|
|
18
19
|
readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
|
|
19
20
|
readonly agent?: HttpAgent | HttpsAgent | ProxyAgentPair;
|
|
21
|
+
/**
|
|
22
|
+
* Handshake with Bun's cipher list, curve list and ALPN instead of
|
|
23
|
+
* OpenSSL's defaults. Opt-in, and off by default: see
|
|
24
|
+
* `network/tls/bun-client-hello.ts` — ciphers and curves match exactly, but
|
|
25
|
+
* the extension list cannot be shaped from Node, so this is a closer hello
|
|
26
|
+
* rather than an identical one.
|
|
27
|
+
*/
|
|
28
|
+
readonly bunClientHello?: boolean;
|
|
20
29
|
}
|
|
21
30
|
export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
22
31
|
|
|
@@ -34,6 +43,57 @@ function agentForTarget(
|
|
|
34
43
|
return agent;
|
|
35
44
|
}
|
|
36
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Keep-alive options for the direct (non-pool) egress agents.
|
|
48
|
+
*
|
|
49
|
+
* Matched to `network/pool/agent.ts`, which builds pool agents from
|
|
50
|
+
* `resolveProxyMaxSockets()` / `resolveProxyMaxFreeSockets()` and
|
|
51
|
+
* `PROXY_KEEP_ALIVE_TIMEOUT_MS`. Direct egress has no per-pool config to
|
|
52
|
+
* read, so it uses the documented defaults those resolvers return.
|
|
53
|
+
*/
|
|
54
|
+
const DIRECT_AGENT_OPTIONS = {
|
|
55
|
+
keepAlive: true,
|
|
56
|
+
maxSockets: 100,
|
|
57
|
+
maxFreeSockets: 20,
|
|
58
|
+
timeout: 60_000,
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An agent whose socket key includes the SSRF-validated address.
|
|
63
|
+
*
|
|
64
|
+
* The direct (non-pool) path pins egress by passing a `lookup` that returns the
|
|
65
|
+
* one validated address, but `Agent#getName` — the keep-alive socket key —
|
|
66
|
+
* hashes only host/port/localAddress/family. Two requests to the same
|
|
67
|
+
* `host:port` that validated to *different* addresses therefore share a pooled
|
|
68
|
+
* socket, and the second one silently egresses to the first one's address:
|
|
69
|
+
* the pin is bypassed without any error surfacing.
|
|
70
|
+
*
|
|
71
|
+
* Including the address in the key makes each validated address its own socket
|
|
72
|
+
* bucket, so a re-resolved address always dials fresh while the *same* address
|
|
73
|
+
* still reuses its socket (keep-alive is preserved, not defeated).
|
|
74
|
+
*
|
|
75
|
+
* This is a real gap, not a theoretical one: with no agent at all the request
|
|
76
|
+
* falls back to the global agent, which has the same key shape *and* is shared
|
|
77
|
+
* with unrelated traffic.
|
|
78
|
+
*/
|
|
79
|
+
class PinnedAddressAgent extends HttpAgent {
|
|
80
|
+
override getName(options: Record<string, unknown> = {}): string {
|
|
81
|
+
const base = super.getName(options as never);
|
|
82
|
+
const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
|
|
83
|
+
return `${base}:${pinned ?? "-"}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
class PinnedAddressHttpsAgent extends HttpsAgent {
|
|
87
|
+
override getName(options: Record<string, unknown> = {}): string {
|
|
88
|
+
const base = super.getName(options as never);
|
|
89
|
+
const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
|
|
90
|
+
return `${base}:${pinned ?? "-"}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DIRECT_HTTP_AGENT = new PinnedAddressAgent(DIRECT_AGENT_OPTIONS);
|
|
95
|
+
const DIRECT_HTTPS_AGENT = new PinnedAddressHttpsAgent(DIRECT_AGENT_OPTIONS);
|
|
96
|
+
|
|
37
97
|
/**
|
|
38
98
|
* Builds the application-relay request used by hosted relay front doors.
|
|
39
99
|
* These endpoints intentionally do not receive CONNECT: they accept the
|
|
@@ -99,10 +159,12 @@ function decompressBody(
|
|
|
99
159
|
return { stream: body.pipe(decoder), encoding: undefined };
|
|
100
160
|
}
|
|
101
161
|
|
|
102
|
-
async function pinnedFetch(
|
|
162
|
+
async function pinnedFetch(
|
|
163
|
+
url: URL,
|
|
103
164
|
init: RequestInit,
|
|
104
165
|
resolvedAddress: string | undefined,
|
|
105
166
|
agent?: HttpAgent | HttpsAgent,
|
|
167
|
+
useBunHello = false,
|
|
106
168
|
): Promise<Response> {
|
|
107
169
|
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
108
170
|
const headers = new Headers(init.headers);
|
|
@@ -126,27 +188,38 @@ async function pinnedFetch( url: URL,
|
|
|
126
188
|
outboundHeaders["content-length"] =
|
|
127
189
|
typeof body === "string" ? String(Buffer.byteLength(body, "utf8")) : String(body.byteLength);
|
|
128
190
|
}
|
|
129
|
-
const lookup = (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
191
|
+
const lookup = Object.assign(
|
|
192
|
+
(
|
|
193
|
+
_hostname: string,
|
|
194
|
+
options: { all?: boolean; family?: number } | number,
|
|
195
|
+
callback: (
|
|
196
|
+
error: Error | null,
|
|
197
|
+
address?: string | Array<{ address: string; family: number }>,
|
|
198
|
+
family?: number,
|
|
199
|
+
) => void,
|
|
200
|
+
): void => {
|
|
201
|
+
if (resolvedAddress === undefined) {
|
|
202
|
+
callback(new Error("no validated address available"));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const opts = typeof options === "number" ? { family: options } : options;
|
|
206
|
+
const family = resolvedAddress.includes(":") ? 6 : 4;
|
|
207
|
+
if (opts?.all) {
|
|
208
|
+
callback(null, [{ address: resolvedAddress, family }] as never);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
callback(null, resolvedAddress, family);
|
|
212
|
+
},
|
|
213
|
+
// Read by `PinnedAddressAgent#getName` so the keep-alive socket key
|
|
214
|
+
// carries the validated address. See the class comment for why.
|
|
215
|
+
{ pinnedAddress: resolvedAddress },
|
|
216
|
+
) as unknown as NonNullable<RequestOptions["lookup"]>;
|
|
217
|
+
// Direct egress gets a dedicated agent as well as the pinning `lookup`.
|
|
218
|
+
// Passing only `lookup` left the request on the global agent, whose socket
|
|
219
|
+
// key ignores `lookup` — so a re-resolved address could reuse a socket
|
|
220
|
+
// dialed against an earlier one and egress past the SSRF check. The direct
|
|
221
|
+
// agent keys its sockets on the pinned address, so the pin holds.
|
|
222
|
+
const directAgent = url.protocol === "https:" ? DIRECT_HTTPS_AGENT : DIRECT_HTTP_AGENT;
|
|
150
223
|
return new Promise<Response>((resolve, reject) => {
|
|
151
224
|
const nodeRequest = request(
|
|
152
225
|
{
|
|
@@ -157,8 +230,17 @@ async function pinnedFetch( url: URL,
|
|
|
157
230
|
method: init.method ?? "GET",
|
|
158
231
|
headers: outboundHeaders,
|
|
159
232
|
signal: init.signal ?? undefined,
|
|
160
|
-
...(agent ? { agent } : { lookup }),
|
|
233
|
+
...(agent ? { agent } : { agent: directAgent, lookup }),
|
|
161
234
|
...(url.protocol === "https:" ? { servername: url.hostname } : {}),
|
|
235
|
+
// Bun's cipher list, curve list, ALPN and version span, when this
|
|
236
|
+
// request opted into it. See `network/tls/bun-client-hello.ts` for
|
|
237
|
+
// what this does and does not match: ciphers and curves land exactly;
|
|
238
|
+
// the extension list cannot be shaped from Node at all.
|
|
239
|
+
//
|
|
240
|
+
// Opt-in, and off by default. It changes the TLS handshake of every
|
|
241
|
+
// request it touches, and no provider needs it until the free-tier
|
|
242
|
+
// gate is shown to reject the default hello.
|
|
243
|
+
...(useBunHello && url.protocol === "https:" ? bunClientHelloOptions() : {}),
|
|
162
244
|
},
|
|
163
245
|
(response) => {
|
|
164
246
|
const responseHeaders = stripResponseHeaders(response.headers);
|
|
@@ -230,7 +312,7 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
|
|
|
230
312
|
// The egress dials the pool/relay, which resolves the target itself.
|
|
231
313
|
// Counting these lets ops confirm how much traffic depends on
|
|
232
314
|
// proxy-resolved DNS rather than the local system resolver.
|
|
233
|
-
metrics.
|
|
315
|
+
metrics.cartethyia_proxy_dial_proxy_resolved_total.inc();
|
|
234
316
|
}
|
|
235
317
|
try {
|
|
236
318
|
if (!poolDialsTarget) {
|
|
@@ -263,7 +345,13 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
|
|
|
263
345
|
response = await options.fetchFn(url, requestInit);
|
|
264
346
|
} else {
|
|
265
347
|
const agent = agentForTarget(configuredAgent, url.protocol);
|
|
266
|
-
response = await pinnedFetch(
|
|
348
|
+
response = await pinnedFetch(
|
|
349
|
+
url,
|
|
350
|
+
requestInit,
|
|
351
|
+
resolvedAddress!,
|
|
352
|
+
agent,
|
|
353
|
+
options.bunClientHello === true,
|
|
354
|
+
);
|
|
267
355
|
}
|
|
268
356
|
if (response.status < 300 || response.status >= 400) {
|
|
269
357
|
trackNetworkCall(url.hostname, performance.now() - startedAt);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Socket } from "node:net";
|
|
2
|
+
import { connect as tlsConnect, type ConnectionOptions } from "node:tls";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A TLS ClientHello that matches Bun's fetch, for the paths that cannot use
|
|
6
|
+
* Bun's own `fetch`.
|
|
7
|
+
*
|
|
8
|
+
* `opencode2api` impersonates OpenCode by impersonating **Bun** — the official
|
|
9
|
+
* clients are Bun programs, and the free-tier gate fingerprints the TLS
|
|
10
|
+
* ClientHello, not just headers. It needed `utls` to do that, because Go's
|
|
11
|
+
* `crypto/tls` cannot change its hello. We are on Bun, so `fetch()` already
|
|
12
|
+
* emits the right one — but our outbound path is `node:https`, which emits
|
|
13
|
+
* OpenSSL's.
|
|
14
|
+
*
|
|
15
|
+
* This module narrows the gap on the Node path. **It does not close it**, and
|
|
16
|
+
* the shortfall is stated plainly below because it decides whether this is
|
|
17
|
+
* worth using at all.
|
|
18
|
+
*
|
|
19
|
+
* What it matches, measured:
|
|
20
|
+
*
|
|
21
|
+
* | Field | Bun | This module |
|
|
22
|
+
* |---|---|---|
|
|
23
|
+
* | Cipher count | 17 | **17** |
|
|
24
|
+
* | Cipher order | `1301-1302-1303-c02b-c02f-c02c-c030-cca9-cca8-c009-c013-c00a-c014-009c-009d-002f-0035` | **identical** |
|
|
25
|
+
* | Curves | `4588,29,23,24` | **`4588,29,23,24`** |
|
|
26
|
+
* | ALPN | `http/1.1` only | **`http/1.1` only** |
|
|
27
|
+
* | TLS span | 1.2–1.3 | **1.2–1.3** |
|
|
28
|
+
* | Extensions | 13, order `0,23,65281,10,11,35,16,5,13,18,51,45,43` | **11, `0,23,65281,10,11,35,16,13,51,45,43`** |
|
|
29
|
+
*
|
|
30
|
+
* The extension list is the part Node cannot reach. The first seven match
|
|
31
|
+
* exactly, then it diverges: `status_request` (5, OCSP stapling) and
|
|
32
|
+
* `signed_certificate_timestamp` (18, SCT) are absent, and BoringSSL sends
|
|
33
|
+
* both between ALPN and `signature_algorithms`. Neither has a Node option.
|
|
34
|
+
* No extension we send is foreign to Bun's — we send a subset, not a
|
|
35
|
+
* different set.
|
|
36
|
+
*
|
|
37
|
+
* **So this is a closer hello, not an identical one.** If the gate's check is
|
|
38
|
+
* JA3/JA4-shaped — and the reference gateway's own comment names JA4
|
|
39
|
+
* `t13d1713h1`, which is computed from the extension list — this will not pass
|
|
40
|
+
* it. Use `fetch` through a CONNECT bridge (`outbound-fetch.ts`) where an exact
|
|
41
|
+
* match is required; that path measures identical, all 13 extensions in order.
|
|
42
|
+
*
|
|
43
|
+
* Kept because it is still strictly better than the OpenSSL default (52 ciphers
|
|
44
|
+
* in OpenSSL's order) for any check that reads ciphers or curves without hashing
|
|
45
|
+
* the whole extension list, and because it costs one function call.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bun's cipher list, in wire order.
|
|
50
|
+
*
|
|
51
|
+
* OpenSSL names, because that is what Node accepts — the `TLS_*` spellings
|
|
52
|
+
* resolve to only 3 suites here (the rest are unavailable to OpenSSL), which is
|
|
53
|
+
* why an earlier attempt at this produced a 3-cipher hello that was *further*
|
|
54
|
+
* from Bun than doing nothing.
|
|
55
|
+
*/
|
|
56
|
+
export const BUN_CIPHER_SUITES =
|
|
57
|
+
"TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:" +
|
|
58
|
+
"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" +
|
|
59
|
+
"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" +
|
|
60
|
+
"ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" +
|
|
61
|
+
"ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:" +
|
|
62
|
+
"ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:" +
|
|
63
|
+
"AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256-SHA";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bun's supported groups, in order: the post-quantum hybrid first, then
|
|
67
|
+
* X25519, P-256, P-384 — and nothing else. Measured identical to Bun.
|
|
68
|
+
*/
|
|
69
|
+
export const BUN_SUPPORTED_GROUPS = "X25519MLKEM768:X25519:P-256:P-384";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Options that make a Node TLS connection handshake with Bun's cipher list,
|
|
73
|
+
* curve list, ALPN and version span.
|
|
74
|
+
*
|
|
75
|
+
* Spread into `tls.connect` or `https.request`. Does not touch the extension
|
|
76
|
+
* list, which Node does not expose.
|
|
77
|
+
*/
|
|
78
|
+
export function bunClientHelloOptions(): Pick<
|
|
79
|
+
ConnectionOptions,
|
|
80
|
+
"ciphers" | "ecdhCurve" | "ALPNProtocols" | "minVersion" | "maxVersion"
|
|
81
|
+
> {
|
|
82
|
+
return {
|
|
83
|
+
ciphers: BUN_CIPHER_SUITES,
|
|
84
|
+
ecdhCurve: BUN_SUPPORTED_GROUPS,
|
|
85
|
+
// One protocol, like Bun. Offering `h2` here would change the ALPN
|
|
86
|
+
// extension and the negotiated protocol.
|
|
87
|
+
ALPNProtocols: ["http/1.1"],
|
|
88
|
+
minVersion: "TLSv1.2",
|
|
89
|
+
maxVersion: "TLSv1.3",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Upgrades an already-connected socket to TLS with Bun's parameters.
|
|
95
|
+
*
|
|
96
|
+
* The socket is the point: it lets a SOCKS5 `SocksProxyAgent` own the dial
|
|
97
|
+
* (keeping its SSRF `lookup`) while this controls the hello. Bun's `fetch`
|
|
98
|
+
* cannot do that combination — it refuses SOCKS5 outright.
|
|
99
|
+
*/
|
|
100
|
+
export function bunTlsOverSocket(
|
|
101
|
+
socket: Socket,
|
|
102
|
+
servername: string,
|
|
103
|
+
extra: Omit<ConnectionOptions, "socket" | "servername"> = {},
|
|
104
|
+
) {
|
|
105
|
+
return tlsConnect({
|
|
106
|
+
socket,
|
|
107
|
+
servername,
|
|
108
|
+
rejectUnauthorized: true,
|
|
109
|
+
...bunClientHelloOptions(),
|
|
110
|
+
...extra,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -176,19 +176,37 @@ export async function readTelemetryEvents(): Promise<TelemetryEventRecord[]> {
|
|
|
176
176
|
export interface EventQuery {
|
|
177
177
|
readonly tenantId?: string;
|
|
178
178
|
readonly since?: Date;
|
|
179
|
+
/**
|
|
180
|
+
* Exclusive upper bound on `createdAt`, used to push a pagination cursor into
|
|
181
|
+
* the query instead of filtering after the whole window was parsed. Without
|
|
182
|
+
* it a cursor page drops `limit` — the reader cannot know where the page
|
|
183
|
+
* ends — so only the first page of a list is bounded.
|
|
184
|
+
*/
|
|
185
|
+
readonly until?: Date;
|
|
179
186
|
readonly apiKeyIds?: readonly string[];
|
|
180
187
|
readonly accountIds?: readonly string[];
|
|
181
188
|
readonly requestId?: string;
|
|
189
|
+
/**
|
|
190
|
+
* Distinct from {@link requestId}: `id` defaults to the request id when a row
|
|
191
|
+
* does not carry one of its own (`event-log.ts:68`), so a row can match by
|
|
192
|
+
* either. A lookup by `id` short-circuits like `requestId` does, which is the
|
|
193
|
+
* whole point — without it a single-event detail read parses the retention
|
|
194
|
+
* window and then scans it in JS.
|
|
195
|
+
*/
|
|
196
|
+
readonly id?: string;
|
|
182
197
|
readonly httpStatus?: number;
|
|
183
198
|
readonly limit?: number;
|
|
184
199
|
}
|
|
185
200
|
|
|
186
|
-
function matches(event: TelemetryEventRecord, query: EventQuery, since: number | undefined, keys: ReadonlySet<string> | undefined, accounts: ReadonlySet<string> | undefined): boolean {
|
|
201
|
+
function matches(event: TelemetryEventRecord, query: EventQuery, since: number | undefined, until: number | undefined, keys: ReadonlySet<string> | undefined, accounts: ReadonlySet<string> | undefined): boolean {
|
|
187
202
|
if (query.tenantId !== undefined && event.tenantId !== query.tenantId) return false;
|
|
188
|
-
|
|
203
|
+
const at = new Date(event.createdAt).getTime();
|
|
204
|
+
if (since !== undefined && at < since) return false;
|
|
205
|
+
if (until !== undefined && at > until) return false;
|
|
189
206
|
if (keys && (event.apiKeyId === null || !keys.has(event.apiKeyId))) return false;
|
|
190
207
|
if (accounts && (event.accountId === null || !accounts.has(event.accountId))) return false;
|
|
191
208
|
if (query.requestId !== undefined && event.requestId !== query.requestId) return false;
|
|
209
|
+
if (query.id !== undefined && event.id !== query.id) return false;
|
|
192
210
|
if (query.httpStatus !== undefined && event.httpStatus !== query.httpStatus) return false;
|
|
193
211
|
return true;
|
|
194
212
|
}
|
|
@@ -199,6 +217,7 @@ export async function queryTelemetryEvents(query: EventQuery): Promise<Telemetry
|
|
|
199
217
|
.filter((file) => file.endsWith(".jsonl") && (query.since === undefined || eventFileHour(file) >= hourStamp(query.since)))
|
|
200
218
|
.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
|
201
219
|
const since = query.since?.getTime();
|
|
220
|
+
const until = query.until?.getTime();
|
|
202
221
|
const keys = query.apiKeyIds ? new Set(query.apiKeyIds) : undefined;
|
|
203
222
|
const accounts = query.accountIds ? new Set(query.accountIds) : undefined;
|
|
204
223
|
const events: TelemetryEventRecord[] = [];
|
|
@@ -207,19 +226,21 @@ export async function queryTelemetryEvents(query: EventQuery): Promise<Telemetry
|
|
|
207
226
|
// field, and a false negative is not possible while a false positive costs
|
|
208
227
|
// only the parse it would have paid anyway.
|
|
209
228
|
const allow =
|
|
210
|
-
query.tenantId === undefined && query.requestId === undefined
|
|
229
|
+
query.tenantId === undefined && query.requestId === undefined && query.id === undefined
|
|
211
230
|
? undefined
|
|
212
231
|
: (line: string): boolean =>
|
|
213
232
|
(query.tenantId === undefined || line.includes(query.tenantId)) &&
|
|
214
|
-
(query.requestId === undefined || line.includes(query.requestId))
|
|
233
|
+
(query.requestId === undefined || line.includes(query.requestId)) &&
|
|
234
|
+
(query.id === undefined || line.includes(query.id));
|
|
235
|
+
// A single-row lookup: stop at the first (newest) match instead of parsing
|
|
236
|
+
// the whole retention window.
|
|
237
|
+
const oneRow = query.requestId !== undefined || query.id !== undefined;
|
|
215
238
|
for (const file of files) {
|
|
216
239
|
const matched: TelemetryEventRecord[] = [];
|
|
217
240
|
for await (const event of readEventFiles([file], allow)) {
|
|
218
|
-
if (!matches(event, query, since, keys, accounts)) continue;
|
|
241
|
+
if (!matches(event, query, since, until, keys, accounts)) continue;
|
|
219
242
|
matched.push(event);
|
|
220
|
-
|
|
221
|
-
// instead of parsing the whole retention window.
|
|
222
|
-
if (query.requestId !== undefined) return matched;
|
|
243
|
+
if (oneRow) return matched;
|
|
223
244
|
}
|
|
224
245
|
matched.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
225
246
|
events.push(...matched);
|