From f6c9f1ab51b96217ac208a2082b4775f519b4e8b Mon Sep 17 00:00:00 2001 From: limityan Date: Wed, 26 Aug 2026 15:15:05 +0800 Subject: [PATCH] fix(runtime): align server and TLS ownership Reuse the canonical Core Agent Runtime initializer in the loopback Server, retain the product event owner, and fail closed when an explicit startup workspace cannot be opened. Run the complete Core library suite on Linux CI, preserve the stable tool catalog order it exposes, and make services-core the only Ring provider owner for provider-neutral Reqwest clients. --- .github/workflows/ci.yml | 5 + Cargo.lock | 117 +------- Cargo.toml | 5 +- .../rust-build-dependency-boundaries.md | 9 +- scripts/check-core-boundaries.test.mjs | 277 ++++++++++++++++-- scripts/check-github-config.test.mjs | 37 +++ .../cargo-dependency-boundaries.mjs | 84 +++++- .../core-boundaries/rules/feature-rules.mjs | 27 +- .../rules/source/forbidden-rules.mjs | 76 +++++ .../rules/source/required-rules.mjs | 115 ++++++++ src/apps/cli/Cargo.toml | 4 +- src/apps/cli/src/main.rs | 6 +- src/apps/cli/src/self_update.rs | 1 + src/apps/desktop/Cargo.toml | 2 +- src/apps/desktop/src/api/app_state.rs | 5 - src/apps/desktop/src/api/relay_deploy_api.rs | 1 + src/apps/desktop/src/api/skill_api.rs | 1 + src/apps/desktop/src/api/system_api.rs | 1 + src/apps/desktop/src/embedded_relay_host.rs | 1 + src/apps/desktop/src/lib.rs | 6 +- src/apps/sdk-host/Cargo.toml | 3 +- src/apps/sdk-host/src/lib.rs | 2 +- src/apps/server/src/bootstrap.rs | 154 +++------- src/apps/server/src/main.rs | 63 +--- src/apps/server/src/routes/dispatch.rs | 2 +- src/crates/adapters/ai-adapters/Cargo.toml | 4 +- .../adapters/ai-adapters/src/client/http.rs | 1 + .../adapters/ai-adapters/src/client/sse.rs | 4 +- .../ai-adapters/src/subscription_auth/mod.rs | 1 + .../tests/common/stream_test_harness.rs | 1 + .../assembly/core/src/agentic/system.rs | 5 +- .../tools/product_runtime/materialization.rs | 2 +- .../core/src/service/token_usage/service.rs | 8 +- src/crates/services/AGENTS.md | 4 +- .../miniapp-market-service/Cargo.toml | 3 +- .../miniapp-market-service/src/auth.rs | 1 + src/crates/services/services-core/AGENTS.md | 10 +- src/crates/services/services-core/Cargo.toml | 2 + src/crates/services/services-core/src/lib.rs | 2 + .../services-core/src/tls_provider.rs | 19 ++ .../services/services-integrations/Cargo.toml | 30 +- .../src/announcement/remote.rs | 2 +- .../src/appearance_market/client.rs | 2 +- .../src/browser_control/cdp.rs | 21 +- .../src/browser_control/launcher.rs | 2 +- .../services/services-integrations/src/lib.rs | 27 ++ .../src/mcp/protocol/transport_remote.rs | 4 +- .../src/miniapp/host_dispatch.rs | 2 +- .../src/miniapp_market/client.rs | 2 +- .../services-integrations/src/models_dev.rs | 2 +- .../src/remote_connect/bot/feishu.rs | 14 +- .../src/remote_connect/bot/telegram.rs | 14 +- .../src/remote_connect/bot/weixin.rs | 10 +- .../src/remote_connect/mobile_web_upload.rs | 2 +- .../src/remote_connect/relay_client.rs | 13 +- .../src/remote_connect/relay_http.rs | 10 +- .../src/remote_ssh/dispatch_ssh.rs | 2 +- .../src/remote_ssh/relay_deploy.rs | 2 +- .../src/review_platform_http.rs | 2 +- .../src/speech/downloader.rs | 2 +- .../services-integrations/src/web_tools.rs | 4 +- .../services/skin-market-service/Cargo.toml | 3 +- .../services/skin-market-service/src/auth.rs | 1 + 63 files changed, 835 insertions(+), 409 deletions(-) create mode 100644 src/crates/services/services-core/src/tls_provider.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 330714e804..0fb4a4c376 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,8 +301,13 @@ jobs: run: cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml - name: Run core library tests + if: runner.os != 'Linux' run: cargo test --locked -p bitfun-core --lib + - name: Run full core library tests on Linux + if: runner.os == 'Linux' + run: cargo test --locked -p bitfun-core --features product-full --lib + - name: Run desktop library tests if: runner.os != 'Windows' run: cargo test --locked -p bitfun-desktop --lib diff --git a/Cargo.lock b/Cargo.lock index a14c48cd7c..7f4f1e8f1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -567,29 +567,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "axum" version = "0.8.9" @@ -1274,6 +1251,7 @@ dependencies = [ "axum", "base64 0.22.1", "bitfun-product-domains", + "bitfun-services-core", "chrono", "hex", "image 0.25.10", @@ -1555,6 +1533,7 @@ dependencies = [ "log", "regex", "rusqlite", + "rustls", "serde", "serde_json", "serde_yaml", @@ -1665,6 +1644,7 @@ dependencies = [ "axum", "base64 0.22.1", "bitfun-product-domains", + "bitfun-services-core", "chrono", "hex", "hmac", @@ -2247,15 +2227,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cobs" version = "0.3.0" @@ -3752,12 +3723,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -5689,12 +5654,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lzma-sys" version = "0.1.20" @@ -7816,63 +7775,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases 0.2.2", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.3", - "rustls", - "socket2", - "thiserror 2.0.19", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash 2.1.3", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.19", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases 0.2.2", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -7970,15 +7872,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "rangemap" version = "1.7.1" @@ -8166,7 +8059,6 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -8607,7 +8499,6 @@ version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -8635,7 +8526,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] @@ -8672,7 +8562,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", diff --git a/Cargo.toml b/Cargo.toml index c06e612c66..2b069164a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -266,13 +266,14 @@ tokio-tungstenite = "0.29" sherpa-onnx = "1.13.4" # MCP and remote runtimes +# Keep RMCP's Reqwest TLS provider-neutral; services-core owns ring selection. rmcp = { version = "1.7", default-features = false, features = [ "auth", "base64", "client", "elicitation", "macros", - "reqwest", + "reqwest-tls-no-provider", "schemars", "server", ] } @@ -282,7 +283,7 @@ russh-sftp = "2.1" russh-keys = "0.45" shellexpand = "3" ssh_config = "0.1" -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rustls = { version = "0.23", default-features = false } [patch.crates-io] # Tauri 2.11 loses keyboard focus for child webviews after Windows reactivates diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index 2392668862..ee29a0d96e 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -97,10 +97,11 @@ Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品 ### 3.4 Reqwest 能力由客户端 owner 选择 - workspace 级 `reqwest` 只统一版本并关闭默认 feature,不替任何客户端选择 HTTP/2、序列化、表单、流、代理或 TLS 能力; -- 真正创建 client 的 app、service 或 adapter 必须在自身依赖声明中显式选择实际使用的 Reqwest feature 和 `reqwest/rustls`;只使用 `reqwest::Url` 的 contract/assembly 路径不加载传输能力; -- capability crate 的每个 Reqwest owner feature 必须独立带齐自己的数据/传输 feature 与 `reqwest/rustls`,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; -- 边界检查以 Cargo metadata 的解码结果看护全部直接 consumer,并检查 resolved Reqwest feature union,防止传递依赖重新激活 Native TLS; -- 不并列启用 native-tls 兼容栈。只有真实产品场景无法由 Rustls 平台证书验证承载时,才以明确行为证据评审替换方案,而不是重新叠加第二后端。 +- 真正创建 client 的 app、service 或 adapter 必须在自身依赖声明中显式选择实际使用的 Reqwest feature 和 provider-neutral 的 `reqwest/rustls-no-provider`;只使用 `reqwest::Url` 的 contract/assembly 路径不加载传输能力; +- capability crate 的每个 Reqwest owner feature 必须独立带齐自己的数据/传输 feature、`reqwest/rustls-no-provider` 和进程级 TLS provider owner,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; +- workspace 级 `rustls` 只统一兼容版本并关闭默认 feature;`services-core/tls-provider` 是内置 crypto provider 的唯一 owner,精确选择并安装 `ring`、`std` 和 `tls12`。产品进程入口或集中 client helper 必须在构造 TLS client 前确保该 provider 已安装; +- 边界检查以 Cargo metadata 的解码结果看护全部直接 consumer,并检查 resolved Reqwest/Rustls feature union,拒绝缺失 provider、同时选择多个 provider、传递依赖重新激活 AWS-LC 或 Native TLS,以及绕过集中 helper 的 Reqwest client 构造; +- 不并列启用 Native TLS 或 AWS-LC 兼容栈。只有真实产品场景无法由当前 Ring/Rustls 平台证书验证承载时,才以明确行为证据评审替换方案;替换时由同一 owner 切换 provider,不能在同一产品闭包叠加第二后端。 ### 3.5 稳定契约 crate 按消费能力切片 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 4fef34316e..15c84b6ede 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -188,6 +188,71 @@ test('Agent Runtime leaf capabilities have one managed feature and source contra } }); +test('Server canonical Agent Runtime ownership is protected by source boundary rules', async () => { + const bootstrapPath = 'src/apps/server/src/bootstrap.rs'; + const mainPath = 'src/apps/server/src/main.rs'; + const bootstrapRequired = requiredContentRules.find((rule) => rule.path === bootstrapPath); + const bootstrapForbidden = forbiddenContentRules.find((rule) => rule.path === bootstrapPath); + const mainRequired = requiredContentRules.find((rule) => rule.path === mainPath); + const mainForbidden = forbiddenContentRules.find((rule) => rule.path === mainPath); + + assert.ok(bootstrapRequired, 'Server bootstrap must have a required ownership contract'); + assert.ok(bootstrapForbidden, 'Server bootstrap must reject duplicate Runtime assembly'); + assert.ok(mainRequired, 'Server main must retain the product event owner'); + assert.ok(mainForbidden, 'Server main must reject an unowned event source'); + + const [bootstrapSource, mainSource] = await Promise.all([ + readFile(new URL('../src/apps/server/src/bootstrap.rs', import.meta.url), 'utf8'), + readFile(new URL('../src/apps/server/src/main.rs', import.meta.url), 'utf8'), + ]); + for (const pattern of bootstrapRequired.patterns) { + assert.match(bootstrapSource, pattern.regex, pattern.message); + } + for (const pattern of mainRequired.patterns) { + assert.match(mainSource, pattern.regex, pattern.message); + } + assert.match( + 'let queue = EventQueue::new(Default::default());', + bootstrapForbidden.patterns[0].regex, + ); + assert.match( + 'let source = AgentEventSource::new(event_queue);', + mainForbidden.patterns[0].regex, + ); +}); + +test('TLS source boundaries reject bypasses of the centralized provider owner', () => { + const integrationsRule = forbiddenContentUnderRules.find((rule) => + rule.path === 'src/crates/services/services-integrations/src' + && rule.reason.includes('provider-initializing Reqwest constructors')); + const providerRule = forbiddenContentUnderRules.find((rule) => + rule.path === 'src' + && rule.reason.includes('only owner allowed to install')); + assert.ok(integrationsRule, 'integration Reqwest constructors must be guarded'); + assert.ok(providerRule, 'direct Rustls provider installation must be guarded'); + + assert.ok(integrationsRule.patterns.some((pattern) => pattern.regex.test( + 'use reqwest::Client;\nfn client() { let _ = Client::builder(); }', + ))); + assert.ok(integrationsRule.patterns.some((pattern) => pattern.regex.test( + 'use reqwest::{Client as HttpClient, Url};\nfn client() { let _ = HttpClient::new(); }', + ))); + assert.ok(integrationsRule.patterns.some((pattern) => pattern.regex.test( + 'fn client() { let _ = reqwest::ClientBuilder::new(); }', + ))); + assert.ok(integrationsRule.patterns.some((pattern) => pattern.regex.test( + 'use reqwest as http;\nfn client() { let _ = http::Client::new(); }', + ))); + assert.match( + 'rustls::crypto::ring::default_provider().install_default();', + providerRule.patterns[0].regex, + ); + assert.doesNotMatch( + 'unrelated_component.install_default();', + providerRule.patterns[0].regex, + ); +}); + test('Core and ACP defaults preserve their explicit assembly contracts', async () => { const [coreManifest, acpManifest] = await Promise.all([ readFile(new URL('../src/crates/assembly/core/Cargo.toml', import.meta.url), 'utf8'), @@ -2640,11 +2705,11 @@ test('services integrations Reqwest policy uses Cargo-decoded feature references const pkg = servicesIntegrationsPackage(` [features] reqwest = ["dep:reqwest"] -announcement = ["reqwest", "reqwest/rustls"] +announcement = ["reqwest", "reqwest/rustls-no-provider"] file-watch = ["reqwest?/__native-tls"] -mcp = ["reqwest", "reqwest/rustls", "reqwest/json"] -models-dev = ["reqwest", "reqwest/rustls", "reqwest/system-proxy"] -speech = ["reqwest", "reqwest/rustls", "reqwest/http3"] +mcp = ["reqwest", "reqwest/rustls-no-provider", "reqwest/json"] +models-dev = ["reqwest", "reqwest/rustls-no-provider", "reqwest/system-proxy"] +speech = ["reqwest", "reqwest/rustls-no-provider", "reqwest/http3"] `); const messages = findServicesIntegrationsReqwestFeatureViolations(pkg) @@ -2655,6 +2720,26 @@ speech = ["reqwest", "reqwest/rustls", "reqwest/http3"] assert.match(messages, /mcp.*missing Reqwest feature reference reqwest\/stream/); assert.doesNotMatch(messages, /models-dev.*system-proxy/); assert.match(messages, /speech.*unreviewed Reqwest feature reference reqwest\/http3/); + assert.doesNotMatch(messages, /missing reqwest\/rustls\b/); + assert.doesNotMatch(messages, /unreviewed.*reqwest\/rustls-no-provider/); +}); + +test('every services integrations Reqwest owner activates the reviewed TLS provider', async () => { + const manifest = await readFile( + new URL('../src/crates/services/services-integrations/Cargo.toml', import.meta.url), + 'utf8', + ); + const mutated = removeFeatureValue( + manifest, + 'review-platform', + 'bitfun-services-core/tls-provider', + ); + assert.notEqual(mutated, manifest, 'review-platform must own the TLS provider in the fixture'); + + const messages = findServicesIntegrationsReqwestFeatureViolations( + servicesIntegrationsPackage(mutated), + ).map((violation) => violation.message).join('\n'); + assert.match(messages, /review-platform.*missing bitfun-services-core\/tls-provider/); }); test('direct Reqwest clients reject extra decoded dependency and package features', () => { @@ -2667,7 +2752,7 @@ test('direct Reqwest clients reject extra decoded dependency and package feature features: [ 'http2', 'stream', - 'rustls', + 'rustls-no-provider', '__native-tls', ], }]), @@ -2691,26 +2776,42 @@ test('direct Reqwest clients reject extra decoded dependency and package feature test('AI adapters Reqwest profile owns the supported SOCKS transport', () => { const baseFeatures = ['http2', 'json', 'stream']; const valid = { - ...packageAt('bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [{ - name: 'reqwest', - kind: null, - optional: false, - uses_default_features: false, - features: [...baseFeatures, 'rustls', 'socks'], - }]), + ...packageAt('bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [ + { + name: 'reqwest', + kind: null, + optional: false, + uses_default_features: false, + features: [...baseFeatures, 'rustls-no-provider', 'socks'], + }, + { + name: 'bitfun-services-core', + kind: null, + optional: false, + features: ['tls-provider'], + }, + ]), features: { 'subscription-auth': ['reqwest/form'] }, }; const missingSocks = { ...packageAt( 'bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', - [{ - name: 'reqwest', - kind: null, - optional: false, - uses_default_features: false, - features: [...baseFeatures, 'rustls'], - }], + [ + { + name: 'reqwest', + kind: null, + optional: false, + uses_default_features: false, + features: [...baseFeatures, 'rustls-no-provider'], + }, + { + name: 'bitfun-services-core', + kind: null, + optional: false, + features: ['tls-provider'], + }, + ], ), features: { 'subscription-auth': ['reqwest/form'] }, }; @@ -2783,8 +2884,11 @@ test('Reqwest consumers inherit the workspace version without duplicating featur assert.equal(rules.length, 7); for (const rule of rules) { const pattern = rule.patterns[0].regex; - assert.match('reqwest = { workspace = true, features = ["rustls"] }', pattern); - assert.doesNotMatch('reqwest = { version = "99", features = ["rustls"] }', pattern); + assert.match('reqwest = { workspace = true, features = ["rustls-no-provider"] }', pattern); + assert.doesNotMatch( + 'reqwest = { version = "99", features = ["rustls-no-provider"] }', + pattern, + ); } }); @@ -2977,6 +3081,11 @@ test('resolved Reqwest feature union rejects every native TLS backend alias', () version: '0.12.28', features: ['rustls-tls', 'default-tls'], }, + { + name: 'rustls', + version: '0.23.42', + features: ['ring', 'std'], + }, ], { root: TEST_ROOT }, ); @@ -2987,6 +3096,132 @@ test('resolved Reqwest feature union rejects every native TLS backend alias', () assert.match(messages, /reqwest 0\.12\.28.*default-tls/); }); +test('resolved Reqwest feature union rejects the AWS-LC selecting Rustls alias', () => { + const violations = findResolvedReqwestNativeTlsViolations( + [{ + name: 'reqwest', + version: '0.13.4', + features: ['rustls', 'rustls-no-provider'], + }, { + name: 'rustls', + version: '0.23.42', + features: ['ring', 'std'], + }], + { root: TEST_ROOT }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /rustls.*AWS-LC/); +}); + +test('resolved Rustls feature union rejects multiple crypto providers', () => { + const violations = findResolvedReqwestNativeTlsViolations( + [ + { + name: 'reqwest', + version: '0.13.4', + features: ['rustls-no-provider'], + }, + { + name: 'rustls', + version: '0.23.42', + features: ['aws_lc_rs', 'ring', 'std'], + }, + ], + { root: TEST_ROOT }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /only the ring crypto provider.*aws_lc_rs, ring/); +}); + +test('resolved Rustls feature union normalizes and rejects AWS-LC aliases once', () => { + const violations = findResolvedReqwestNativeTlsViolations( + [ + { + name: 'reqwest', + version: '0.13.4', + features: ['rustls-no-provider'], + }, + { + name: 'rustls', + version: '0.23.42', + features: ['aws-lc-rs', 'aws_lc_rs', 'std'], + }, + ], + { root: TEST_ROOT }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /selects aws_lc_rs$/); +}); + +test('resolved Rustls feature union rejects providers split across Rustls versions', () => { + const violations = findResolvedReqwestNativeTlsViolations( + [ + { + name: 'reqwest', + version: '0.13.4', + features: ['rustls-no-provider'], + }, + { + name: 'rustls', + version: '0.23.42', + features: ['ring', 'std'], + }, + { + name: 'rustls', + version: '0.22.4', + features: ['aws_lc_rs', 'std'], + }, + ], + { root: TEST_ROOT }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /only the ring crypto provider.*aws_lc_rs, ring/); +}); + +test('resolved Reqwest Rustls closure rejects a missing crypto provider', () => { + const violations = findResolvedReqwestNativeTlsViolations( + [{ + name: 'reqwest', + version: '0.13.4', + features: ['rustls-no-provider'], + }], + { root: TEST_ROOT }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /selects \(no provider\)/); +}); + +test('resolved Reqwest Rustls closure rejects linked AWS-LC packages', () => { + const violations = findResolvedReqwestNativeTlsViolations( + [ + { + name: 'reqwest', + version: '0.13.4', + features: ['rustls-no-provider'], + }, + { + name: 'rustls', + version: '0.23.42', + features: ['ring', 'std'], + }, + { + name: 'aws-lc-rs', + version: '1.13.3', + features: [], + }, + ], + { root: TEST_ROOT }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /aws_lc_rs, ring/); +}); + test('Cargo metadata Tokio policy catches table-style and renamed full dependencies', () => { const pkg = packageAt('table-style', 'src/crates/services/table-style/Cargo.toml', [{ name: 'tokio', diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index 4ead104878..ffab49fcef 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -250,6 +250,11 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => 'build-impact', 'Rust validation must not wait for the frontend build', ); + assert.deepEqual( + rustJob.strategy.matrix.os, + ['ubuntu-latest', 'macos-15', 'windows-latest'], + 'Rust validation must retain the reviewed Linux, macOS, and Windows matrix', + ); assert.equal( rustJob.steps.some((step) => step.uses?.startsWith('actions/download-artifact@')), false, @@ -376,6 +381,12 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => const coreLibraryTests = rustJob.steps.find( (step) => step.name === 'Run core library tests', ); + const linuxFullCoreLibraryTests = rustJob.steps.find( + (step) => step.name === 'Run full core library tests on Linux', + ); + const coreLibraryTestSteps = rustJob.steps.filter( + (step) => /^cargo test --locked -p bitfun-core\b.*\s--lib$/.test(step.run ?? ''), + ); const desktopLibraryTests = rustJob.steps.find( (step) => step.name === 'Run desktop library tests', ); @@ -385,10 +396,36 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => const productControlContracts = rustJob.steps.find( (step) => step.name === 'Run product-control domain and delivery-profile contracts', ); + assert.equal( + coreLibraryTests?.if, + "runner.os != 'Linux'", + ); assert.equal( coreLibraryTests?.run, 'cargo test --locked -p bitfun-core --lib', ); + assert.equal( + linuxFullCoreLibraryTests?.if, + "runner.os == 'Linux'", + ); + assert.equal( + linuxFullCoreLibraryTests?.run, + 'cargo test --locked -p bitfun-core --features product-full --lib', + ); + assert.deepEqual( + coreLibraryTestSteps.map((step) => ({ if: step.if, run: step.run })), + [ + { + if: "runner.os != 'Linux'", + run: 'cargo test --locked -p bitfun-core --lib', + }, + { + if: "runner.os == 'Linux'", + run: 'cargo test --locked -p bitfun-core --features product-full --lib', + }, + ], + 'Core library validation must contain exactly the reviewed complementary steps', + ); assert.equal(desktopLibraryTests?.if, "runner.os != 'Windows'"); assert.equal( desktopLibraryTests?.run, diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 8a4f7c94f2..665732809e 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -303,28 +303,32 @@ const REQWEST_PACKAGE_PROFILES = new Map([ servicesOwners: true, }], ['bitfun-ai-adapters', { - dependencyFeatures: ['http2', 'json', 'rustls', 'socks', 'stream'], + dependencyFeatures: ['http2', 'json', 'rustls-no-provider', 'socks', 'stream'], optional: false, allowedPackageFeatureRefs: new Set(['reqwest/form']), requiredPackageFeatureRefs: new Map([ ['subscription-auth', new Set(['reqwest/form'])], ]), + tlsProviderDependency: 'bitfun-services-core', }], ['bitfun-cli', { - dependencyFeatures: ['http2', 'rustls', 'stream'], + dependencyFeatures: ['http2', 'rustls-no-provider', 'stream'], optional: false, + tlsProviderDependency: 'bitfun-services-core', }], ['bitfun-desktop', { - dependencyFeatures: ['http2', 'json', 'query', 'rustls', 'stream'], + dependencyFeatures: ['http2', 'json', 'query', 'rustls-no-provider', 'stream'], optional: false, }], ['bitfun-miniapp-market-service', { - dependencyFeatures: ['form', 'http2', 'json', 'rustls'], + dependencyFeatures: ['form', 'http2', 'json', 'rustls-no-provider'], optional: false, + tlsProviderDependency: 'bitfun-services-core', }], ['bitfun-skin-market-service', { - dependencyFeatures: ['http2', 'json', 'rustls'], + dependencyFeatures: ['http2', 'json', 'rustls-no-provider'], optional: false, + tlsProviderDependency: 'bitfun-services-core', }], ]); @@ -423,6 +427,26 @@ function findReqwestPackageProfileViolations(pkg, profile) { } } + if (profile.tlsProviderDependency) { + const providerDependencies = (pkg.dependencies ?? []).filter( + (candidate) => candidate.name === profile.tlsProviderDependency + && (candidate.kind ?? null) === null + && (candidate.target ?? null) === null, + ); + if ( + providerDependencies.length !== 1 + || !(providerDependencies[0].features ?? []).includes('tls-provider') + ) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: + `${pkg.name} must select ${profile.tlsProviderDependency}/tls-provider ` + + 'for its provider-neutral Reqwest client', + }); + } + } + return violations; } @@ -930,7 +954,7 @@ export function findResolvedReqwestNativeTlsViolations(records, { root }) { }]; } - return reqwestRecords.flatMap((record) => { + const reqwestViolations = reqwestRecords.flatMap((record) => { const nativeTlsFeatures = (record.features ?? []).filter( (feature) => feature === 'default-tls' @@ -939,7 +963,12 @@ export function findResolvedReqwestNativeTlsViolations(records, { root }) { || feature === 'native-tls' || feature.startsWith('native-tls-'), ); - if (nativeTlsFeatures.length === 0) { + const providerSelectingFeatures = record.version.startsWith('0.13.') + && (record.features ?? []).includes('rustls') + ? ['rustls (selects AWS-LC)'] + : []; + const unreviewedTlsFeatures = [...nativeTlsFeatures, ...providerSelectingFeatures]; + if (unreviewedTlsFeatures.length === 0) { return []; } return [{ @@ -947,9 +976,35 @@ export function findResolvedReqwestNativeTlsViolations(records, { root }) { line: 1, message: `resolved reqwest ${record.version} feature union enables an unreviewed TLS backend: ` - + nativeTlsFeatures.join(', '), + + unreviewedTlsFeatures.join(', '), }]; }); + + const rustlsRecords = records.filter((record) => record.name === 'rustls'); + const selectedRustlsProviders = new Set(rustlsRecords + .flatMap((record) => (record.features ?? []) + .filter((feature) => feature === 'ring' || feature === 'aws_lc_rs' || feature === 'aws-lc-rs') + .map((feature) => feature === 'ring' ? feature : 'aws_lc_rs'))); + if (records.some((record) => record.name === 'aws-lc-rs' || record.name === 'aws-lc-sys')) { + selectedRustlsProviders.add('aws_lc_rs'); + } + const sortedRustlsProviders = [...selectedRustlsProviders].sort(); + const rustlsVersions = [...new Set(rustlsRecords.map((record) => record.version))].sort(); + const reqwestUsesRustls = reqwestRecords.some((record) => + (record.features ?? []).some((feature) => feature.includes('rustls'))); + const rustlsProviderViolations = !reqwestUsesRustls + || (sortedRustlsProviders.length === 1 && sortedRustlsProviders[0] === 'ring') + ? [] + : [{ + path: join(root, 'Cargo.toml'), + line: 1, + message: + `resolved Reqwest/Rustls closure must select only the ring crypto provider; ` + + `rustls ${rustlsVersions.join(', ') || '(missing)'} selects ` + + (sortedRustlsProviders.join(', ') || '(no provider)'), + }]; + + return [...reqwestViolations, ...rustlsProviderViolations]; } export function findServicesIntegrationsReqwestFeatureViolations(pkg) { @@ -987,11 +1042,18 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { message: `${pkg.name}:${featureName} must explicitly enable reqwest`, }); } - if (!references.includes('reqwest/rustls')) { + if (!references.includes('reqwest/rustls-no-provider')) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${featureName} is missing reqwest/rustls-no-provider`, + }); + } + if (!references.includes('bitfun-services-core/tls-provider')) { violations.push({ path: pkg.manifest_path, line: 1, - message: `${pkg.name}:${featureName} is missing reqwest/rustls`, + message: `${pkg.name}:${featureName} is missing bitfun-services-core/tls-provider`, }); } for (const reference of ownerFeatureReferences.get(featureName) ?? []) { @@ -1026,7 +1088,7 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { const allowedReferences = new Set([ 'reqwest', 'dep:reqwest', - 'reqwest/rustls', + 'reqwest/rustls-no-provider', ...(ownerFeatureReferences.get(featureName) ?? []), ]); for (const reference of reqwestReferences) { diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index ed75390271..292ce6802f 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -66,6 +66,7 @@ export const optionalDependencyFeatureOwnerRules = [ ], }, { depName: 'rusqlite', ownerFeatures: ['permission', 'session-search'] }, + { depName: 'rustls', ownerFeatures: ['tls-provider'] }, { depName: 'serde_yaml', ownerFeatures: ['markdown', 'workspace-instructions'] }, { depName: 'similar', ownerFeatures: ['diff', 'local-storage'] }, { @@ -266,7 +267,24 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', - ownerFeatures: ['browser-control', 'git', 'hook-import', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh', 'review-platform', 'workspace-search'], + ownerFeatures: [ + 'announcement', + 'browser-control', + 'git', + 'hook-import', + 'mcp', + 'miniapp-market', + 'miniapp-runtime', + 'models-dev', + 'process-tree', + 'remote-connect', + 'remote-ssh', + 'remote-ssh-concrete', + 'review-platform', + 'speech', + 'web-tools', + 'workspace-search', + ], }, { depName: 'bzip2', ownerFeatures: ['speech'] }, { depName: 'chrono', ownerFeatures: ['git', 'miniapp-market', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech'] }, @@ -1321,6 +1339,13 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'services-core default profile must stay empty so consumers select capabilities explicitly', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'tls-provider', + requiredFeatureRefs: ['dep:rustls'], + exact: true, + reason: 'services-core TLS provider owner must select only the workspace Rustls implementation', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'diagnostics', diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 9a4caf3e15..21882de3c1 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -8,6 +8,17 @@ const agentRuntimeRootUnexpectedLine = new RegExp( ); export const forbiddenContentRules = [ + { + path: 'Cargo.toml', + reason: + 'workspace Rustls owns only the compatible version; concrete provider features belong to services-core', + patterns: [ + { + regex: /^rustls\s*=\s*\{[^\n}]*,\s*features\s*=/m, + message: 'root workspace Rustls dependency must not select crypto-provider capabilities', + }, + ], + }, { path: 'src/crates/execution/agent-runtime/src/lib.rs', reason: @@ -3967,6 +3978,39 @@ export const forbiddenContentRules = [ }, ], }, + { + path: 'src/apps/server/src/bootstrap.rs', + reason: + 'Server must not assemble a second Agent Runtime beside the canonical Core owner', + patterns: [ + { + regex: /\b(?:EventQueue|EventRouter|SessionManager|ToolPipeline|ExecutionEngine|ConversationCoordinator)::new\s*\(/, + message: 'Server bootstrap must not directly construct canonical Agent Runtime components', + }, + ], + }, + { + path: 'src/apps/server/src/main.rs', + reason: + 'Server must retain the product event-queue owner instead of bypassing legacy queue draining', + patterns: [ + { + regex: /\bAgentEventSource::new\s*\(/, + message: 'Server must not construct an unowned Agent event source', + }, + ], + }, + { + path: 'src/apps/desktop/src/api/app_state.rs', + reason: + 'token usage publication belongs to the canonical Core Agent Runtime initializer for every embedded host', + patterns: [ + { + regex: /\bset_global_token_usage_service\s*\(/, + message: 'Desktop AppState must not re-own canonical token usage publication', + }, + ], + }, ]; export const rustWebUiSourceBoundaryRule = { @@ -4375,4 +4419,36 @@ export const forbiddenContentUnderRules = [ }, ], }, + { + path: 'src/crates/services/services-integrations/src', + reason: + 'integration modules must use the crate-level provider-initializing Reqwest constructors', + patterns: [ + { + regex: /\breqwest::(?:get|Client::(?:new|builder)|ClientBuilder::new)\s*\(/, + allowPaths: ['src/crates/services/services-integrations/src/lib.rs'], + message: 'use crate::reqwest_client or crate::reqwest_client_builder', + }, + { + regex: /use\s+reqwest(?:::(?:Client|ClientBuilder)|::\{[^}]*\b(?:Client|ClientBuilder)\b[^}]*\})[^;]*;/, + message: 'keep Reqwest client types fully qualified and construct them through crate-level TLS helpers', + }, + { + regex: /use\s+reqwest\s+as\s+\w+\s*;/, + message: 'do not alias Reqwest around the centralized client-construction guard', + }, + ], + }, + { + path: 'src', + reason: + 'services-core is the only owner allowed to install the process-wide Rustls provider', + patterns: [ + { + regex: /\brustls::crypto::(?:ring|aws_lc_rs)\b/, + allowPaths: ['src/crates/services/services-core/src/tls_provider.rs'], + message: 'delegate built-in Rustls provider selection to services-core::tls_provider', + }, + ], + }, ]; diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 900dcbae49..9963951f42 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -10090,4 +10090,119 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/apps/server/src/bootstrap.rs', + reason: + 'Server must remain on the canonical Core Agent Runtime and product event-queue owners', + patterns: [ + { + regex: /init_agentic_system_for_profile_with_runtime_ownership\s*\(/, + message: 'Server bootstrap must use the canonical Core Agent Runtime initializer', + }, + { + regex: /CoreProductEventQueueOwner::new\s*\(/, + message: 'Server bootstrap must retain the product event-queue owner', + }, + ], + }, + { + path: 'src/apps/server/src/main.rs', + reason: + 'Server App Server event projection must consume the product-owned runtime source', + patterns: [ + { + regex: /agent_event_queue_owner\.runtime_source\s*\(\s*\)/, + message: 'Server must consume events through CoreProductEventQueueOwner', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/system.rs', + reason: + 'the canonical Core Agent Runtime initializer must publish its token usage service for every embedded host', + patterns: [ + { + regex: /set_global_token_usage_service\s*\(\s*token_usage_service\.clone\s*\(\s*\)\s*\)/, + message: 'canonical Agent Runtime initialization must publish token usage globally', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/tls_provider.rs', + reason: + 'provider-neutral TLS clients must share the services-owned process-level ring selection', + patterns: [ + { + regex: /rustls::crypto::ring::default_provider\s*\(\s*\)\.install_default\s*\(\s*\)/, + message: 'services-core TLS owner must install the workspace-owned ring provider', + }, + ], + }, + { + path: 'src/crates/services/services-core/Cargo.toml', + reason: + 'services-core must own the exact Rustls provider and protocol feature selection', + patterns: [ + { + regex: /rustls = \{ workspace = true, features = \["ring", "std", "tls12"\], optional = true \}/, + message: 'services-core TLS owner must select only ring, std, and TLS 1.2 support', + }, + ], + }, + { + path: 'src/crates/services/services-integrations/src/lib.rs', + reason: + 'integration clients must initialize the reviewed TLS provider through centralized constructors', + patterns: [ + { + regex: /fn reqwest_client_builder[\s\S]*?ensure_ring_crypto_provider\s*\(\s*\)/, + message: 'integration Reqwest builder must initialize the ring provider', + }, + { + regex: /fn reqwest_client\s*\([^)]*\)[\s\S]*?ensure_ring_crypto_provider\s*\(\s*\)/, + message: 'integration default Reqwest client must initialize the ring provider', + }, + ], + }, + { + path: 'src/crates/adapters/ai-adapters/src/client/http.rs', + reason: 'AI transport clients must initialize the reviewed TLS provider before Reqwest', + patterns: [ + { + regex: /fn create_http_client[\s\S]*?ensure_ring_crypto_provider\s*\(\s*\)[\s\S]*?Client::builder\s*\(/, + message: 'AI client construction must initialize the ring provider first', + }, + ], + }, + { + path: 'src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs', + reason: + 'subscription authentication clients must initialize the same reviewed TLS provider as normal AI transport', + patterns: [ + { + regex: /fn build_http_client[\s\S]*?ensure_ring_crypto_provider\s*\(\s*\)[\s\S]*?reqwest::Client::builder\s*\(/, + message: 'subscription HTTP client construction must initialize the ring provider first', + }, + ], + }, + { + path: 'src/crates/services/miniapp-market-service/src/auth.rs', + reason: 'MiniApp market HTTP clients must initialize the reviewed TLS provider', + patterns: [ + { + regex: /fn new\s*\([^)]*MarketConfig[\s\S]*?ensure_ring_crypto_provider\s*\(\s*\)[\s\S]*?reqwest::Client::builder\s*\(/, + message: 'MiniApp market AuthService must initialize the ring provider first', + }, + ], + }, + { + path: 'src/crates/services/skin-market-service/src/auth.rs', + reason: 'appearance market HTTP clients must initialize the reviewed TLS provider', + patterns: [ + { + regex: /fn new\s*\([^)]*Url[\s\S]*?ensure_ring_crypto_provider\s*\(\s*\)[\s\S]*?Client::builder\s*\(/, + message: 'appearance market IdentityVerifier must initialize the ring provider first', + }, + ], + }, ]; diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 36aa5262af..aa78508ce2 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -60,7 +60,7 @@ bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } bitfun-app-server = { path = "../../crates/interfaces/app-server" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "git-port", "permission", "plugin-runtime", "workspace-ports"] } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } -bitfun-services-core = { path = "../../crates/services/services-core", features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership"] } +bitfun-services-core = { path = "../../crates/services/services-core", features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership", "tls-provider"] } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["external-sources"] } @@ -120,7 +120,7 @@ fs2 = { workspace = true } base64 = { workspace = true } image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] } minisign-verify = "0.2" -reqwest = { workspace = true, features = ["http2", "rustls", "stream"] } +reqwest = { workspace = true, features = ["http2", "rustls-no-provider", "stream"] } sha2 = { workspace = true } tar = { workspace = true } tempfile = "3" diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index baca196035..7eb1f00a6d 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1677,9 +1677,9 @@ async fn run_interactive_with_session( } fn main() { - // Install rustls CryptoProvider before any TLS-capable work (relay WS, - // reqwest rustls paths, Feishu wss). Required when both ring and aws-lc-rs - // are linked: rustls cannot auto-select a provider. + // Install the workspace-owned ring CryptoProvider before any TLS-capable + // work (relay WS, reqwest rustls paths, Feishu wss) so every client uses + // the same explicit process-level provider. bitfun_core::service::remote_connect::ensure_rustls_crypto_provider(); let worker = std::thread::Builder::new() diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index 7f17be4526..cdec225042 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -671,6 +671,7 @@ fn platform_asset<'a>( } fn build_client() -> Result { + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); Client::builder() .connect_timeout(Duration::from_secs(8)) // Deliberately no `.timeout()`: a whole-request ceiling turns "slow" into diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 6fc396bd70..6b44ddf8f3 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -65,7 +65,7 @@ dark-light = { workspace = true } similar = { workspace = true } ignore = { workspace = true } urlencoding = { workspace = true } -reqwest = { workspace = true, features = ["http2", "json", "query", "rustls", "stream"] } +reqwest = { workspace = true, features = ["http2", "json", "query", "rustls-no-provider", "stream"] } semver = { workspace = true } zip = { workspace = true } tar = { workspace = true } diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 838d07c9f8..43359e5b55 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -104,11 +104,6 @@ impl AppState { ) -> BitFunResult { let start_time = std::time::Instant::now(); - // Make the usage store reachable from tools that call the model outside - // the round executor (e.g. `analyze_image`), so their consumption is - // included in the usage statistics. - token_usage::set_global_token_usage_service(token_usage_service.clone()); - let config_service = config::get_global_config_service().await.map_err(|e| { BitFunError::config(format!("Failed to get global config service: {}", e)) })?; diff --git a/src/apps/desktop/src/api/relay_deploy_api.rs b/src/apps/desktop/src/api/relay_deploy_api.rs index 207c06d61d..0150dc83df 100644 --- a/src/apps/desktop/src/api/relay_deploy_api.rs +++ b/src/apps/desktop/src/api/relay_deploy_api.rs @@ -158,6 +158,7 @@ pub async fn relay_deploy_verify(relay_url: String) -> Result Result Vec { + crate::ensure_rustls_crypto_provider(); let client = match reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(5)) .read_timeout(PROBE_WINDOW) diff --git a/src/apps/desktop/src/embedded_relay_host.rs b/src/apps/desktop/src/embedded_relay_host.rs index 72fd793762..e663084eee 100644 --- a/src/apps/desktop/src/embedded_relay_host.rs +++ b/src/apps/desktop/src/embedded_relay_host.rs @@ -294,6 +294,7 @@ mod tests { let host = DesktopEmbeddedRelayHost::default(); let port = start_on_free_port(&host, Some(static_dir.to_string_lossy().into_owned())).await; + crate::ensure_rustls_crypto_provider(); let client = reqwest::Client::new(); let index = client .get(format!("http://127.0.0.1:{port}/")) diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 1f7c767b60..2f21d62ff7 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -94,6 +94,10 @@ use startup_trace::{DesktopStartupTrace, DesktopStartupTraceSnapshot}; pub(crate) const PLUGIN_HOST_LAUNCH_POLICY: bitfun_core::plugin_host::PluginHostLaunchPolicy = bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled; +pub(crate) fn ensure_rustls_crypto_provider() { + bitfun_core::service::remote_connect::ensure_rustls_crypto_provider(); +} + /// Agentic Coordinator state #[derive(Clone)] pub struct CoordinatorState { @@ -589,7 +593,7 @@ pub async fn run() { // Install the rustls ring CryptoProvider as the process-level default early, // so that all subsequent TLS operations (relay_client, reqwest, tokio-tungstenite) // reuse the same provider instead of each attempting their own install_default(). - bitfun_core::service::remote_connect::ensure_rustls_crypto_provider(); + ensure_rustls_crypto_provider(); eprintln!("=== BitFun Desktop Starting ==="); diff --git a/src/apps/sdk-host/Cargo.toml b/src/apps/sdk-host/Cargo.toml index afa6bddf81..3a483fe6e6 100644 --- a/src/apps/sdk-host/Cargo.toml +++ b/src/apps/sdk-host/Cargo.toml @@ -30,10 +30,10 @@ bitfun-core = { path = "../../crates/assembly/core", features = [ bitfun-sdk-host = { path = "../../crates/interfaces/sdk-host" } bitfun-services-core = { path = "../../crates/services/services-core", features = [ "process-runtime", + "tls-provider", ] } bitfun-transport = { path = "../../crates/adapters/transport" } futures-util = { workspace = true } -rustls = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi-thread", "sync", "time"] } tokio-util = { workspace = true, features = ["codec"] } @@ -42,6 +42,7 @@ tracing-subscriber = { workspace = true } url = { workspace = true } [dev-dependencies] +rustls = { workspace = true } tempfile = "3" tokio = { workspace = true, features = ["process"] } diff --git a/src/apps/sdk-host/src/lib.rs b/src/apps/sdk-host/src/lib.rs index 062de67bb9..1c133d690c 100644 --- a/src/apps/sdk-host/src/lib.rs +++ b/src/apps/sdk-host/src/lib.rs @@ -11,7 +11,7 @@ pub const SDK_HOST_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; /// Installs process-global prerequisites before any service or descendant starts. pub fn initialize_process_runtime() -> std::io::Result<()> { bitfun_services_core::process_manager::contain_current_process_tree()?; - let _ = rustls::crypto::ring::default_provider().install_default(); + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); Ok(()) } diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index 17dd01c35e..f836c503f8 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -2,9 +2,13 @@ //! //! Mirrors the Desktop app's init sequence without any Tauri dependency. -use bitfun_core::agentic::*; +use bitfun_core::agentic::{agents, coordination, system, tools}; use bitfun_core::infrastructure::ai::AIClientFactory; use bitfun_core::infrastructure::try_get_path_manager_arc; +use bitfun_core::product_runtime::{ + ensure_product_dialog_scheduler, CoreProductEventQueueOwner, CoreRuntimeServicesProvider, +}; +use bitfun_core::runtime_ownership::CoreRuntimeOwnership; use bitfun_core::service::{config, filesystem, mcp, token_usage, workspace}; use std::sync::Arc; use tokio::sync::RwLock; @@ -26,8 +30,7 @@ pub(crate) struct ServerAppState { pub token_usage_service: Arc, pub coordinator: Arc, pub scheduler: Arc, - pub event_queue: Arc, - pub event_router: Arc, + pub agent_event_queue_owner: CoreProductEventQueueOwner, pub tool_registry_snapshot: Arc>>, pub start_time: std::time::Instant, } @@ -38,9 +41,7 @@ pub(crate) struct ServerAppState { pub(crate) async fn initialize(workspace: Option) -> anyhow::Result> { log::info!("Initializing BitFun server core services"); - bitfun_core::agentic::system::select_agentic_system_profile( - bitfun_core::agentic::system::DeliveryProfile::ProductFull, - )?; + system::select_agentic_system_profile(system::DeliveryProfile::ProductFull)?; // 1. Global config config::initialize_global_config().await?; @@ -64,87 +65,27 @@ pub(crate) async fn initialize(workspace: Option) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result Some(Arc::new(service)), + Ok(service) => { + let service = Arc::new(service); + mcp::set_global_mcp_service(service.clone()); + Some(service) + } Err(e) => { log::warn!("Failed to initialize MCP service: {}", e); None @@ -188,7 +134,7 @@ pub(crate) async fn initialize(workspace: Option) -> anyhow::Result) -> anyhow::Result { - log::info!( - "Workspace opened: name={}, path={}", - info.name, - info.root_path.display() - ); - Some(info.root_path) - } - Err(e) => { - log::error!("Failed to open workspace '{}': {}", ws_path, e); - None - } - } + .map_err(|error| { + anyhow::anyhow!("Failed to open workspace '{}': {}", ws_path, error) + })?; + log::info!( + "Workspace opened: name={}, path={}", + info.name, + info.root_path.display() + ); + Some(info.root_path) } else { // Try to restore last workspace workspace_service @@ -230,8 +171,7 @@ pub(crate) async fn initialize(workspace: Option) -> anyhow::Result, @@ -90,6 +90,7 @@ async fn main() -> Result<()> { .init(); tracing::info!("BitFun Server v{}", env!("CARGO_PKG_VERSION")); + bitfun_core::service::remote_connect::ensure_rustls_crypto_provider(); let args = ServerArgs::parse(); let external_workspace_root = args @@ -128,11 +129,9 @@ async fn main() -> Result<()> { server_state.token_usage_service.clone(), ) .map_err(|error| anyhow::anyhow!("Failed to build agent runtime: {error}"))?; - // The event source wraps the same `EventQueue` the coordinator publishes to; - // each connection's `serve` main loop subscribes independently and projects - // runtime events to the frontend shape before pushing them to the browser. - let event_source = - bitfun_agent_runtime::sdk::AgentEventSource::new(server_state.event_queue.clone()); + // The product owner keeps the legacy queue drained while each connection's + // `serve` loop independently subscribes to and projects Runtime events. + let event_source = server_state.agent_event_queue_owner.runtime_source(); let product_search = Arc::new( bitfun_core::product_runtime::CoreAgentRuntimeCompatibility::build( server_state.coordinator.clone(), @@ -165,10 +164,9 @@ async fn main() -> Result<()> { }) .collect::>>()?; - // This is a narrow controller/observer capability. It deliberately does - // not initialize the Server Host's dormant Agent Runtime: authoritative - // sessions and execution stay inside the target-side `bitfun dispatch` - // worker. + // Detached dispatch remains a narrow controller/observer capability beside + // the host-owned Agent Runtime. It keeps its SSH/process state separate and + // does not construct another Agent Runtime or widen the App Server scope. let path_manager = Arc::new(bitfun_core::infrastructure::PathManager::new()?); let ssh_data_dir = dirs::data_local_dir() .ok_or_else(|| anyhow::anyhow!("Could not resolve the local data directory"))? @@ -271,39 +269,4 @@ mod tests { assert!(normalize_browser_origin(invalid).is_err(), "{invalid}"); } } - - #[test] - fn agent_bootstrap_reuses_core_ownership_without_activating_the_http_shell() { - let bootstrap = include_str!("bootstrap.rs"); - assert!(bootstrap.contains("CoreRuntimeOwnership::embedded")); - let coordinator = bootstrap - .split("ConversationCoordinator::new") - .nth(1) - .and_then(|source| source.split(");").next()) - .expect("Server agent bootstrap Coordinator assembly"); - assert!(coordinator.contains("runtime_ownership")); - assert!(bootstrap.contains("open_workspace_with_runtime_ownership")); - assert!(!bootstrap.contains("initialize_snapshot_manager_for_workspace")); - - let rpc = include_str!("rpc_dispatcher.rs"); - let delete = rpc - .split("\"delete_session\" =>") - .nth(1) - .and_then(|source| source.split("\"start_dialog_turn\" =>").next()) - .expect("Server delete RPC"); - assert!(delete.contains("ensure_workspace_runtime_ownership")); - - let main_source = include_str!("main.rs") - .split("#[cfg(test)]") - .next() - .expect("Server production entrypoint"); - assert!( - !main_source.contains("bootstrap::initialize"), - "the current read-only HTTP shell must not silently start an Agent Runtime" - ); - assert!( - main_source.contains("DispatchHostState"), - "the lightweight Server Host should expose dispatch without booting an Agent Runtime" - ); - } } diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs index 86329e95fc..6244ae9ced 100644 --- a/src/apps/server/src/routes/dispatch.rs +++ b/src/apps/server/src/routes/dispatch.rs @@ -1,4 +1,4 @@ -//! Narrow detached-dispatch capability for the lightweight Server Host. +//! Narrow detached-dispatch capability for the loopback Server Host. //! //! This route owns no Agent Runtime and no target session. It only exposes the //! same platform-neutral controller used by Desktop, backed by saved SSH diff --git a/src/crates/adapters/ai-adapters/Cargo.toml b/src/crates/adapters/ai-adapters/Cargo.toml index dd676b6a14..38b25324e6 100644 --- a/src/crates/adapters/ai-adapters/Cargo.toml +++ b/src/crates/adapters/ai-adapters/Cargo.toml @@ -24,7 +24,7 @@ async-trait = { workspace = true } base64 = { workspace = true, optional = true } bitfun-agent-stream = { path = "../../execution/agent-stream" } bitfun-core-types = { path = "../../contracts/core-types" } -bitfun-services-core = { path = "../../services/services-core", optional = true } +bitfun-services-core = { path = "../../services/services-core", features = ["tls-provider"] } chrono = { workspace = true } dirs = { workspace = true, optional = true } keyring-core = { workspace = true, optional = true } @@ -33,7 +33,7 @@ futures = { workspace = true } fs2 = { workspace = true, optional = true } libc = { workspace = true, optional = true } log = { workspace = true } -reqwest = { workspace = true, features = ["http2", "json", "rustls", "socks", "stream"] } +reqwest = { workspace = true, features = ["http2", "json", "rustls-no-provider", "socks", "stream"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/src/crates/adapters/ai-adapters/src/client/http.rs b/src/crates/adapters/ai-adapters/src/client/http.rs index 3477831804..35e5b6a6d7 100644 --- a/src/crates/adapters/ai-adapters/src/client/http.rs +++ b/src/crates/adapters/ai-adapters/src/client/http.rs @@ -8,6 +8,7 @@ pub(crate) fn create_http_client( proxy_config: Option, skip_ssl_verify: bool, ) -> Client { + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); let mut builder = Client::builder() .tls_backend_rustls() .connect_timeout(std::time::Duration::from_secs( diff --git a/src/crates/adapters/ai-adapters/src/client/sse.rs b/src/crates/adapters/ai-adapters/src/client/sse.rs index 560f1da90e..4c8c29ff5c 100644 --- a/src/crates/adapters/ai-adapters/src/client/sse.rs +++ b/src/crates/adapters/ai-adapters/src/client/sse.rs @@ -585,7 +585,7 @@ mod tests { .expect("retry fixture should run"); }); let url = format!("http://{address}/chat/completions"); - let client = reqwest::Client::new(); + let client = crate::client::http::create_http_client(None, false); let request_body = serde_json::json!({"model": "configured-model"}); let result = execute_sse_request( @@ -623,7 +623,7 @@ mod tests { .expect("retry-after fixture should run"); }); let url = format!("http://{address}/chat/completions"); - let client = reqwest::Client::new(); + let client = crate::client::http::create_http_client(None, false); let request_body = serde_json::json!({"model": "configured-model"}); let result = execute_sse_request( diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs index 5d68cc68de..90724cfbec 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs @@ -337,6 +337,7 @@ pub(crate) fn build_http_client( options: &SubscriptionHttpOptions, provider: &str, ) -> Result { + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); let mut builder = reqwest::Client::builder() .tls_backend_rustls() .timeout(Duration::from_secs(30)) diff --git a/src/crates/adapters/ai-adapters/tests/common/stream_test_harness.rs b/src/crates/adapters/ai-adapters/tests/common/stream_test_harness.rs index 025f963f2d..7ea5ab1768 100644 --- a/src/crates/adapters/ai-adapters/tests/common/stream_test_harness.rs +++ b/src/crates/adapters/ai-adapters/tests/common/stream_test_harness.rs @@ -98,6 +98,7 @@ pub(crate) async fn run_stream_fixture_with_options( ) -> StreamFixtureRunOutput { let fixture_bytes = load_fixture_bytes(fixture_relative_path); let fixture_server = FixtureSseServer::spawn(fixture_bytes, options.server_options).await; + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); let response = tokio::time::timeout( options.request_timeout, diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs index 92351371d7..7d92477ff4 100644 --- a/src/crates/assembly/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -16,7 +16,9 @@ use crate::agentic::tools; use crate::infrastructure::ai::AIClientFactory; use crate::infrastructure::try_get_path_manager_arc; use crate::runtime_ownership::CoreRuntimeOwnership; -use crate::service::token_usage::{TokenUsageService, TokenUsageSubscriber}; +use crate::service::token_usage::{ + set_global_token_usage_service, TokenUsageService, TokenUsageSubscriber, +}; pub use bitfun_product_capabilities::DeliveryProfile; fn session_manager_config_for_profile( @@ -92,6 +94,7 @@ pub async fn init_agentic_system_for_profile_with_runtime_ownership( let path_manager = try_get_path_manager_arc()?; let persistence_manager = Arc::new(persistence::PersistenceManager::new(path_manager.clone())?); let token_usage_service = Arc::new(TokenUsageService::new(path_manager.clone()).await?); + set_global_token_usage_service(token_usage_service.clone()); let context_store = Arc::new(session::SessionContextStore::new()); let context_compressor = Arc::new(session::ContextCompressor::new(Default::default())); diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index fd23016227..bbd589c5b5 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -31,7 +31,6 @@ const PRODUCT_TOOL_REGISTRATION_ORDER: &[&str] = &[ "ExecControl", "GetTime", "ListModels", - "BitFunControl", "Task", "AgentSpawn", "AgentSendInput", @@ -50,6 +49,7 @@ const PRODUCT_TOOL_REGISTRATION_ORDER: &[&str] = &[ "submit_code_review", "GetToolSpec", "CallDeferredTool", + "BitFunControl", "GetFileDiff", "CreateCanvas", "ReadCanvas", diff --git a/src/crates/assembly/core/src/service/token_usage/service.rs b/src/crates/assembly/core/src/service/token_usage/service.rs index bb6938af8d..3e96ed04a8 100644 --- a/src/crates/assembly/core/src/service/token_usage/service.rs +++ b/src/crates/assembly/core/src/service/token_usage/service.rs @@ -189,10 +189,10 @@ impl TokenUsageService { static GLOBAL_TOKEN_USAGE_SERVICE: std::sync::OnceLock> = std::sync::OnceLock::new(); -/// Install the process-wide token usage service. Called once by the desktop -/// runtime after the service is constructed; tools that call the model outside -/// the round executor (e.g. `analyze_image`) use it to persist usage that would -/// otherwise never reach the token usage store. +/// Install the process-wide token usage service. The canonical Agent Runtime +/// initializer calls this after constructing the service; tools that call the +/// model outside the round executor (e.g. `analyze_image`) use it to persist +/// usage that would otherwise never reach the token usage store. pub fn set_global_token_usage_service(service: Arc) { match GLOBAL_TOKEN_USAGE_SERVICE.set(service) { Ok(_) => log::info!("Global token usage service set"), diff --git a/src/crates/services/AGENTS.md b/src/crates/services/AGENTS.md index a161899b77..3fc3b90439 100644 --- a/src/crates/services/AGENTS.md +++ b/src/crates/services/AGENTS.md @@ -11,7 +11,7 @@ OS/network capabilities. | Crate | Responsibility | Local doc | |---|---|---| -| `services-core` | Reusable local service primitives, managed process-tree lifecycle, filesystem helpers, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | +| `services-core` | Reusable local service primitives, process-wide TLS provider selection, managed process-tree lifecycle, filesystem helpers, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | Concrete MCP, git, remote, file-watch, MiniApp runtime, review-platform provider service, product-domain port implementations, and platform-neutral Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | | `miniapp-market-service` | Concrete SQLite, artifact storage, GitHub OAuth, package validation, and HTTP behavior for the MiniApp market | [README.md](miniapp-market-service/README.md) | | `skin-market-service` | Concrete SQLite, artifact storage, Appearance package validation, review, and HTTP behavior for the Skin market | [README.md](skin-market-service/README.md) | @@ -21,7 +21,7 @@ OS/network capabilities. ## Placement Rules -- Put concrete OS, process, filesystem, git, terminal, MCP, remote SSH, +- Put concrete OS, process-wide TLS provider, process, filesystem, git, terminal, MCP, remote SSH, file-watch, MiniApp runtime IO, and network service implementations here. - Implement `contracts`, `execution`, or `contracts/product-domains` ports here when the implementation needs concrete dependencies. diff --git a/src/crates/services/miniapp-market-service/Cargo.toml b/src/crates/services/miniapp-market-service/Cargo.toml index eb939c9c47..634be7f06a 100644 --- a/src/crates/services/miniapp-market-service/Cargo.toml +++ b/src/crates/services/miniapp-market-service/Cargo.toml @@ -13,11 +13,12 @@ anyhow = { workspace = true } axum = { workspace = true } base64 = { workspace = true } bitfun-product-domains = { path = "../../contracts/product-domains", features = ["miniapp"] } +bitfun-services-core = { path = "../services-core", features = ["tls-provider"] } chrono = { workspace = true } hex = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } rand = { workspace = true } -reqwest = { workspace = true, features = ["form", "http2", "json", "rustls"] } +reqwest = { workspace = true, features = ["form", "http2", "json", "rustls-no-provider"] } serde = { workspace = true } serde_json = { workspace = true } semver = { workspace = true } diff --git a/src/crates/services/miniapp-market-service/src/auth.rs b/src/crates/services/miniapp-market-service/src/auth.rs index 2bc852f17c..67da6667e9 100644 --- a/src/crates/services/miniapp-market-service/src/auth.rs +++ b/src/crates/services/miniapp-market-service/src/auth.rs @@ -147,6 +147,7 @@ struct OAuthFlowRecord { impl AuthService { pub(crate) fn new(config: MarketConfig, db: Database) -> MarketResult { + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); let client = reqwest::Client::builder() .user_agent("BitFun-MiniApp-Market/1") .redirect(reqwest::redirect::Policy::none()) diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 13d0f39129..7f92c8657b 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -7,7 +7,8 @@ without the full product runtime. This includes generic filesystem/search/JSON IO helpers, bounded local Instruction file reads, session metadata storage helpers, and local OS action primitives such as command lookup, clipboard, file/url opening, script execution, workspace runtime FS/shell -providers, managed process-tree lifecycle, process-level Agent Runtime ownership locks, and system facts. Product crates may layer routing, policy, +providers, process-wide TLS provider selection, managed process-tree lifecycle, +process-level Agent Runtime ownership locks, and system facts. Product crates may layer routing, policy, capability selection, event emission, or legacy error mapping outside this crate. @@ -24,7 +25,7 @@ crate. (JSON/session/usage persistence), `process-runtime` (command lookup and supervised child lifecycle), and `workspace-instructions` (declarative instruction discovery). Consumers enable those or the narrower - `workspace-runtime`, `workspace-identity`, `runtime-ownership`, + `workspace-runtime`, `workspace-identity`, `runtime-ownership`, `tls-provider`, `permission`, `dispatch-workspace`, `markdown`, `session-git`, and `workspace-text-runtime` extensions only for behavior they use. Products needing IANA time-zone ranges and dashboard aggregation additionally select @@ -34,6 +35,10 @@ crate. carries no Tokio dependency, `workspace-runtime` explicitly composes `process-runtime`, and Windows storage/process bindings must not be enabled from one shared dependency feature union. +- `tls-provider` is the single owner of the process-wide Rustls provider. It + selects only `ring`, `std`, and `tls12`; provider-neutral Reqwest consumers + call `tls_provider::ensure_ring_crypto_provider` before client construction. + Do not install a Rustls provider from another crate. - Runtime call sites that touch agent execution, scheduler state, workspace managers, filesystem orchestration, or product behavior stay outside this crate. `workspace-runtime` may implement local `bitfun-runtime-ports` @@ -75,6 +80,7 @@ cargo test -p bitfun-services-core --no-default-features --features local-storag cargo test -p bitfun-services-core --no-default-features --features local-storage --test session_write_lock_contracts cargo test -p bitfun-services-core --no-default-features --features token-usage-statistics --lib token_usage:: cargo test -p bitfun-services-core --no-default-features --features process-runtime --test process_runtime_contracts +cargo test --locked -p bitfun-services-core --no-default-features --features tls-provider --lib tls_provider::tests pnpm run check:core-boundaries ``` diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index d313b55bcc..63e401987d 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -36,6 +36,7 @@ which = { workspace = true, optional = true } similar = { workspace = true, optional = true } regex = { workspace = true, optional = true } rusqlite = { version = "0.32", features = ["bundled"], optional = true } +rustls = { workspace = true, features = ["ring", "std", "tls12"], optional = true } [target.'cfg(windows)'.dependencies] win32job = { workspace = true, optional = true } @@ -46,6 +47,7 @@ libc = { workspace = true, optional = true } [features] default = [] +tls-provider = ["dep:rustls"] diagnostics = ["dep:regex"] diff = ["dep:similar", "dep:tokio", "tokio/rt", "tokio/time"] filesystem = [ diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index d388b83fa0..6c55cec1f6 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -47,6 +47,8 @@ pub mod session_usage; pub mod storage_cleanup; #[cfg(feature = "process-runtime")] pub mod system; +#[cfg(feature = "tls-provider")] +pub mod tls_provider; #[cfg(feature = "local-storage")] pub mod token_usage; #[cfg(feature = "workspace-runtime")] diff --git a/src/crates/services/services-core/src/tls_provider.rs b/src/crates/services/services-core/src/tls_provider.rs new file mode 100644 index 0000000000..0136b2992f --- /dev/null +++ b/src/crates/services/services-core/src/tls_provider.rs @@ -0,0 +1,19 @@ +//! Process-wide TLS crypto-provider selection for BitFun product clients. + +/// Installs the workspace-owned ring provider before a provider-neutral TLS +/// client is constructed. +/// +/// Repeated calls are safe: rustls keeps the first process-wide provider and +/// every reviewed BitFun build selects ring as its only provider. +pub fn ensure_ring_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +#[cfg(test)] +mod tests { + #[test] + fn installs_the_workspace_owned_provider() { + super::ensure_ring_crypto_provider(); + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + } +} diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index b3cf31adc5..43f4750270 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -97,10 +97,11 @@ default = [] # feature group (e.g. `git`) only derive `TS` when that group is also enabled; # app-server's `ts` feature enables `product-full` which pulls `git`. ts = ["dep:ts-rs"] -announcement = ["reqwest", "reqwest/json", "reqwest/rustls", "tokio/fs", "tokio/sync"] +announcement = ["bitfun-services-core/tls-provider", "reqwest", "reqwest/json", "reqwest/rustls-no-provider", "tokio/fs", "tokio/sync"] models-dev = [ + "bitfun-services-core/tls-provider", "reqwest", - "reqwest/rustls", + "reqwest/rustls-no-provider", "reqwest/system-proxy", "sha2", "tokio/fs", @@ -108,7 +109,7 @@ models-dev = [ "tokio/time", "windows", ] -browser-control = ["anyhow", "bitfun-services-core/process-runtime", "dirs", "reqwest", "reqwest/json", "reqwest/rustls", "thiserror", "tokio/time"] +browser-control = ["anyhow", "bitfun-services-core/process-runtime", "bitfun-services-core/tls-provider", "dirs", "reqwest", "reqwest/json", "reqwest/rustls-no-provider", "thiserror", "tokio/time"] canvas-runtime = [ "dep:bitfun-product-domains", "oxc", @@ -149,12 +150,13 @@ mcp = [ "base64", "bitfun-agent-tools/mcp-bridge", "bitfun-services-core/process-runtime", + "bitfun-services-core/tls-provider", "futures", "hex", "rand", "reqwest", "reqwest/json", - "reqwest/rustls", + "reqwest/rustls-no-provider", "reqwest/stream", "rmcp", "rmcp/transport-streamable-http-client-reqwest", @@ -174,10 +176,11 @@ miniapp-runtime = [ "base64", "bitfun-product-domains/miniapp", "bitfun-services-core/process-runtime", + "bitfun-services-core/tls-provider", "dep:bitfun-product-domains", "dirs", "reqwest", - "reqwest/rustls", + "reqwest/rustls-no-provider", "reqwest/stream", "tokio/fs", "tokio/io-util", @@ -190,6 +193,7 @@ miniapp-runtime = [ "which", ] miniapp-market = [ + "bitfun-services-core/tls-provider", "bitfun-product-domains/appearance-market", "chrono", "dep:apple-native-keyring-store", @@ -206,7 +210,7 @@ miniapp-market = [ "reqwest", "reqwest/json", "reqwest/query", - "reqwest/rustls", + "reqwest/rustls-no-provider", "reqwest/stream", "semver", "sha2", @@ -254,6 +258,7 @@ remote-connect = [ "dep:bitfun-core-types", "bitfun-services-core/local-storage", "bitfun-services-core/process-runtime", + "bitfun-services-core/tls-provider", "bitfun-runtime-ports/agent-api", "bitfun-runtime-ports/remote-workspace-ports", "chrono", @@ -276,7 +281,7 @@ remote-connect = [ "reqwest/json", "reqwest/multipart", "reqwest/query", - "reqwest/rustls", + "reqwest/rustls-no-provider", "rustls", "rustls-native-certs", "schannel", @@ -324,13 +329,14 @@ remote-ssh-concrete = [ "anyhow", "async-trait", "base64", + "bitfun-services-core/tls-provider", "dep:bitfun-runtime-ports", "chrono", "dirs", "minisign-verify", "rand", "reqwest", - "reqwest/rustls", + "reqwest/rustls-no-provider", "reqwest/stream", "russh", "russh-sftp", @@ -345,12 +351,13 @@ remote-ssh-concrete = [ review-platform = [ "async-trait", "bitfun-services-core/process-runtime", + "bitfun-services-core/tls-provider", "chrono", "futures", "reqwest", "reqwest/json", "reqwest/query", - "reqwest/rustls", + "reqwest/rustls-no-provider", "reqwest/stream", "sha2", "thiserror", @@ -363,12 +370,13 @@ review-platform = [ speech = [ "async-trait", "base64", + "bitfun-services-core/tls-provider", "bzip2", "chrono", "dep:bitfun-core-types", "futures-util", "reqwest", - "reqwest/rustls", + "reqwest/rustls-no-provider", "reqwest/stream", "sha2", "sherpa-onnx", @@ -406,7 +414,7 @@ script-tool-runtime = [ "tokio/time", "which", ] -web-tools = ["reqwest", "reqwest/json", "reqwest/rustls", "thiserror"] +web-tools = ["bitfun-services-core/tls-provider", "reqwest", "reqwest/json", "reqwest/rustls-no-provider", "thiserror"] product-full = [ "announcement", "models-dev", diff --git a/src/crates/services/services-integrations/src/announcement/remote.rs b/src/crates/services/services-integrations/src/announcement/remote.rs index 80d04486b9..7c4183fbc3 100644 --- a/src/crates/services/services-integrations/src/announcement/remote.rs +++ b/src/crates/services/services-integrations/src/announcement/remote.rs @@ -76,7 +76,7 @@ impl RemoteAnnouncementFetcher { request.endpoint_url ); - match reqwest::get(&request_url).await { + match crate::reqwest_client().get(&request_url).send().await { Ok(resp) if resp.status().is_success() => { match resp.json::>().await { Ok(cards) => { diff --git a/src/crates/services/services-integrations/src/appearance_market/client.rs b/src/crates/services/services-integrations/src/appearance_market/client.rs index 7746e83a97..36f842965a 100644 --- a/src/crates/services/services-integrations/src/appearance_market/client.rs +++ b/src/crates/services/services-integrations/src/appearance_market/client.rs @@ -57,7 +57,7 @@ impl AppearanceMarketClient { ) -> Result { let base_url = base_url.into().trim_end_matches('/').to_string(); validate_market_url(&base_url)?; - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .user_agent(format!("BitFun-Desktop/{}", env!("CARGO_PKG_VERSION"))) .redirect(reqwest::redirect::Policy::none()) .build() diff --git a/src/crates/services/services-integrations/src/browser_control/cdp.rs b/src/crates/services/services-integrations/src/browser_control/cdp.rs index f8bb9454b0..9ac4cde965 100644 --- a/src/crates/services/services-integrations/src/browser_control/cdp.rs +++ b/src/crates/services/services-integrations/src/browser_control/cdp.rs @@ -52,7 +52,9 @@ impl CdpEndpointProvider { /// Discover browser version on the given debug port. pub async fn get_version(port: u16) -> Result { let url = format!("http://127.0.0.1:{}/json/version", port); - let resp = reqwest::get(&url) + let resp = crate::reqwest_client() + .get(&url) + .send() .await .map_err(|source| CdpEndpointError::VersionRequest { port, @@ -66,13 +68,14 @@ impl CdpEndpointProvider { /// List all pages/tabs on the given debug port. pub async fn list_pages(port: u16) -> Result, CdpEndpointError> { let url = format!("http://127.0.0.1:{}/json", port); - let resp = - reqwest::get(&url) - .await - .map_err(|source| CdpEndpointError::ListPagesRequest { - port, - message: source.to_string(), - })?; + let resp = crate::reqwest_client() + .get(&url) + .send() + .await + .map_err(|source| CdpEndpointError::ListPagesRequest { + port, + message: source.to_string(), + })?; resp.json() .await .map_err(|source| CdpEndpointError::ListPagesResponse(source.to_string())) @@ -89,7 +92,7 @@ impl CdpEndpointProvider { } else { format!("http://127.0.0.1:{}/json/new", port) }; - let resp = reqwest::Client::new() + let resp = crate::reqwest_client() .put(&endpoint) .send() .await diff --git a/src/crates/services/services-integrations/src/browser_control/launcher.rs b/src/crates/services/services-integrations/src/browser_control/launcher.rs index 554865db1c..289202429c 100644 --- a/src/crates/services/services-integrations/src/browser_control/launcher.rs +++ b/src/crates/services/services-integrations/src/browser_control/launcher.rs @@ -85,7 +85,7 @@ impl BrowserLauncher { /// Check if a CDP debug port is already listening. pub async fn is_cdp_available(port: u16) -> bool { let url = format!("http://127.0.0.1:{}/json/version", port); - reqwest::Client::new() + crate::reqwest_client() .get(&url) .timeout(std::time::Duration::from_secs(2)) .send() diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 066ea77ca9..715906502d 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -3,6 +3,33 @@ //! Heavy external integrations live here behind feature groups so local checks //! can opt into only the integration family they need. +#[cfg(any( + feature = "mcp", + feature = "miniapp-market", + feature = "miniapp-runtime", + feature = "models-dev", + feature = "remote-connect", + feature = "remote-ssh-concrete", + feature = "review-platform", + feature = "speech", + feature = "web-tools", +))] +pub(crate) fn reqwest_client_builder() -> reqwest::ClientBuilder { + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); + reqwest::Client::builder() +} + +#[cfg(any( + feature = "announcement", + feature = "browser-control", + feature = "mcp", + feature = "remote-connect", +))] +pub(crate) fn reqwest_client() -> reqwest::Client { + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); + reqwest::Client::new() +} + #[cfg(feature = "announcement")] pub mod announcement; diff --git a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs index bdc0629517..63f6b1d075 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs @@ -420,7 +420,7 @@ impl RemoteMCPTransport { None }; - let http_client = reqwest::Client::builder() + let http_client = crate::reqwest_client_builder() .connect_timeout(Duration::from_secs(10)) .danger_accept_invalid_certs(false) .tls_backend_rustls() @@ -428,7 +428,7 @@ impl RemoteMCPTransport { .build() .unwrap_or_else(|e| { warn!("Failed to create HTTP client, using default config: {}", e); - reqwest::Client::new() + crate::reqwest_client() }); let transport = StreamableHttpClientTransport::with_client( diff --git a/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs b/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs index b05152b9b6..535ca051fe 100644 --- a/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs +++ b/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs @@ -547,7 +547,7 @@ async fn dispatch_net( .host_str() .ok_or_else(|| MiniAppHostDispatchError::parse("URL has no host"))?; let pinned_address = resolve_public_address(¤t_url).await?; - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .redirect(reqwest::redirect::Policy::none()) .resolve(host, pinned_address) .build() diff --git a/src/crates/services/services-integrations/src/miniapp_market/client.rs b/src/crates/services/services-integrations/src/miniapp_market/client.rs index deb17b9257..a23cbfba23 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/client.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/client.rs @@ -140,7 +140,7 @@ impl MarketClient { "The MiniApp market API must use HTTPS.", )); } - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .user_agent(format!("BitFun-Desktop/{}", env!("CARGO_PKG_VERSION"))) .redirect(reqwest::redirect::Policy::none()) .build() diff --git a/src/crates/services/services-integrations/src/models_dev.rs b/src/crates/services/services-integrations/src/models_dev.rs index b86f8dd5da..c20ef227a5 100644 --- a/src/crates/services/services-integrations/src/models_dev.rs +++ b/src/crates/services/services-integrations/src/models_dev.rs @@ -225,7 +225,7 @@ impl ModelsDevCatalogService { refresh_state.last_attempt = Some(now); drop(refresh_state); - let client = match reqwest::Client::builder() + let client = match crate::reqwest_client_builder() .timeout(Duration::from_secs(10)) .build() { diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/feishu.rs b/src/crates/services/services-integrations/src/remote_connect/bot/feishu.rs index 58dea5ffb6..5ccda09748 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/feishu.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/feishu.rs @@ -76,7 +76,7 @@ impl FeishuBotApi { } } - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let resp = client .post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal") .json(&serde_json::json!({ @@ -112,7 +112,7 @@ impl FeishuBotApi { pub async fn send_message(&self, chat_id: &str, content: &str) -> Result<()> { let token = self.get_access_token().await?; let card = build_markdown_card(content); - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let resp = client .post("https://open.feishu.cn/open-apis/im/v1/messages") .query(&[("receive_id_type", "chat_id")]) @@ -156,7 +156,7 @@ impl FeishuBotApi { actions: &[BotAction], ) -> Result<()> { let token = self.get_access_token().await?; - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let card = build_action_card(chat_id, language, content, actions); let resp = client .post("https://open.feishu.cn/open-apis/im/v1/messages") @@ -185,7 +185,7 @@ impl FeishuBotApi { file_key: &str, ) -> Result { let token = self.get_access_token().await?; - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let url = format!( "https://open.feishu.cn/open-apis/im/v1/messages/{}/resources/{}?type=image", message_id, file_key @@ -224,7 +224,7 @@ impl FeishuBotApi { let file_key = self.upload_file(file_path).await?; let token = self.get_access_token().await?; - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let resp = client .post("https://open.feishu.cn/open-apis/im/v1/messages") .query(&[("receive_id_type", "chat_id")]) @@ -272,7 +272,7 @@ impl FeishuBotApi { .text("file_name", content.name) .part("file", part); - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let resp = client .post("https://open.feishu.cn/open-apis/im/v1/files") .bearer_auth(&token) @@ -294,7 +294,7 @@ impl FeishuBotApi { /// Obtain a WebSocket URL from Feishu for long-connection event delivery. pub async fn get_ws_endpoint(&self) -> Result { - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let resp = client .post("https://open.feishu.cn/callback/ws/endpoint") .json(&serde_json::json!({ diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/telegram.rs b/src/crates/services/services-integrations/src/remote_connect/bot/telegram.rs index 0535a6bcd8..0e7ae4aa64 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/telegram.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/telegram.rs @@ -73,7 +73,7 @@ impl TelegramBotApi { } pub async fn send_message(&self, chat_id: i64, text: &str) -> Result<()> { - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); for chunk in chunk_text_for_telegram(text) { let resp = client .post(self.api_url("sendMessage")) @@ -110,7 +110,7 @@ impl TelegramBotApi { }) .collect(); - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let resp = client .post(self.api_url("sendMessage")) .json(&serde_json::json!({ @@ -143,7 +143,7 @@ impl TelegramBotApi { .text("chat_id", chat_id.to_string()) .part("document", part); - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let resp = client .post(self.api_url("sendDocument")) .multipart(form) @@ -160,7 +160,7 @@ impl TelegramBotApi { /// Acknowledge a callback query so Telegram removes the button loading state. pub async fn answer_callback_query(&self, callback_query_id: &str) { - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let _ = client .post(self.api_url("answerCallbackQuery")) .json(&serde_json::json!({ "callback_query_id": callback_query_id })) @@ -170,7 +170,7 @@ impl TelegramBotApi { /// Register the bot command menu visible in Telegram's "/" menu. pub async fn set_bot_commands(&self) -> Result<()> { - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let commands = serde_json::json!({ "commands": [ { "command": "menu", "description": "Show the main menu" }, @@ -199,7 +199,7 @@ impl TelegramBotApi { /// Download a Telegram photo by file_id and return it as an ImageAttachment. async fn download_photo(&self, file_id: &str) -> Result { - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let resp = client .post(self.api_url("getFile")) @@ -251,7 +251,7 @@ impl TelegramBotApi { &self, last_update_id: i64, ) -> Result<(i64, Vec)> { - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .timeout(std::time::Duration::from_secs(35)) .build()?; diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs b/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs index eae2d2f731..4126b44540 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs @@ -254,7 +254,7 @@ impl WeixinProviderClient { async fn post_ilink(&self, endpoint: &str, body: Value, timeout: Duration) -> Result { let url = format!("{}{}", self.base_url(), endpoint.trim_start_matches('/')); let body_str = serde_json::to_string(&body)?; - let client = reqwest::Client::builder().timeout(timeout).build()?; + let client = crate::reqwest_client_builder().timeout(timeout).build()?; let resp = client .post(&url) .headers(self.build_auth_headers()) @@ -660,7 +660,7 @@ impl WeixinProviderClient { Some(url) => url.to_string(), None => build_cdn_download_url(DEFAULT_CDN_BASE_URL, encrypted_query_param), }; - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .timeout(Duration::from_secs(120)) .build()?; let resp = client.get(&url).send().await?; @@ -804,7 +804,7 @@ impl WeixinProviderClient { } async fn post_weixin_cdn_upload(&self, cdn_url: &str, ciphertext: &[u8]) -> Result { - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .timeout(Duration::from_secs(120)) .build()?; let mut last_err: Option = None; @@ -945,7 +945,7 @@ async fn poll_found_qr_session( url.push_str(&urlencoding::encode(code)); } - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .timeout(Duration::from_secs(QR_POLL_TIMEOUT_SECS)) .build()?; @@ -1508,7 +1508,7 @@ async fn fetch_qr_code(base: &str, local_token_list: &[String]) -> Result() ); - let client = reqwest::Client::new(); + let client = crate::reqwest_client(); let relay_base = relay_url.trim_end_matches('/'); let manifest = mobile_web_upload_manifest(&all_files); diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index 4dd59c9030..2527d0d075 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs @@ -25,11 +25,10 @@ use tokio_tungstenite::{tungstenite::client::IntoClientRequest, Connector}; /// `install_default()` returns `Err` only when a provider is already installed, /// which is harmless — we silently ignore it. /// -/// This is safe to call multiple times and from any thread. Required on all -/// platforms: rustls 0.23 does not auto-select a provider when multiple TLS -/// stacks are linked into the process. +/// This is safe to call multiple times and from any thread. Installing it +/// explicitly keeps provider choice deterministic for every product client. pub fn ensure_rustls_crypto_provider() { - let _ = rustls::crypto::ring::default_provider().install_default(); + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); } type WsStream = @@ -696,11 +695,7 @@ mod tests { #[cfg(windows)] fn build_windows_rustls_connector() -> Result { - // Install the ring CryptoProvider as the process-level default. - // Required by rustls 0.23+ when `default-features = false`. - // `install_default()` returns Err only when a provider is already installed, - // which is fine — subsequent reconnects reuse the same process-level provider. - let _ = rustls::crypto::ring::default_provider().install_default(); + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); let mut root_store = rustls::RootCertStore::empty(); diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_http.rs b/src/crates/services/services-integrations/src/remote_connect/relay_http.rs index 48ff10e1c7..f13c4471e5 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_http.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_http.rs @@ -75,7 +75,7 @@ impl BufferedRelayResponse { pub(crate) fn relay_http_client() -> reqwest::Client { RELAY_HTTP_CLIENT .get_or_init(|| { - reqwest::Client::builder() + crate::reqwest_client_builder() .timeout(RELAY_HTTP_TIMEOUT) .connect_timeout(RELAY_HTTP_CONNECT_TIMEOUT) .read_timeout(RELAY_HTTP_READ_TIMEOUT) @@ -85,7 +85,7 @@ pub(crate) fn relay_http_client() -> reqwest::Client { warn!( "Failed to build shared relay HTTP client; using reqwest defaults: {error}" ); - reqwest::Client::new() + crate::reqwest_client() }) }) .clone() @@ -318,7 +318,7 @@ mod tests { stream.write_all(response.as_bytes()).await.unwrap(); } }); - let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let client = crate::reqwest_client_builder().no_proxy().build().unwrap(); let response = send_with_retry( "test-safe-read", @@ -353,7 +353,7 @@ mod tests { stream.write_all(response.as_bytes()).await.unwrap(); } }); - let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let client = crate::reqwest_client_builder().no_proxy().build().unwrap(); let response = send_with_retry( "test-truncated-body", @@ -386,7 +386,7 @@ mod tests { .await .unwrap(); }); - let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let client = crate::reqwest_client_builder().no_proxy().build().unwrap(); let response = send_with_retry( "test-truncated-error-body", diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index 14caf5d576..843526510d 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -2371,7 +2371,7 @@ fn release_target(os: &str, arch: &str) -> Result<&'static str> { } fn release_http_client() -> Result { - reqwest::Client::builder() + crate::reqwest_client_builder() .connect_timeout(Duration::from_secs(10)) // Per-read rather than whole-request: a genuine slow link may take // longer than an arbitrary archive deadline, but a stalled source must diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 6ab81b05e1..329c2b3319 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -1400,7 +1400,7 @@ async fn verified_latest_relay_image_descriptor() -> Result Result { - let inner = reqwest::Client::builder() + let inner = crate::reqwest_client_builder() .tls_backend_rustls() .redirect(review_redirect_policy()) .timeout(Duration::from_secs(REVIEW_PLATFORM_TIMEOUT_SECS)) diff --git a/src/crates/services/services-integrations/src/speech/downloader.rs b/src/crates/services/services-integrations/src/speech/downloader.rs index e93f8890c7..57f5c3617f 100644 --- a/src/crates/services/services-integrations/src/speech/downloader.rs +++ b/src/crates/services/services-integrations/src/speech/downloader.rs @@ -25,7 +25,7 @@ pub(super) async fn download_and_install_model( where F: Fn(SpeechModelProgress) + Send + Sync, { - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .connect_timeout(Duration::from_secs(15)) .timeout(Duration::from_secs(30 * 60)) .build() diff --git a/src/crates/services/services-integrations/src/web_tools.rs b/src/crates/services/services-integrations/src/web_tools.rs index 971b513425..0c75ba5bc4 100644 --- a/src/crates/services/services-integrations/src/web_tools.rs +++ b/src/crates/services/services-integrations/src/web_tools.rs @@ -64,7 +64,7 @@ pub struct WebToolNetworkProvider; impl WebToolNetworkProvider { pub async fn fetch_text(url: &str) -> Result { - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .user_agent(USER_AGENT_VALUE) .timeout(Duration::from_secs(WEB_FETCH_TIMEOUT_SECS)) .build() @@ -105,7 +105,7 @@ impl WebToolNetworkProvider { } pub async fn search_exa(request: ExaSearchRequest<'_>) -> Result { - let client = reqwest::Client::builder() + let client = crate::reqwest_client_builder() .timeout(Duration::from_secs(EXA_TIMEOUT_SECS)) .build() .map_err(|error| WebToolNetworkError::BuildClient(error.to_string()))?; diff --git a/src/crates/services/skin-market-service/Cargo.toml b/src/crates/services/skin-market-service/Cargo.toml index de0c5f3e35..b89a512bd3 100644 --- a/src/crates/services/skin-market-service/Cargo.toml +++ b/src/crates/services/skin-market-service/Cargo.toml @@ -13,11 +13,12 @@ anyhow = { workspace = true } axum = { workspace = true } base64 = { workspace = true } bitfun-product-domains = { path = "../../contracts/product-domains", features = ["appearance-market"] } +bitfun-services-core = { path = "../services-core", features = ["tls-provider"] } chrono = { workspace = true } hex = { workspace = true } image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] } hmac = { workspace = true } -reqwest = { workspace = true, features = ["http2", "json", "rustls"] } +reqwest = { workspace = true, features = ["http2", "json", "rustls-no-provider"] } semver = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/services/skin-market-service/src/auth.rs b/src/crates/services/skin-market-service/src/auth.rs index 7c402a90b5..0fc3050f75 100644 --- a/src/crates/services/skin-market-service/src/auth.rs +++ b/src/crates/services/skin-market-service/src/auth.rs @@ -31,6 +31,7 @@ struct IdentityResponse { impl IdentityVerifier { pub(crate) fn new(me_url: Url) -> anyhow::Result { + bitfun_services_core::tls_provider::ensure_ring_crypto_provider(); Ok(Self { client: Client::builder() .connect_timeout(Duration::from_secs(3))