完善AGC Router代理与后置计费
修正无 provisioning 时仅使用服务端 fallback Key,禁止生成 Router 不认识的随机 Key 补齐 AGC 凭据元数据脱敏、失效复用保护与 SpacetimeDB 字段映射 让 Responses 与 Chat 流式调用在成功后按 usage 幂等扣除泥点 同步 schema 迁移、环境变量示例、后端架构和决策文档
This commit is contained in:
@@ -8,6 +8,12 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
# but it should not be relied on by browser code.
|
||||
LLM_API_KEY=""
|
||||
|
||||
# Temporary server-private AGC Router fallback until Router provisioning exists.
|
||||
# Never expose this value to AGC clients or browser code.
|
||||
GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY=""
|
||||
GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY_FILE=""
|
||||
GENARRATIVE_AGC_ROUTER_FALLBACK_ACCOUNT_ID="server-fallback"
|
||||
|
||||
# Optional frontend override for the local proxy path.
|
||||
VITE_LLM_PROXY_BASE_URL="/api/llm"
|
||||
|
||||
|
||||
@@ -7837,6 +7837,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- Windows 私有路径严格拒绝 reparse/symlink、非普通对象、路径类型冲突和候选路径冲突。只有本次调用新建的目录/临时文件可在普通进程内初始化 owner;owner 已正确但仅继承 ACL 不合规时,正式 prepare 入口先完成归属校验,再通过当前用户私有、禁止继承、单一 ACE 的 DACL 收紧。用户通过原生选择器明确选中的项目根/文件,或 AGC managed 路径,在发现 owner/DACL 权限不足时由一次性 UAC helper 将普通对象接管为当前 TokenUser 并复核;取消/失败保持失败关闭,未经过正式选择或项目根入口的内部路径不得触发任意提权。
|
||||
- 规划、Runtime sidecar、UI workflow、资源桥、Skill 隔离目录和图片读取统一经过私有路径准备,并在原子写入后复核类型、owner/DACL 与文件身份。真实 Windows UAC、foreign owner 修复、继承 DACL 收紧、注册后 Router 签发和生产 `/v1/responses` 联通仍需在受控实机/部署环境验证。
|
||||
|
||||
## 2026-08-30 AGC Router fallback 与后置扣费修订
|
||||
|
||||
- Router 与 api-server 当前没有生产 provisioning 控制面,废止“按用户本地生成随机 `tnr_sk`”的临时路径。未配置 provisioning 时,api-server 只能读取服务端私有 `GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY`,并把同一 fallback 的密文、Router 账号 JSON 和 `credential_version=1` 写入现有 `external_api_key` AGC 行;没有 fallback 则失败关闭并进入对账,不向 Router 发送不可识别的伪账号 Key。
|
||||
- AGC 调用固定为客户端 access token -> api-server -> Router。Router 成功返回后才扣泥点,按临时规则每开始 1000 token 扣 1 点且至少 1 点;ledger id 由 `Idempotency-Key`(缺省请求 ID)哈希得到,Router 失败不扣费。该计费规则是过渡实现,待产品定价确认后替换,不能视为 Router 真实成本结算。
|
||||
|
||||
## 2026-08-29 DirectProject 受控联网搜索默认与边界
|
||||
|
||||
- 正式产品本次只覆盖 `DirectProject` 单 Codex Agent。`Provider`、`ToolHost`、`DirectHome` 不是 Agent,也不是本次联网主链路;不新增全路由联网或工具桥。唯一受控联网工具为 `agc_tools.agc_web_search`,链路固定为 Codex MCP 工具目录 -> 客户端 loopback `DirectToolBridge` -> 有界 Bing RSS HTTPS -> 过滤 / 脱敏 -> MCP 结果回传。
|
||||
|
||||
@@ -630,8 +630,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
|
||||
- Rust 结构体:`ExternalApiKey`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs`
|
||||
- 说明:外部 OpenAPI 与 AGC 官方 LLM 使用的账号级 API Key 凭据表,公开元数据只包含 key prefix、作用域、用途、撤销状态和使用时间;服务端内部还保存 AGC Router Key 的 AES-256-GCM 密文、Router 账号标识和固定上游元数据,明文永不通过登录态接口、SpacetimeDB 表查询或客户端返回。普通 External Editor Key 的明文只在 `/api/profile/api-keys` 创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`;AGC `purpose=agc-llm` 在注册/首次 LLM 请求时由 api-server 幂等签发并保存,部署存在 Router 正式 provisioning 端点时优先使用该端点,否则使用 route+owner 派生的稳定账号标识和本地随机 Key。AGC 密文加密优先使用专用 `GENARRATIVE_AGC_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 JWT secret 派生值,避免注册产生无法持久化的账号 Key。生产环境应显式配置专用 secret,轮换时先完成旧密文对账,不能静默创建第二个 active Key。`/api/profile/api-keys/agc-llm` 只返回安全元数据,不返回 Router 明文 Key。API Key 管理接口不写入外部 OpenAPI JSON。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、去背景、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。`purpose=agc-llm` 是 AGC 正式客户端专用账号 Key,作用域固定为 `llm:responses`。
|
||||
- 说明:外部 OpenAPI 与 AGC 官方 LLM 使用的账号级 API Key 凭据表,公开元数据只包含 key prefix、作用域、用途、撤销状态和使用时间;服务端内部还保存 AGC Router Key 的 AES-256-GCM 密文、Router 账号标识和固定上游元数据,明文永不通过登录态接口、SpacetimeDB 表查询或客户端返回。普通 External Editor Key 的明文只在 `/api/profile/api-keys` 创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`;AGC `purpose=agc-llm` 在注册/首次 LLM 请求时由 api-server 幂等确保并保存,部署存在 Router 正式 provisioning 端点时优先调用该端点;在 provisioning 尚未提供的过渡期,只允许使用服务端私有 fallback Key,禁止生成 Router 不认识的随机 `tnr_sk`。AGC 密文加密优先使用专用 `GENARRATIVE_AGC_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 JWT secret 派生值。生产环境应显式配置专用 secret,轮换时先完成旧密文对账,不能静默创建第二个 active Key。`/api/profile/api-keys/agc-llm` 只返回安全元数据,不返回 Router 明文 Key。API Key 管理接口不写入外部 OpenAPI JSON。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、去背景、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。`purpose=agc-llm` 是 AGC 正式客户端专用账号 Key,作用域固定为 `llm:responses`。
|
||||
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
|
||||
- 2026-08-30 修订:AGC 行在上述字段末尾追加 `provider_account_json`(Router 账号 JSON,普通 External Editor Key 为 `NULL`)与 `credential_version`(默认 `1`)。Router 没有正式 provisioning 控制面时只允许使用服务端注入的临时 fallback Key,禁止生成 Router 不认识的随机 `tnr_sk`;Responses 请求成功后再按 usage 写入钱包扣费流水,失败请求不扣费。
|
||||
- Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。
|
||||
|
||||
### `admin_account`
|
||||
|
||||
@@ -215,7 +215,7 @@ SpacetimeDB procedure:
|
||||
|
||||
### AGC 官方 LLM Key 与 Router 代理
|
||||
|
||||
AGC 普通发行版不把 Router 当作客户端可配置 Provider,也不把 `tnr_sk` 下发到桌面端。注册成功后视为账号已有可用余额,由 `api-server` 幂等签发一个稳定的 Router 账号标识和随机 Key,并将账号标识与 AES-256-GCM 密文写入同一条 `external_api_key` 记录(`purpose=agc-llm`、固定 `llm:responses` scope、固定官方模型)。如果部署已经提供 Router 侧正式账号签发端点,api-server 优先调用该受控端点;未配置时使用本地签发路径,不因缺少 provisioning 配置阻断注册。AGC 密文加密优先使用 `GENARRATIVE_AGC_ROUTER_API_KEY_ENCRYPTION_SECRET`(或对应文件);未配置时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥,确保注册不会因为遗漏可选 AGC 专用配置而产生无法保存的账号 Key。生产环境仍建议显式配置并持久化专用 secret;切换 JWT 或专用 secret 会使旧密文无法解密,必须在轮换窗口内完成账号 Key 对账/重新签发,不能静默生成第二个 active Key。AGC Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/agc-llm` 仅用于登录态补偿/排障并只返回安全元数据,不返回明文 Router Key;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
|
||||
AGC 普通发行版不把 Router 当作客户端可配置 Provider,也不把 `tnr_sk` 下发到桌面端。注册成功后视为账号已有可用余额;Router 尚未提供正式 provisioning 控制面时,api-server 不伪造 Router 账号或随机 Key,而是使用服务端私有的临时 fallback Key。该 fallback 只能由 `GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY`(或对应文件)注入,仍按 `purpose=agc-llm` 写入 `external_api_key` 的 AES-256-GCM 密文;没有 fallback 时保持失败关闭并进入对账/补偿流程。若部署提供 Router 侧正式账号签发端点,api-server 优先调用该受控端点,并在同一记录中保存 Router 账号 JSON、凭据版本和密文。AGC 密文加密优先使用 `GENARRATIVE_AGC_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。AGC Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/agc-llm` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
|
||||
|
||||
```text
|
||||
AGC loopback Provider Proxy(Bearer=平台 access token)
|
||||
@@ -224,7 +224,7 @@ AGC loopback Provider Proxy(Bearer=平台 access token)
|
||||
-> POST https://router.genarrative.world/v1/responses(Bearer=tnr_sk)
|
||||
```
|
||||
|
||||
`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;调用前保留 `precharge_agc_llm_usage` 计费钩子,待钱包策略确定后接入正式扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销,下一次请求再按登录态补偿签发;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 AGC Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。
|
||||
`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;Router 成功返回后按 usage 以临时规则“每开始 1000 token 扣 1 泥点、至少 1 点”写入钱包流水,ledger id 由幂等键(缺省为请求 ID)稳定派生,Router 失败不扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 AGC Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。
|
||||
|
||||
Windows 私有路径由同一套正式准备入口复用:AGC 自有 AppData、凭据目录和 `.agent` 运行态使用 managed 范围;原生文件选择器明确选中的项目根/文件使用 user-selected 范围。两类入口在发现 owner/DACL 权限不足时均允许一次性 UAC helper;helper 只接管严格复核后的普通文件/目录,并写入当前 TokenUser owner、禁止继承且仅含当前用户 ACE 的 DACL。reparse/symlink、非普通对象、祖先类型冲突和未经过正式选择/项目根入口的路径仍失败关闭。
|
||||
|
||||
|
||||
@@ -559,6 +559,39 @@ impl AppConfig {
|
||||
Some(format!("{:x}", digest.finalize()))
|
||||
}
|
||||
|
||||
/// Returns the server-private temporary Router key used until a Router
|
||||
/// provisioning control plane is available. This is deliberately a
|
||||
/// separate setting from the legacy LLM provider key; production builds
|
||||
/// never fall back to arbitrary client/provider configuration.
|
||||
pub(crate) fn effective_agc_router_fallback_api_key(&self) -> Option<String> {
|
||||
if let Some(value) = read_secret_env_or_file(
|
||||
&["GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY"],
|
||||
&["GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY_FILE"],
|
||||
) {
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
{
|
||||
return self
|
||||
.llm_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn effective_agc_router_fallback_account_id(&self) -> String {
|
||||
read_first_non_empty_env(&["GENARRATIVE_AGC_ROUTER_FALLBACK_ACCOUNT_ID"])
|
||||
.unwrap_or_else(|| "server-fallback".to_string())
|
||||
}
|
||||
|
||||
/// 单次 provider attempt 上限:`N × est × 2`。运行时派生,禁止硬编码计算结果;
|
||||
/// worker 角色在 main 中覆盖 `bgfilter_worker_concurrency` 后自动生效。
|
||||
pub fn bgfilter_provider_attempt_timeout_ms(&self) -> u64 {
|
||||
|
||||
@@ -50,6 +50,7 @@ static AGC_ROUTER_PROVISION_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>
|
||||
|
||||
struct ProvisionedRouterCredential {
|
||||
provider_account_id: String,
|
||||
provider_account_json: Option<String>,
|
||||
raw_key: String,
|
||||
externally_provisioned: bool,
|
||||
}
|
||||
@@ -144,6 +145,8 @@ pub async fn create_external_api_key(
|
||||
provider_account_id: None,
|
||||
provider_base_url: None,
|
||||
provider_model: None,
|
||||
provider_account_json: None,
|
||||
credential_version: 1,
|
||||
})
|
||||
.await
|
||||
.map_err(map_external_api_key_error)?;
|
||||
@@ -221,6 +224,36 @@ pub(crate) async fn ensure_agc_router_account(
|
||||
Some(existing) => return Ok(existing.clone()),
|
||||
None => {}
|
||||
}
|
||||
if state
|
||||
.config
|
||||
.agc_router_account_provision_url
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
// A deterministic 401/403 already revoked this local row. Reusing the
|
||||
// same server fallback would otherwise create an unbounded sequence of
|
||||
// unusable rows on every request. A changed fallback prefix is allowed
|
||||
// to recover; formal provisioning is handled by the branch above.
|
||||
let fallback_prefix = state
|
||||
.config
|
||||
.effective_agc_router_fallback_api_key()
|
||||
.and_then(|value| normalize_router_api_key(value.as_str()).ok())
|
||||
.map(|value| external_api_key_prefix(value.as_str()));
|
||||
let fallback_account_id = state.config.effective_agc_router_fallback_account_id();
|
||||
if let Some(fallback_prefix) = fallback_prefix {
|
||||
if existing.iter().any(|record| {
|
||||
record.purpose == EXTERNAL_API_KEY_PURPOSE_AGC_LLM
|
||||
&& record.revoked_at.is_some()
|
||||
&& record.key_prefix == fallback_prefix
|
||||
&& record.provider_account_id.as_deref() == Some(fallback_account_id.as_str())
|
||||
}) {
|
||||
return Err(
|
||||
"AGC Router fallback Key 已被标记失效,请更新服务端 fallback 配置后重试"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate the effective local persistence key before creating a remote
|
||||
// Router account; otherwise an encryption failure could orphan a billable
|
||||
// Router account that the server cannot store or use.
|
||||
@@ -251,11 +284,15 @@ pub(crate) async fn ensure_agc_router_account(
|
||||
let key = state
|
||||
.spacetime_client()
|
||||
.create_external_api_key(ExternalApiKeyCreateRecordInput {
|
||||
key_id,
|
||||
key_id: key_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
name: AGC_LLM_API_KEY_NAME.to_string(),
|
||||
key_prefix: external_api_key_prefix(provisioned.raw_key.as_str()),
|
||||
key_hash: hash_external_api_key(provisioned.raw_key.as_str()),
|
||||
key_hash: hash_agc_router_key(
|
||||
owner_user_id,
|
||||
key_id.as_str(),
|
||||
provisioned.raw_key.as_str(),
|
||||
),
|
||||
scopes: AGC_LLM_API_KEY_SCOPES
|
||||
.iter()
|
||||
.map(|scope| (*scope).to_string())
|
||||
@@ -266,6 +303,8 @@ pub(crate) async fn ensure_agc_router_account(
|
||||
provider_account_id: Some(provisioned.provider_account_id),
|
||||
provider_base_url: Some(state.config.agc_router_base_url.clone()),
|
||||
provider_model: Some(state.config.agc_router_model.clone()),
|
||||
provider_account_json: provisioned.provider_account_json,
|
||||
credential_version: 1,
|
||||
})
|
||||
.await;
|
||||
match key {
|
||||
@@ -402,6 +441,8 @@ async fn persist_agc_router_reconciliation_marker(
|
||||
)),
|
||||
provider_base_url: Some(state.config.agc_router_base_url.clone()),
|
||||
provider_model: Some(state.config.agc_router_model.clone()),
|
||||
provider_account_json: None,
|
||||
credential_version: 1,
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
@@ -509,41 +550,38 @@ async fn provision_router_account(
|
||||
})?;
|
||||
return Ok(ProvisionedRouterCredential {
|
||||
provider_account_id: account_id.to_string(),
|
||||
provider_account_json: sanitize_provider_account_json(data),
|
||||
raw_key: normalize_router_api_key(api_key)?,
|
||||
externally_provisioned: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 当前 Router 与 api-server 尚未共享“创建账号并充值”的正式控制面。
|
||||
// 先把注册视为已具备可用额度,由 api-server 为每个用户签发一个稳定的
|
||||
// Router 账号标识和随机 tnr_sk,并把密文写入 external_api_key。这样
|
||||
// 客户端永远不需要接触凭据;部署侧若已经提供 Router 的正式签发端点,
|
||||
// 上面的受控调用仍优先使用它。Router 侧接入该账号目录后即可无缝替换,
|
||||
// 不需要再改客户端或数据库契约。
|
||||
let provider_account_id =
|
||||
derive_local_router_account_id(owner_user_id, state.config.agc_router_base_url.as_str());
|
||||
// 此时只能使用服务端私有的临时默认 Key;绝不生成 Router 未注册的随机
|
||||
// tnr_sk。没有显式 fallback 就保持失败关闭,避免把不可用凭据写入数据库。
|
||||
let raw_key = state
|
||||
.config
|
||||
.effective_agc_router_fallback_api_key()
|
||||
.ok_or_else(|| "未配置服务端 AGC Router fallback API Key".to_string())?;
|
||||
let raw_key = normalize_router_api_key(raw_key.as_str())?;
|
||||
let provider_account_id = state.config.effective_agc_router_fallback_account_id();
|
||||
Ok(ProvisionedRouterCredential {
|
||||
provider_account_id,
|
||||
raw_key: generate_external_api_key_secret(),
|
||||
provider_account_id: provider_account_id.clone(),
|
||||
provider_account_json: Some(
|
||||
json!({
|
||||
"version": 1,
|
||||
"accountId": provider_account_id.clone(),
|
||||
"routeOrigin": state.config.agc_router_base_url,
|
||||
"model": state.config.agc_router_model,
|
||||
"credentialSource": "server-fallback"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
raw_key,
|
||||
externally_provisioned: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_local_router_account_id(owner_user_id: &str, route_origin: &str) -> String {
|
||||
let account_fingerprint = Sha256::digest(
|
||||
format!(
|
||||
"{}\n{}",
|
||||
owner_user_id.trim(),
|
||||
route_origin.trim_end_matches('/')
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
format!(
|
||||
"agc-router-account-{}",
|
||||
&hex::encode(account_fingerprint)[..32]
|
||||
)
|
||||
}
|
||||
|
||||
fn derive_agc_router_key_id(
|
||||
owner_user_id: &str,
|
||||
route_origin: &str,
|
||||
@@ -569,6 +607,13 @@ fn derive_agc_router_key_id(
|
||||
)
|
||||
}
|
||||
|
||||
fn hash_agc_router_key(owner_user_id: &str, key_id: &str, raw_key: &str) -> String {
|
||||
// AGC rows are server-owned credentials and are never authenticated through
|
||||
// the public external-key endpoint. Bind the uniqueness hash to owner and
|
||||
// row id so a shared temporary fallback key can safely back many accounts.
|
||||
hash_external_api_key(&format!("agc-router:{owner_user_id}:{key_id}:{raw_key}"))
|
||||
}
|
||||
|
||||
fn normalize_router_api_key(value: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if !value.starts_with("tnr_sk_")
|
||||
@@ -582,6 +627,45 @@ fn normalize_router_api_key(value: &str) -> Result<String, String> {
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn sanitize_provider_account_json(value: &Value) -> Option<String> {
|
||||
let sanitized = sanitize_provider_account_value(value);
|
||||
(!sanitized.is_null()).then(|| sanitized.to_string())
|
||||
}
|
||||
|
||||
fn sanitize_provider_account_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => Value::Object(
|
||||
object
|
||||
.iter()
|
||||
.filter(|(key, _)| !is_sensitive_provider_field(key))
|
||||
.map(|(key, value)| (key.clone(), sanitize_provider_account_value(value)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(values) => {
|
||||
Value::Array(values.iter().map(sanitize_provider_account_value).collect())
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_sensitive_provider_field(key: &str) -> bool {
|
||||
matches!(
|
||||
key.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"apikey"
|
||||
| "accesstoken"
|
||||
| "refreshtoken"
|
||||
| "authorization"
|
||||
| "secret"
|
||||
| "password"
|
||||
| "credential"
|
||||
| "credentials"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn encrypt_router_api_key(raw_key: &str, secret: &str) -> Result<String, String> {
|
||||
let key_material = Sha256::digest(secret.trim().as_bytes());
|
||||
let unbound = aead::UnboundKey::new(&aead::AES_256_GCM, key_material.as_ref())
|
||||
@@ -743,6 +827,10 @@ mod tests {
|
||||
provider_account_id: Some("agc-router-account-fixture".to_string()),
|
||||
provider_base_url: route.map(|value| value.to_string()),
|
||||
provider_model: model.map(|value| value.to_string()),
|
||||
provider_account_json: Some(
|
||||
json!({"accountId":"agc-router-account-fixture"}).to_string(),
|
||||
),
|
||||
credential_version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,19 +863,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_router_account_id_is_stable_and_route_scoped() {
|
||||
let first = derive_local_router_account_id("user-1", "https://router.example/v1/");
|
||||
let second = derive_local_router_account_id("user-1", "https://router.example/v1");
|
||||
let other_user = derive_local_router_account_id("user-2", "https://router.example/v1");
|
||||
let other_route = derive_local_router_account_id("user-1", "https://router.example/v2");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_ne!(first, other_user);
|
||||
assert_ne!(first, other_route);
|
||||
assert!(first.starts_with("agc-router-account-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_router_key_id_is_stable_and_generation_scoped() {
|
||||
let first_generation =
|
||||
@@ -921,4 +996,23 @@ mod tests {
|
||||
.expect("revoked rows should not block provisioning");
|
||||
assert!(selected.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_account_metadata_drops_plaintext_credentials() {
|
||||
let sanitized = sanitize_provider_account_json(&json!({
|
||||
"accountId": "router-account-1",
|
||||
"displayName": "fixture",
|
||||
"apiKey": "tnr_sk_should-not-persist-here",
|
||||
"nested": {
|
||||
"access_token": "secret",
|
||||
"region": "cn"
|
||||
},
|
||||
"keyId": "safe-key-id"
|
||||
}))
|
||||
.expect("metadata should remain after redaction");
|
||||
assert!(!sanitized.contains("tnr_sk_should-not-persist-here"));
|
||||
assert!(!sanitized.contains("access_token"));
|
||||
assert!(sanitized.contains("router-account-1"));
|
||||
assert!(sanitized.contains("safe-key-id"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ pub struct ExternalApiKeyRecord {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -33,6 +35,8 @@ pub struct ExternalApiKeyCreateRecordInput {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ExternalApiKeyRecord {
|
||||
@@ -51,6 +55,8 @@ impl fmt::Debug for ExternalApiKeyRecord {
|
||||
.field("provider_account_id", &self.provider_account_id)
|
||||
.field("provider_base_url", &self.provider_base_url)
|
||||
.field("provider_model", &self.provider_model)
|
||||
.field("provider_account_json", &self.provider_account_json)
|
||||
.field("credential_version", &self.credential_version)
|
||||
.field("created_at", &self.created_at)
|
||||
.field("last_used_at", &self.last_used_at)
|
||||
.field("revoked_at", &self.revoked_at)
|
||||
@@ -87,6 +93,8 @@ impl From<ExternalApiKeyCreateRecordInput> for crate::module_bindings::ExternalA
|
||||
provider_account_id: input.provider_account_id,
|
||||
provider_base_url: input.provider_base_url,
|
||||
provider_model: input.provider_model,
|
||||
provider_account_json: input.provider_account_json,
|
||||
credential_version: input.credential_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,5 +167,7 @@ fn map_external_api_key_snapshot(
|
||||
provider_account_id: snapshot.provider_account_id,
|
||||
provider_base_url: snapshot.provider_base_url,
|
||||
provider_model: snapshot.provider_model,
|
||||
provider_account_json: snapshot.provider_account_json,
|
||||
credential_version: snapshot.credential_version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct ExternalApiKeyRecord {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -33,6 +35,8 @@ pub struct ExternalApiKeyCreateRecordInput {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ExternalApiKeyRecord {
|
||||
@@ -51,6 +55,8 @@ impl fmt::Debug for ExternalApiKeyRecord {
|
||||
.field("provider_account_id", &self.provider_account_id)
|
||||
.field("provider_base_url", &self.provider_base_url)
|
||||
.field("provider_model", &self.provider_model)
|
||||
.field("provider_account_json", &self.provider_account_json)
|
||||
.field("credential_version", &self.credential_version)
|
||||
.field("created_at", &self.created_at)
|
||||
.field("last_used_at", &self.last_used_at)
|
||||
.field("revoked_at", &self.revoked_at)
|
||||
@@ -87,6 +93,8 @@ impl From<ExternalApiKeyCreateRecordInput> for crate::module_bindings::ExternalA
|
||||
provider_account_id: input.provider_account_id,
|
||||
provider_base_url: input.provider_base_url,
|
||||
provider_model: input.provider_model,
|
||||
provider_account_json: input.provider_account_json,
|
||||
credential_version: input.credential_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,5 +167,7 @@ fn map_external_api_key_snapshot(
|
||||
provider_account_id: snapshot.provider_account_id,
|
||||
provider_base_url: snapshot.provider_base_url,
|
||||
provider_model: snapshot.provider_model,
|
||||
provider_account_json: snapshot.provider_account_json,
|
||||
credential_version: snapshot.credential_version,
|
||||
})
|
||||
}
|
||||
|
||||
+2
@@ -19,6 +19,8 @@ pub struct ExternalApiKeyCreateInput {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalApiKeyCreateInput {
|
||||
|
||||
+2
@@ -21,6 +21,8 @@ pub struct ExternalApiKeySnapshot {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalApiKeySnapshot {
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct ExternalApiKey {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalApiKey {
|
||||
@@ -47,6 +49,8 @@ pub struct ExternalApiKeyCols {
|
||||
pub provider_account_id: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
|
||||
pub provider_base_url: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
|
||||
pub provider_model: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
|
||||
pub provider_account_json: __sdk::__query_builder::Col<ExternalApiKey, Option<String>>,
|
||||
pub credential_version: __sdk::__query_builder::Col<ExternalApiKey, u32>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ExternalApiKey {
|
||||
@@ -71,6 +75,11 @@ impl __sdk::__query_builder::HasCols for ExternalApiKey {
|
||||
),
|
||||
provider_base_url: __sdk::__query_builder::Col::new(table_name, "provider_base_url"),
|
||||
provider_model: __sdk::__query_builder::Col::new(table_name, "provider_model"),
|
||||
provider_account_json: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"provider_account_json",
|
||||
),
|
||||
credential_version: __sdk::__query_builder::Col::new(table_name, "credential_version"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ pub struct ExternalApiKey {
|
||||
pub provider_base_url: Option<String>,
|
||||
#[default(None)]
|
||||
pub provider_model: Option<String>,
|
||||
/// AGC Router 账号原始 JSON 元数据;普通外部 Key 保持 None。
|
||||
#[default(None)]
|
||||
pub provider_account_json: Option<String>,
|
||||
/// AGC 凭据契约版本,用于后续迁移;历史行默认为 1。
|
||||
#[default(1u32)]
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -52,6 +58,8 @@ pub struct ExternalApiKeyCreateInput {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -88,6 +96,8 @@ pub struct ExternalApiKeySnapshot {
|
||||
pub provider_account_id: Option<String>,
|
||||
pub provider_base_url: Option<String>,
|
||||
pub provider_model: Option<String>,
|
||||
pub provider_account_json: Option<String>,
|
||||
pub credential_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -180,6 +190,8 @@ fn create_external_api_key(
|
||||
provider_account_id: normalize_optional_metadata(input.provider_account_id),
|
||||
provider_base_url: normalize_optional_metadata(input.provider_base_url),
|
||||
provider_model: normalize_optional_metadata(input.provider_model),
|
||||
provider_account_json: normalize_optional_metadata(input.provider_account_json),
|
||||
credential_version: input.credential_version.max(1),
|
||||
});
|
||||
ctx.db
|
||||
.external_api_key()
|
||||
@@ -289,6 +301,8 @@ fn external_api_key_snapshot_from_row(row: ExternalApiKey) -> ExternalApiKeySnap
|
||||
provider_account_id: row.provider_account_id,
|
||||
provider_base_url: row.provider_base_url,
|
||||
provider_model: row.provider_model,
|
||||
provider_account_json: row.provider_account_json,
|
||||
credential_version: row.credential_version,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1276,6 +1276,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
object
|
||||
.entry("provider_model".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("provider_account_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("credential_version".to_string())
|
||||
.or_insert_with(|| serde_json::Value::from(1));
|
||||
}
|
||||
}
|
||||
if table_name == "profile_wallet_config" {
|
||||
|
||||
Reference in New Issue
Block a user