diff --git a/docs/design/managed-session-model-config.md b/docs/design/managed-session-model-config.md new file mode 100644 index 00000000000..08e4a4ef18d --- /dev/null +++ b/docs/design/managed-session-model-config.md @@ -0,0 +1,134 @@ +# Session model configuration at creation + +[English](managed-session-model-config.md) | [简体中文](managed-session-model-config.zh-CN.md) + +## Problem and current behavior + +Hosted execution resolves model connections from operator CLI settings. WebShell +can select a registered model ID, while the public creation API has no model +selection field. An application cannot supply its own model connection for one +Session without changing the Harness deployment. + +## Proposed behavior + +Public creation accepts optional `model_config`; WebShell creation accepts +`modelConfig`. Both use the same object: `protocol`, `model`, `base_url`, +`api_key`, and optional `generation_config`. Supported key-based protocols are +`openai`, `openai-responses`, `anthropic`, and `gemini`. Generation parameters are +explicitly validated sampling parameters; they cannot replace connection fields. +Omitting `generation_config` retains normal request sampling defaults; an explicit +object supplies wire sampling parameters. The Anthropic adapter ignores `seed`, +`presence_penalty` and `frequency_penalty`; accepted fields are not a promise of +support by every provider. +OAuth, ADC, arbitrary headers, environment-variable expansion and credential +rotation are outside this change. No browser credential editor is added. + +The complete connection is immutable for the Session. An explicit `modelId` and +complete configuration cannot be supplied together. Existing Sessions and +requests without the new field retain their behavior. A configured Session +cannot switch to the host catalog; create another Session to use another +connection. Children inherit the parent's connection. + +## Control plane and persistence + +Validate configuration before admission, include its canonical digest in the +creation idempotency digest, and persist the encrypted configuration in the same +transaction as the Session and initial Turn. Reuse `SecretProtector`, binding +encryption to tenant and Session identity. Store only the ciphertext, key ID and +configuration digest on the Session. Keep credentials out of API responses, +public events, receipts and journal resources. + +The deployment must configure permitted model base URLs through +`qwen.managed-agent.model-config-base-urls` and provide a `SecretProtector`. +The default empty list disables new inline configurations. URLs must match a +permitted endpoint and cannot contain user information, query strings or +fragments. This policy controls the new caller-selected outbound connection; +it does not register model IDs or credentials in the Harness. + +Update the unreleased shared V1 schema in place. Existing development databases +must be recreated according to the repository's schema policy; do not modify +running user databases. Copy and re-encrypt configuration when admitting a child +Session. Include the new columns in schema inventory and recovery projections. + +## Private execution protocol + +Advertise `session-model-config/1`. Carry only `modelConfigDigest` in private +create/load requests and persist it in the managed definition. Check it on +resident reuse and cold load. An older Harness must refuse attachment rather +than execute with deployment defaults. + +Add a Session Store read protected by tenant, workspace, writer token, writer ID +and generation. It returns the configuration only for the matching digest and +current execution owner, with `Cache-Control: no-store`. Lifecycle reads retain +the existing lifecycle authority checks. Missing, changed or unavailable +configuration fails closed. Reading secrets does not create journal resources. + +## Harness configuration isolation + +Resolve configuration through the authenticated store only when a digest is +bound. Inject it into each new execution Config through a host-only option. +Bypass environment and user provider resolution for this connection, remove +ambient model registries, auxiliary model selectors and fallbacks, and preserve +all unrelated operator settings. Do not change process environment or settings +files. Do not look up the supplied model in the host registry. +Derive modalities and context capacity from the supplied model name, preserving +operator logging and reasoning-effort preferences without importing ambient +connection or generation settings. Unknown models retain text-only defaults. + +Apply the same binding to normal Turns, approval continuations, workflow Turns, +context compression and lifecycle model Hooks. Existing provider-pin checks +continue to protect resumed requests and child execution. A child request for a +different route must not reuse the configured connection's credentials. + +## Multi-tenant deployment boundary + +The intended caller is a trusted application service that authenticates tenant +and actor identity before supplying a connection. The platform signing key must +remain with that service or its trusted gateway. Tenant/Session-bound storage +and writer authorization provide application-level isolation; a shared Harness +process remains in the same operational trust domain. + +The endpoint allowlist is deployment-wide. This change does not grant or check +tenant-specific model permissions. Deployments with different tenant policies +must enforce allowed endpoints, protocols and generation limits in the trusted +caller. Tenant-level quotas and outbound network restrictions remain deployment +responsibilities. + +Each Session retains its own encrypted, immutable credential snapshot, including +after child inheritance. Provider credential revocation can make existing +Sessions unable to execute; changing a key requires a new Session under this +contract. A future tenant-owned connection/secret reference would need explicit +version, rotation and recovery semantics. Applications that already manage +credentials can continue supplying them without a second model registry here. + +## Affected components + +- Public/WebShell contracts, creation services and SQL Session admission. +- Session configuration validation, encrypted persistence and owner-only read. +- Java SDK create/load requests and single/pool Harness connectors. +- Hosted create/load definitions and all model execution entry points. +- CLI configuration construction, OpenAPI and generated WebShell types. +- Shared schema inventory, focused regression and process integration tests. + +## Validation and acceptance + +Use isolated fake providers and synthetic keys. Create two concurrent Sessions +with the same model ID but different endpoints, keys and sampling parameters; +verify each provider receives only its own connection. Cover omitted configuration, +invalid protocols/fields/URLs, idempotency replay and conflict, encrypted SQL +storage, read denial for wrong tenants/writers, and absence of secrets in public +responses and journal resources. Reattach after Harness restart and verify the +same binding. Cover workflow, compression, Hooks and child inheritance. + +Run the global CLI baseline before implementation, then focused Java/SDK and +TypeScript tests, build, typecheck and bundle. Java local verification shares the +module's ten-minute budget; unavailable MySQL or process checks remain explicit +CI acceptance gaps. Finish with two clean self-audit passes and independent +review. This document describes the proposed implementation, not completed +acceptance. + +## Open questions + +None required for the initial immutable, API-key-based connection. Updating +credentials on an existing Session requires a separate revision and recovery +contract. diff --git a/docs/design/managed-session-model-config.zh-CN.md b/docs/design/managed-session-model-config.zh-CN.md new file mode 100644 index 00000000000..e1df2461711 --- /dev/null +++ b/docs/design/managed-session-model-config.zh-CN.md @@ -0,0 +1,104 @@ +# 创建会话时配置模型连接 + +[English](managed-session-model-config.md) | [简体中文](managed-session-model-config.zh-CN.md) + +## 问题与当前行为 + +Hosted 执行从运维配置的 CLI settings 解析模型连接。WebShell 可以选择已登记的模型 +ID,公共创建 API 没有模型选择字段。业务应用无法在不修改 Harness 部署配置的情况下, +为单个 Session 提供自己的模型连接。 + +## 预期行为 + +公共创建请求增加可选 `model_config`,WebShell 创建请求增加 `modelConfig`。两者使用 +相同对象:`protocol`、`model`、`base_url`、`api_key` 和可选的 +`generation_config`。支持基于 API Key 的 `openai`、`openai-responses`、 +`anthropic` 和 `gemini` 协议。生成参数使用明确校验的采样参数,不能覆盖连接字段。 +省略 `generation_config` 时保留正常请求的采样默认值;显式对象用于下发 wire 采样参数。 +Anthropic 适配器忽略 `seed`、`presence_penalty` 和 `frequency_penalty`; +接受字段不代表每个服务商都支持这些参数。 +OAuth、ADC、任意请求头、环境变量展开和凭据轮换不在本次范围内。不新增浏览器凭据编辑器。 + +完整连接在 Session 内不可变。不能同时提交显式 `modelId` 和完整配置。已有 Session +以及省略新字段的请求保持原有行为。使用完整配置的 Session 不能切换到宿主模型目录; +使用其他连接时创建新 Session。子会话继承父会话的连接。 + +## 控制面与持久化 + +准入前校验配置,将其规范化摘要纳入创建请求幂等摘要,并在创建 Session 和首轮的同一 +事务中保存加密配置。复用 `SecretProtector`,将加密上下文绑定到租户和 Session 身份。 +Session 仅保存密文、密钥 ID 和配置摘要。API 响应、公共事件、回执和日志资源不包含凭据。 + +部署通过 `qwen.managed-agent.model-config-base-urls` 配置允许的模型 API 地址,并提供 +`SecretProtector`。默认空列表禁止新的内联配置。URL 必须匹配允许的端点,不能包含用户 +信息、查询参数或片段。这项策略约束新增的调用方指定出站连接,不会在 Harness 中登记 +模型 ID 或凭据。 + +按仓库规则原地更新尚未发布的共享 V1 schema。已有开发数据库需要重建,不修改正在运行 +的用户数据库。准入子 Session 时复制配置,并按子 Session 上下文重新加密。schema +清单与恢复投影同步覆盖新增列。 + +## 私有执行协议 + +声明 `session-model-config/1` 能力。私有 create/load 请求只携带 +`modelConfigDigest`,并将其写入托管定义。复用驻留会话和冷加载时核对绑定。旧 Harness +必须拒绝挂接,不能使用部署默认值执行。 + +增加 Session Store 读取接口,校验租户、工作区、writer token、writer ID 和 generation。 +仅向当前执行所有者返回摘要匹配的配置,并设置 `Cache-Control: no-store`。生命周期读取 +继续校验已有生命周期授权。配置缺失、改变或不可用时拒绝执行。读取秘密不创建日志资源。 + +## Harness 配置隔离 + +只有绑定摘要时才通过鉴权存储读取完整配置。通过仅宿主可用的选项注入每次新建的执行 +Config。该连接跳过环境变量和用户 provider 解析,清除宿主模型注册表、辅助模型选择和 +回退配置,保留其他运维设置。不修改进程环境或 settings 文件,不在宿主注册表中查找 +下发的模型。 +从下发的模型名称推导输入模态和上下文容量,保留运维的日志与推理强度偏好,但不引入 +宿主连接或生成设置。未知模型保留仅文本的默认能力。 + +普通 Turn、审批继续执行、workflow Turn、上下文压缩和生命周期模型 Hook 使用同一绑定。 +已有 provider pin 校验继续保护恢复请求和子会话执行。子会话请求其他路由时,不能借用 +该配置的凭据。 + +## 多租户部署边界 + +预期调用方是可信业务服务,在下发连接前鉴权并确定租户和 actor 身份。平台签名密钥 +必须保留在该服务或可信网关中。绑定租户/Session 的存储和 writer 授权提供应用层 +隔离;共享 Harness 进程仍处于同一个运维信任域。 + +地址白名单作用于整个部署。本次不授予或检查租户专属的模型使用权限。不同租户采用 +不同策略时,可信调用方必须约束允许的端点、协议和生成参数上限。租户配额和出站网络 +限制仍由部署侧负责。 + +每个 Session 保存独立加密、不可变的凭据快照,子会话继承后也是如此。服务商吊销 +凭据可能使既有 Session 无法继续执行;按当前契约,更换 key 需要创建新 Session。 +未来若引入租户名下的连接/秘密引用,需要明确版本、轮换和恢复语义。已经管理凭据的 +上层应用可以继续直接下发,无需在这里再建一套模型注册表。 + +## 涉及组件 + +- 公共和 WebShell 契约、创建服务与 SQL Session 准入。 +- 会话配置校验、加密持久化及仅执行所有者可用的读取接口。 +- Java SDK create/load 请求及单实例、池化 Harness connector。 +- Hosted create/load 定义与所有模型执行入口。 +- CLI 配置构建、OpenAPI 及生成的 WebShell 类型。 +- 共享 schema 清单、定向回归测试和真实进程集成测试。 + +## 验证与验收 + +使用隔离的模拟 provider 和测试密钥。并发创建两个模型 ID 相同、地址、密钥和采样参数 +不同的 Session,确认各 provider 仅收到自己的配置。覆盖省略配置、无效协议/字段/URL、 +幂等重放和冲突、SQL 加密保存、错误租户或 writer 读取被拒绝,以及公共响应和日志资源 +不含秘密。重启 Harness 后重新挂接,确认连接绑定不变。覆盖 workflow、压缩、Hook 和 +子会话继承。 + +实现前用全局 CLI 验证基线,再执行定向 Java/SDK 和 TypeScript 测试、build、typecheck +及 bundle。Java 本地验证共享模块的十分钟预算;无法运行的 MySQL 或真实进程检查明确 +保留为 CI 验收缺口。最后完成两轮干净自审和独立评审。本文件描述拟实现方案,不代表 +验收已经完成。 + +## 待定事项 + +初版不可变、基于 API Key 的连接没有必须先解决的开放问题。已有 Session 的凭据更新 +需要单独定义修订与恢复契约。 diff --git a/docs/users/managed-agent-configuration-reference.md b/docs/users/managed-agent-configuration-reference.md index 382b57d0353..aa610d6fdd2 100644 --- a/docs/users/managed-agent-configuration-reference.md +++ b/docs/users/managed-agent-configuration-reference.md @@ -41,6 +41,7 @@ Top-level settings of the Managed Agent server: the built-in agent revision, wha | `definition-tool-profiles` | `QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES` | list | `hosted-workspace-files/1` | The hosted tool profiles a stored AgentDefinition may select in `tools`, as a comma-separated list (such as `hosted-workspace-files/1`, `hosted-workspace-shell/1` or `hosted-workspace-mcp/1`); a Session whose definition selects another profile is refused at admission. `hosted-workspace-team-lead/1` and Shell profiles other than `hosted-workspace-shell/1` are always refused, as is a Shell profile whose approval mode is `yolo`. Preflight checks that the Harness supports each listed profile. | | `message-relay.scan-delay` | — | duration | `2s` | How long the Session message relay waits between passes; each pass moves pending messages between Sessions (handover, receipt, acceptance) and settles those that cannot be delivered. Skipped on a node without an available Hosted Harness. Accepts a duration such as `2s`; a plain number is milliseconds. | | `model-catalog` | — | YAML list | *(empty)* | The models offered for Sessions of the built-in `qwen-code` agent, selectable at Session creation and changeable later by a Workspace operator. A list of objects with `model-id` (a model ID configured in the Harness; required, unique, at most 512 bytes) and `label` (display name, defaults to `model-id`, at most 512 characters); an invalid or duplicate entry is refused at startup. Set it in a YAML configuration file; there is no environment variable. Selection also needs `qwen.managed-agent.harness.workspace-files-enabled` and Harness support. | +| `model-config-base-urls` | — | YAML list | *(empty)* | Exact HTTP(S) base URLs permitted for complete Session model configurations, set as a YAML list. Empty disables admission of new configurations. Public creation accepts `model_config`; WebShell creation accepts `modelConfig`. Requires the Session Store, Harness capability `session-model-config/1` and a `SecretProtector` (provided by the enabled Runtime Broker and its credential key ring in the standard server). Model IDs and API keys need no Harness registration. See the [Session model configuration design](../design/managed-session-model-config.md). | | `service-user` | `QWEN_MANAGED_AGENT_SERVICE_USER` | string | *(empty)* | The operating-system account the server runs as. Only the preflight check reads it: it confirms the account exists and checks that the configuration and credentials files are readable by that account rather than by the account running preflight. Empty checks access as the account running preflight. | | `session-worktree.scan-delay` | — | duration | `1000` | How long the Session worktree pass waits between runs; each run resumes pending per-Session Git worktree steps and fails the initial Turns of Sessions whose worktree could not be prepared. Accepts a duration such as `1s`; a plain number is milliseconds. | | `trusted-actor-header` | `QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER` | string | *(empty)* | The name of a request header (for example `X-Qwen-Actor-Id`) whose value is trusted as the caller's actor ID, together with `X-Qwen-Tenant-Id`, when no upstream principal is present. Used only in open authentication mode; combining it with signed mode is refused at startup. In open mode, `qwen.managed-agent.runtime-broker.local-directory-import-enabled` requires it. Never set it where untrusted clients can reach the server. Empty disables it. | diff --git a/docs/users/managed-agent-configuration-reference.zh-CN.md b/docs/users/managed-agent-configuration-reference.zh-CN.md index 956134ddc15..c25ef698c7a 100644 --- a/docs/users/managed-agent-configuration-reference.zh-CN.md +++ b/docs/users/managed-agent-configuration-reference.zh-CN.md @@ -41,6 +41,7 @@ Managed Agent 服务端的顶层设置:内置 agent 的修订号、存储的 A | `definition-tool-profiles` | `QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES` | 列表 | `hosted-workspace-files/1` | 存储的 AgentDefinition 可在 `tools` 中选择的托管工具配置,以逗号分隔(如 `hosted-workspace-files/1`、`hosted-workspace-shell/1` 或 `hosted-workspace-mcp/1`);定义选择其他配置的 Session 会在准入时被拒绝。`hosted-workspace-team-lead/1` 以及 `hosted-workspace-shell/1` 以外的 Shell 配置始终被拒绝,审批模式为 `yolo` 的 Shell 配置同样被拒绝。预检会确认 Harness 支持所列的每个配置。 | | `message-relay.scan-delay` | — | 时长 | `2s` | Session 消息中继两次运行之间的间隔;每次运行推进 Session 之间待投递的消息(移交、回执、接受),并结算无法投递的消息。没有可用 Hosted Harness 的节点会跳过。接受 `2s` 这类时长;不带单位的数字按毫秒计。 | | `model-catalog` | — | YAML 列表 | *(空)* | 为内置 `qwen-code` agent 的 Session 提供的可选模型,可在创建 Session 时选择,之后也可由 Workspace operator 更改。元素为对象列表,包含 `model-id`(Harness 中配置的模型 ID;必填、唯一、最多 512 字节)和 `label`(显示名称,默认等于 `model-id`,最多 512 个字符);条目无效或重复时启动时拒绝。需在 YAML 配置文件中设置,没有对应的环境变量。模型选择还要求开启 `qwen.managed-agent.harness.workspace-files-enabled`,且 Harness 支持模型选择。 | +| `model-config-base-urls` | — | YAML 列表 | *(空)* | 允许完整 Session 模型配置使用的 HTTP(S) API 地址,按完整字符串精确匹配,以 YAML 列表设置。为空时禁止准入新配置。公共创建接口接受 `model_config`,WebShell 创建接口接受 `modelConfig`。要求启用 Session Store、Harness 能力 `session-model-config/1` 和 `SecretProtector`(标准服务由已启用的 Runtime Broker 及其凭据密钥环提供)。模型 ID 和 API key 无需在 Harness 注册。参见 [Session 模型配置设计](../design/managed-session-model-config.zh-CN.md)。 | | `service-user` | `QWEN_MANAGED_AGENT_SERVICE_USER` | 字符串 | *(空)* | 服务端运行所用的操作系统账号。仅预检会读取该值:确认账号存在,并按该账号(而非运行预检的账号)检查配置文件和凭据文件是否可读。为空时按运行预检的账号检查访问权限。 | | `session-worktree.scan-delay` | — | 时长 | `1000` | Session worktree 任务两次运行之间的间隔;每次运行继续未完成的单 Session Git worktree 步骤,并使 worktree 未能准备好的 Session 的初始 Turn 失败。接受 `1s` 这类时长;不带单位的数字按毫秒计。 | | `trusted-actor-header` | `QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER` | 字符串 | *(空)* | 一个请求头名称(例如 `X-Qwen-Actor-Id`),在没有上游认证主体时,其值连同 `X-Qwen-Tenant-Id` 一起被信任为调用方的 actor ID。仅在 open 认证模式下使用;与 signed 模式同时配置时启动时拒绝。open 模式下开启 `qwen.managed-agent.runtime-broker.local-directory-import-enabled` 时必须设置本项。切勿在不可信客户端能访问服务端的环境中设置。为空时禁用。 | diff --git a/docs/users/managed-agent-reference-deployment.md b/docs/users/managed-agent-reference-deployment.md index 9a31c796777..4873f21ca75 100644 --- a/docs/users/managed-agent-reference-deployment.md +++ b/docs/users/managed-agent-reference-deployment.md @@ -280,21 +280,32 @@ Wrap every map key in `[ ... ]`. Spring's relaxed binder normalises plain keys Restart the single Java control plane, verify that existing bindings load, and verify that new binding rows record `reference-v2`. New secrets use only the active key; old rows remain encrypted under their recorded key ID. The server does not re-encrypt old rows on read, and removing a still-referenced key makes those rows unreadable. -Retire `reference-v1` only when no persisted row references it. `ma_runtime_binding.credential_key_id` and `ma_runtime_binding.runtime_credential_key_id` are the only persisted references; both store UTF-8 bytes. Select them without `CONVERT` so the deduplication stays byte-exact, the way key selection is: a converted value carries the character set's default collation and would merge two IDs that differ only in case or accent, hiding a key that rows still reference. +Retire `reference-v1` only when no persisted row references it. Runtime bindings, +complete Session model configurations, and extension settings retain key IDs. +Compare them as bytes so case or accent differences cannot hide a still-required +key: ```sql SELECT DISTINCT credential_key_id AS key_id FROM ma_runtime_binding WHERE credential_key_id IS NOT NULL UNION SELECT DISTINCT runtime_credential_key_id AS key_id -FROM ma_runtime_binding WHERE runtime_credential_key_id IS NOT NULL; +FROM ma_runtime_binding WHERE runtime_credential_key_id IS NOT NULL +UNION +SELECT DISTINCT CAST(model_config_key_id AS BINARY) AS key_id +FROM ma_session WHERE model_config_key_id IS NOT NULL +UNION +SELECT DISTINCT CAST(encryption_key_id AS BINARY) AS key_id +FROM ma_extension_settings WHERE encryption_key_id IS NOT NULL; ``` -The result lists the key IDs that persisted rows still require: expect the active ID (`reference-v2`) only. While `reference-v1` appears, that key must stay configured. +The result lists the key IDs that persisted rows still require. Keep every returned key configured; `reference-v1` can be retired only when it is absent from the result. + +Session model configurations are written once; closing or deleting a Session retains their rows. Extension settings retain earlier revisions. Rotating the active key does not re-encrypt or remove these records, so their old key IDs stay in the result while the records remain. This procedure provides no re-encryption or physical-purge step; keep the required keys configured and do not delete rows just to clear the check. The key ring is a map and cannot be supplied through a `QWEN_MANAGED_AGENT_*` environment variable: relaxed binding silently resolves an env-var form to an empty or mis-keyed ring, with no startup error. Configure `previous-credential-keys` in `config/application.yml`. The distribution ships `config/application.example.yml`, and both launchers export `SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.yml` when that file exists, so the location no longer depends on the process working directory and no option is added to the command's positional arguments. When the variable is already set, the launchers leave it in effect instead. A deployment that relies on environment variables alone starts with an empty ring, so rotating `credential-key-id` without configuration of the previous key makes rows written under it unreadable. An explicit `--spring.config.additional-location` command-line argument outranks this environment default; when both are present, the command-line value wins and the bundled `config/application.yml` is not loaded. -The operator-recovery entry reads the same active pair and `previous-credential-keys` map as the server. Its launcher loads `config/application.yml` the same way; when the file lives elsewhere, set `SPRING_CONFIG_ADDITIONAL_LOCATION=file:/application.yml`. Keep the new key active while retaining every key still referenced by a binding. The CSI/migration maintenance mains, including the CSI Hosted probe, still read a single credential key pair; launch those entries with the key that encrypted their target binding as the active pair. +The operator-recovery entry reads the same active pair and `previous-credential-keys` map as the server. Its launcher loads `config/application.yml` the same way; when the file lives elsewhere, set `SPRING_CONFIG_ADDITIONAL_LOCATION=file:/application.yml`. Keep the new key active while retaining every key still referenced by a persisted row. The CSI/migration maintenance mains, including the CSI Hosted probe, still read a single credential key pair; launch those entries with the key that encrypted their target binding as the active pair. After successful key-ring configuration, the server and recovery entry log the resolved active ID and configured IDs once per protector instance. These IDs are non-secret identifiers already stored beside ciphertext; diagnostics JSON-escape controls and non-ASCII characters, preserve spaces and case, and never include key material, ciphertext or tokens. Compare the configured IDs with the exact IDs returned by the retirement query before using the rotated deployment. An unavailable-key error names the requested, active and configured IDs, so a silently normalised or mistyped mapping can be located without exposing secrets. This diagnostic does not prove that every stored row can be decrypted. diff --git a/docs/users/managed-agent-reference-deployment.zh-CN.md b/docs/users/managed-agent-reference-deployment.zh-CN.md index aff982944eb..f0f48f4d747 100644 --- a/docs/users/managed-agent-reference-deployment.zh-CN.md +++ b/docs/users/managed-agent-reference-deployment.zh-CN.md @@ -280,21 +280,31 @@ qwen: 重启单 Java control plane,验证既有 binding 可以加载,并确认新 binding 记录写入 `reference-v2`。新 secret 只使用 active key;旧记录继续由其已记录的 key ID 加密。服务器不会在读取时重加密旧记录,删除仍被引用的密钥会使这些记录不可读。 -只有在没有持久化记录再引用 `reference-v1` 之后,才能退场该密钥。`ma_runtime_binding` 的 `credential_key_id` 与 `runtime_credential_key_id` 是仅有的两处持久化引用;两列都以 UTF-8 字节存储。查询时不要加 `CONVERT`,让去重与密钥选择一样按字节精确比较:转换后的值携带该字符集的默认排序规则,会把仅大小写或重音不同的两个 ID 合并,从而漏掉仍被记录引用的密钥。 +只有在没有持久化记录再引用 `reference-v1` 之后,才能退场该密钥。 +Runtime binding、完整 Session 模型配置和扩展配置都会保存密钥 ID。 +按字节比较这些 ID,避免大小写或重音差异掩盖仍被引用的密钥: ```sql SELECT DISTINCT credential_key_id AS key_id FROM ma_runtime_binding WHERE credential_key_id IS NOT NULL UNION SELECT DISTINCT runtime_credential_key_id AS key_id -FROM ma_runtime_binding WHERE runtime_credential_key_id IS NOT NULL; +FROM ma_runtime_binding WHERE runtime_credential_key_id IS NOT NULL +UNION +SELECT DISTINCT CAST(model_config_key_id AS BINARY) AS key_id +FROM ma_session WHERE model_config_key_id IS NOT NULL +UNION +SELECT DISTINCT CAST(encryption_key_id AS BINARY) AS key_id +FROM ma_extension_settings WHERE encryption_key_id IS NOT NULL; ``` -查询结果列出持久化记录仍需要的 key ID:预期只剩 active ID(`reference-v2`)。只要结果中仍出现 `reference-v1`,就必须继续配置该密钥。 +查询结果列出持久化记录仍需要的密钥 ID。结果中出现的每把密钥都必须继续配置;只有 `reference-v1` 不再出现时,才能退场该密钥。 + +Session 模型配置只在创建时写入,关闭或删除 Session 会保留相关行;扩展配置也会保留历史修订。切换 active key 不会重加密或移除这些记录,因此只要记录仍在,旧密钥 ID 就会继续出现在结果中。本流程不提供重加密或物理清除步骤;请保留所需密钥,不要仅为通过该检查而删除持久化记录。 该 key ring 是映射结构,无法通过 `QWEN_MANAGED_AGENT_*` 环境变量提供:宽松绑定会把环境变量形式静默解析为空 ring 或键名错误的 ring,且启动时不报错。`previous-credential-keys` 配置在 `config/application.yml` 中。分发产物随包提供 `config/application.example.yml`,两个启动脚本都会在该文件存在时导出 `SPRING_CONFIG_ADDITIONAL_LOCATION=file:<分发产物根目录>/config/application.yml`,因此加载位置不再取决于进程工作目录,也不会向命令的位置参数注入选项。若该变量已设置,启动脚本保留原值。只依赖环境变量的部署会以空 ring 启动,因此在未配置旧密钥的情况下轮换 `credential-key-id` 会使旧密钥写入的记录不可读。显式的 `--spring.config.additional-location` 命令行参数优先于该环境变量默认值;两者同时存在时命令行值生效,随包的 `config/application.yml` 不会被加载。 -`operator-recovery` 入口与服务器读取相同的 active pair 和 `previous-credential-keys` 映射。其启动脚本同样加载 `config/application.yml`;文件位于其他位置时,设置 `SPRING_CONFIG_ADDITIONAL_LOCATION=file:<绝对路径>/application.yml`。保持新密钥为 active,并保留 binding 仍引用的每把旧密钥。CSI/迁移维护入口(含 CSI Hosted 探针)仍只读取单个 credential key pair;这些入口继续以加密目标 binding 的密钥作为 active pair 启动。 +`operator-recovery` 入口与服务器读取相同的 active pair 和 `previous-credential-keys` 映射。其启动脚本同样加载 `config/application.yml`;文件位于其他位置时,设置 `SPRING_CONFIG_ADDITIONAL_LOCATION=file:<绝对路径>/application.yml`。保持新密钥为 active,并保留持久化记录仍引用的每把旧密钥。CSI/迁移维护入口(含 CSI Hosted 探针)仍只读取单个 credential key pair;这些入口继续以加密目标 binding 的密钥作为 active pair 启动。 密钥环配置成功后,服务器和恢复入口会为每个 protector 实例记录一次解析后的 active ID 和 configured IDs。这些 ID 是已随密文保存的非 secret 标识;诊断通过 JSON 转义控制字符与非 ASCII 字符、保留空格和大小写,绝不包含密钥材料、密文或 token。使用轮换后的部署前,应将 configured IDs 与退场查询返回的精确 ID 对照。密钥不可用错误会同时给出 requested、active 和 configured IDs,便于定位被静默规整或拼错的映射,同时避免泄露 secret。这些诊断不能证明每条持久化记录都能解密。 diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 6ae0b0b5dcb..99deb34af9f 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1804,6 +1804,113 @@ describe('loadCliConfig', () => { expect(nativeLspServiceMock).not.toHaveBeenCalled(); }); + it('isolates a host-owned Session connection from same-ID providers, environment and auxiliary models', async () => { + process.argv = ['node', 'script.js']; + vi.stubEnv('OPENAI_API_KEY', 'ambient-key'); + vi.stubEnv('OPENAI_BASE_URL', 'https://ambient.example/v1'); + const argv = await parseArguments(); + const settings: Settings = { + modelProviders: { + openai: [ + { + id: 'shared', + apiKey: 'catalog-key', + baseUrl: 'https://catalog.example/v1', + }, + ], + }, + fastModel: 'ambient-fast', + compactionModel: 'ambient-compaction', + advisorModel: 'ambient-advisor', + modelFallbacks: ['ambient-fallback'], + }; + const modelConfiguration = { + authType: AuthType.USE_ANTHROPIC, + model: 'shared', + apiKey: 'session-key', + baseUrl: 'https://session.example/v1', + samplingParams: { temperature: 0.4 }, + }; + await loadCliConfig( + settings, + argv, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + { modelConfiguration }, + ); + expect(mockConfigConstructorParams).toHaveBeenLastCalledWith( + expect.objectContaining({ + authType: AuthType.USE_ANTHROPIC, + generationConfig: expect.objectContaining(modelConfiguration), + modelProvidersConfig: {}, + providerProtocolConfig: {}, + fastModel: undefined, + compactionModel: undefined, + advisorModel: undefined, + modelFallbacks: [], + }), + ); + expect(settings.fastModel).toBe('ambient-fast'); + expect(settings.modelProviders?.openai?.[0]?.apiKey).toBe('catalog-key'); + expect(process.env['OPENAI_API_KEY']).toBe('ambient-key'); + }); + + it.each<[string, Record, number | undefined]>([ + ['gpt-4o-mini', { image: true }, 131072], + ['unknown-session-model', {}, undefined], + ])( + 'derives capabilities for a Session connection using %s without ambient generation settings', + async (model, modalities, contextWindowSize) => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const modelConfiguration = { + authType: AuthType.USE_OPENAI, + model, + apiKey: 'session-key', + baseUrl: 'https://session.example/v1', + }; + await loadCliConfig( + { + model: { + generationConfig: { + modalities: { video: true }, + contextWindowSize: 7, + samplingParams: { temperature: 1.9 }, + }, + enableOpenAILogging: true, + openAILoggingDir: '/tmp/session-model-audit', + reasoningEffort: 'high', + }, + }, + argv, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + { modelConfiguration }, + ); + const actual = + mockConfigConstructorParams.mock.calls.at(-1)?.[0].generationConfig; + expect(actual).toMatchObject({ + ...modelConfiguration, + enableOpenAILogging: true, + openAILoggingDir: '/tmp/session-model-audit', + reasoning: { effort: 'high' }, + }); + expect(actual?.modalities).toEqual(modalities); + expect(actual?.contextWindowSize).toBe(contextWindowSize); + expect(actual?.samplingParams).toBeUndefined(); + }, + ); + it('passes the effective model API to Config at startup', async () => { process.argv = ['node', 'script.js']; vi.stubEnv('RESPONSES_KEY', 'responses-key'); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 2726ee9768d..804040d6725 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -5,6 +5,7 @@ */ import { readSshWorkspace } from '../serve/ssh-workspace-store.js'; +import type { ContentGeneratorConfig } from '@qwen-code/qwen-code-core/core/contentGenerator.js'; import { isInteractiveInvocation } from '../utils/interactive-mode.js'; import { SshExecutionEnvironment } from '@qwen-code/qwen-code-core/services/ssh-execution-environment.js'; import { @@ -1763,6 +1764,7 @@ export async function loadCliConfig( * construction may install the executor-boundary callback. */ hostPolicy?: { + modelConfiguration?: ContentGeneratorConfig & { authType: AuthType }; toolInvocationGuard?: ToolInvocationGuard; workflowAncestorTrustProvider?: ConfigParameters['workflowAncestorTrustProvider']; shellExecutionSandbox?: ConfigParameters['shellExecutionSandbox']; @@ -1784,6 +1786,20 @@ export async function loadCliConfig( }, enabledSkillNamesProvider?: () => ReadonlySet, ): Promise { + const modelConfiguration = hostPolicy?.modelConfiguration; + if (modelConfiguration) { + settings = { + ...settings, + modelProviders: {}, + providerProtocol: {}, + fastModel: undefined, + compactionModel: undefined, + advisorModel: undefined, + visionModel: undefined, + imageModel: undefined, + modelFallbacks: undefined, + }; + } assertKnownOmniSettingKeys(settings); const agentHostReadOnly = hostPolicy?.agentHostReadOnly === true; const sshWorkspace = agentHostReadOnly ? undefined : readSshWorkspace(cwd); @@ -2275,18 +2291,49 @@ export async function loadCliConfig( // up-front provider validation above is what classifies invalid configuration; // this call's own settings reads must not re-wrap a resolver // defect as a user config error, so it stays unwrapped. - const resolvedCliConfig = resolveCliGenerationConfig({ - argv: { - model: argv.model, - openaiApiKey: argv.openaiApiKey, - openaiBaseUrl: argv.openaiBaseUrl, - openaiLogging: argv.openaiLogging, - openaiLoggingDir: argv.openaiLoggingDir, - }, - settings, - selectedAuthType, - env: process.env as Record, - }); + const resolvedCliConfig: ReturnType = + modelConfiguration + ? { + authType: modelConfiguration.authType, + model: modelConfiguration.model, + apiKey: modelConfiguration.apiKey ?? '', + baseUrl: modelConfiguration.baseUrl ?? '', + generationConfig: { + ...resolveCliGenerationConfig({ + argv: { + model: modelConfiguration.model, + openaiApiKey: modelConfiguration.apiKey ?? '', + openaiBaseUrl: modelConfiguration.baseUrl ?? '', + openaiLogging: argv.openaiLogging, + openaiLoggingDir: argv.openaiLoggingDir, + }, + settings: { + model: { + enableOpenAILogging: settings.model?.enableOpenAILogging, + openAILoggingDir: settings.model?.openAILoggingDir, + reasoningEffort: settings.model?.reasoningEffort, + }, + }, + selectedAuthType: modelConfiguration.authType, + env: {}, + }).generationConfig, + ...modelConfiguration, + }, + sources: {}, + warnings: [], + } + : resolveCliGenerationConfig({ + argv: { + model: argv.model, + openaiApiKey: argv.openaiApiKey, + openaiBaseUrl: argv.openaiBaseUrl, + openaiLogging: argv.openaiLogging, + openaiLoggingDir: argv.openaiLoggingDir, + }, + settings, + selectedAuthType, + env: process.env as Record, + }); const { model: resolvedModel } = resolvedCliConfig; @@ -2915,10 +2962,9 @@ export async function loadCliConfig( compactionModel: settings.compactionModel || undefined, imageModel: settings.imageModel || undefined, visionBridgeTimeoutMs: settings.visionBridgeTimeoutMs, - modelFallbacks: resolveModelFallbacks( - argv.fallbackModel, - settings.modelFallbacks, - ), + modelFallbacks: modelConfiguration + ? [] + : resolveModelFallbacks(argv.fallbackModel, settings.modelFallbacks), // Use separated hooks if provided, otherwise fall back to merged hooks ...resolveHookSettingsForConfig( settings.hooks, diff --git a/packages/cli/src/serve/capability-parity.ts b/packages/cli/src/serve/capability-parity.ts index 29694521c7b..f43abcb17c1 100644 --- a/packages/cli/src/serve/capability-parity.ts +++ b/packages/cli/src/serve/capability-parity.ts @@ -617,6 +617,7 @@ export const MANAGED_ONLY_NOT_A_GAP = new Set([ 'PUB_STREAM_PREFIX', 'PUB_STREAM_SEAL', 'STORE_APPROVAL_QUERY', + 'STORE_MODEL_CONFIG_QUERY', 'STORE_USER_MESSAGE_CLAIM', 'STORE_EXECUTION_AUTHORIZE', 'STORE_LIFECYCLE_AUTHORIZE', diff --git a/packages/cli/src/serve/hosted-context-compression.test.ts b/packages/cli/src/serve/hosted-context-compression.test.ts index c257c415dfd..aec24669609 100644 --- a/packages/cli/src/serve/hosted-context-compression.test.ts +++ b/packages/cli/src/serve/hosted-context-compression.test.ts @@ -9,6 +9,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import type { Content } from '@google/genai'; +import { AuthType } from '@qwen-code/qwen-code-core/core/contentGenerator.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { openManagedSession, @@ -44,6 +45,7 @@ const config = { getFastModel: () => undefined, getModelsConfig: () => ({ getCurrentAuthType: () => 'openai' }), refreshAuth: vi.fn(), + switchModel: vi.fn(), getLlmClient: () => ({ getChat: () => chat }), getHookSystem: () => undefined, }; @@ -181,6 +183,37 @@ async function reopen() { } describe('Hosted context compression', () => { + it('compresses with the Session connection without a host model registration', async () => { + const modelConfiguration = { + authType: AuthType.USE_OPENAI, + model: 'session-model', + apiKey: 'session-key', + baseUrl: 'https://session.example/v1', + }; + compression = new HostedContextCompression({ + session, + cwd: root, + instructions: async () => '', + busy: () => false, + hasPendingHooks: () => false, + modelConfig: async () => modelConfiguration, + }); + engine(); + const request = { + ...intent(), + modelSelection: { modelId: modelConfiguration.model, revision: 1 }, + }; + await compression.admit(request); + await compression.wait(); + expect(compression.status(request.operationId)).toMatchObject({ + status: 'completed', + }); + expect( + vi.mocked(loadCliConfig).mock.calls[0]?.[9]?.modelConfiguration, + ).toEqual(modelConfiguration); + expect(config.switchModel).not.toHaveBeenCalled(); + }); + it('durably compresses without a user Turn and replays the exact operation', async () => { const summarize = engine(); const request = intent(); diff --git a/packages/cli/src/serve/hosted-context-compression.ts b/packages/cli/src/serve/hosted-context-compression.ts index 4b6c9f9d431..a9738e03aa5 100644 --- a/packages/cli/src/serve/hosted-context-compression.ts +++ b/packages/cli/src/serve/hosted-context-compression.ts @@ -39,6 +39,7 @@ import { type HookInput, } from '@qwen-code/qwen-code-core/hooks/types.js'; import { loadCliConfig, type CliArgs } from '../config/config.js'; +import type { HostedModelConfigReader } from './hosted-model-config.js'; import { loadSettings } from '../config/settings.js'; import { applyHostedModelPin } from './hosted-harness-model.js'; import type { HostedAgentDefinition } from './hosted-agent-definition.js'; @@ -73,6 +74,7 @@ interface CompressionOptions { session: ManagedSession; cwd: string; agentDefinition?: HostedAgentDefinition; + modelConfig?: HostedModelConfigReader; instructions: () => Promise; busy: () => boolean; hasPendingHooks: () => boolean; @@ -349,6 +351,14 @@ export class HostedContextCompression { sessionId: this.options.session.authority.sessionHeader.sessionKey.sessionId, } as CliArgs; + const sessionModelConfig = await this.options.modelConfig?.(); + if ( + sessionModelConfig && + intent.modelSelection && + sessionModelConfig.model !== intent.modelSelection.modelId + ) { + throw new Error('Session model configuration conflicts.'); + } const config = await loadCliConfig( settings.merged, argv, @@ -359,15 +369,21 @@ export class HostedContextCompression { undefined, undefined, true, - { toolInvocationGuard: () => ({ allowed: false }) }, + { + toolInvocationGuard: () => ({ allowed: false }), + ...(sessionModelConfig + ? { modelConfiguration: sessionModelConfig } + : {}), + }, ); try { - await applyHostedModelPin( - config, - intent.modelSelection - ? { id: intent.modelSelection.modelId } - : this.options.agentDefinition?.model, - ); + if (!sessionModelConfig) + await applyHostedModelPin( + config, + intent.modelSelection + ? { id: intent.modelSelection.modelId } + : this.options.agentDefinition?.model, + ); config.setUserMemory(instructions); await config.initialize({ skipHooks: true, diff --git a/packages/cli/src/serve/hosted-harness-contract.test.ts b/packages/cli/src/serve/hosted-harness-contract.test.ts index 09efbbd1db6..7bf60e0625b 100644 --- a/packages/cli/src/serve/hosted-harness-contract.test.ts +++ b/packages/cli/src/serve/hosted-harness-contract.test.ts @@ -54,6 +54,7 @@ describe('Hosted Harness private contract', () => { 'hosted_approval_resume_v1', 'hosted_model_round_recovery_v1', 'model-selection/1', + 'session-model-config/1', 'context-compression/1', 'user-message-queue/1', 'public-child-agents/1', diff --git a/packages/cli/src/serve/hosted-harness-contract.ts b/packages/cli/src/serve/hosted-harness-contract.ts index 1753b6c6eab..30b86317a9d 100644 --- a/packages/cli/src/serve/hosted-harness-contract.ts +++ b/packages/cli/src/serve/hosted-harness-contract.ts @@ -83,6 +83,7 @@ export function createHostedHarnessContract( 'hosted_approval_resume_v1', 'hosted_model_round_recovery_v1', 'model-selection/1', + 'session-model-config/1', 'context-compression/1', 'user-message-queue/1', 'public-child-agents/1', diff --git a/packages/cli/src/serve/hosted-harness-model.test.ts b/packages/cli/src/serve/hosted-harness-model.test.ts index efca476e58c..fb71c77268a 100644 --- a/packages/cli/src/serve/hosted-harness-model.test.ts +++ b/packages/cli/src/serve/hosted-harness-model.test.ts @@ -44,6 +44,7 @@ import type { ChatRecord } from '@qwen-code/qwen-code-core/services/chatRecordin import type { HostedModelRequest } from './hosted-model-recovery.js'; import { HostedToolRecoveryRequiredError } from './hosted-workspace-tool-turn.js'; import { ManagedSessionRecordError } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js'; +import type { PublicChildModelRoute } from '@qwen-code/qwen-code-core/managed-runtime/managed-child-operations.js'; const state = vi.hoisted(() => ({ config: undefined as unknown as { @@ -502,6 +503,148 @@ describe('Hosted Harness model boundary', () => { expect(model.sendMessageStream).toHaveBeenCalledOnce(); }); + it.each([ + 'same', + 'explicit-inherit', + 'explicit-same', + 'legacy-explicit-same', + 'endpoint-drift', + 'provider-pin-drift', + 'model-id-drift', + ])( + 'inherits a configured child route with an empty host registry (%s)', + async (scenario) => { + const model = config([]); + const generation = { + model: 'tenant-model', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://tenant.test/v1', + apiKey: 'synthetic-tenant-key', + samplingParams: { temperature: 0.25 }, + }; + const modelConfig = vi.fn().mockResolvedValue(generation); + const getAllConfiguredModels = vi.fn().mockReturnValue([]); + const switchModel = vi.fn(); + state.config['getModel'] = () => generation.model; + state.config['getModelsConfig'] = () => ({ + getCurrentAuthType: () => generation.authType, + getGenerationConfig: () => generation, + }); + state.config['getContentGeneratorConfig'] = () => generation; + state.config['getAllConfiguredModels'] = getAllConfiguredModels; + state.config['switchModel'] = switchModel; + const resolveForModel = vi.fn().mockResolvedValue({ + model: generation.model, + contentGeneratorConfig: generation, + }); + state.config['getBaseLlmClient'] = () => ({ resolveForModel }); + let explicitRoute: PublicChildModelRoute | undefined; + model.sendMessageStream.mockImplementation( + async function* (_request, _signal, _promptId, options) { + await ( + options as { onPreparedRequest: LlmPreparedRequestCallback } + ).onPreparedRequest({ + request: { model: generation.model, contents: [], config: {} }, + history: [], + completedToolCallIds: [], + routeSelector: `openai:${generation.model}`, + providerPin: llmPreparedProviderPin(generation), + promptTokensForClamp: 0, + }); + if (scenario.includes('explicit-') && !explicitRoute) { + expect(model.shutdown).not.toHaveBeenCalled(); + const selector = + scenario === 'explicit-inherit' ? 'inherit' : generation.model; + const resolved = + await setChildModelResolver.mock.calls[0]![0](selector); + expect(resolveForModel).toHaveBeenCalledExactlyOnceWith(selector, { + failClosed: true, + }); + expect(resolved.modelId).toBe(generation.model); + explicitRoute = resolved.route; + } + yield { type: LlmEventType.Content, value: 'done' }; + yield { type: LlmEventType.Finished }; + }, + ); + const setChildModelRoute = + vi.fn<(modelId: string, route: PublicChildModelRoute) => void>(); + const setChildModelResolver = vi.fn< + ( + resolve: (selector: string) => Promise<{ + modelId: string; + route: PublicChildModelRoute; + }>, + ) => void + >(); + await expect( + runHostedHarnessTextTurn({ + ...input, + ...(scenario === 'legacy-explicit-same' ? {} : { modelConfig }), + toolTurn: { + declarations: async () => [{ name: 'agent' }], + consumeResults: async () => undefined, + execute: vi.fn(), + capturesChildModelRoute: () => true, + setChildModelRoute, + setChildModelResolver, + }, + }), + ).resolves.toMatchObject({ text: 'done' }); + expect(setChildModelRoute).toHaveBeenCalledExactlyOnceWith( + generation.model, + { + authType: generation.authType, + endpointDigest: createHash('sha256') + .update(generation.baseUrl) + .digest('hex'), + providerPin: llmPreparedProviderPin(generation), + }, + ); + let route = { ...setChildModelRoute.mock.calls[0]![1] }; + if (scenario === 'legacy-explicit-same') { + expect(explicitRoute).toEqual({ + ...route, + origin: { modelId: generation.model, ...route }, + }); + expect(modelConfig).not.toHaveBeenCalled(); + expect( + vi.mocked(loadCliConfig).mock.calls.at(-1)?.[9], + ).not.toHaveProperty('modelConfiguration'); + return; + } + if (scenario.startsWith('explicit-')) { + expect(explicitRoute).toBeDefined(); + route = explicitRoute!; + } + if (scenario === 'endpoint-drift') route.endpointDigest = 'f'.repeat(64); + if (scenario === 'provider-pin-drift') route.providerPin = 'f'.repeat(64); + const child = runHostedHarnessTextTurn({ + ...input, + modelConfig, + modelSelection: { + modelId: + scenario === 'model-id-drift' ? 'other-model' : generation.model, + revision: 1, + }, + publicChildModelRoute: route, + }); + if (scenario === 'same' || scenario.startsWith('explicit-')) { + await expect(child).resolves.toMatchObject({ text: 'done' }); + expect(model.sendMessageStream).toHaveBeenCalledTimes(2); + expect(getAllConfiguredModels).not.toHaveBeenCalled(); + } else { + await expect(child).rejects.toBeInstanceOf(HostedModelUnavailableError); + expect(model.sendMessageStream).toHaveBeenCalledOnce(); + } + expect(modelConfig).toHaveBeenCalledTimes(2); + expect(vi.mocked(loadCliConfig).mock.calls.at(-1)?.[9]).toMatchObject({ + modelConfiguration: generation, + }); + expect(switchModel).not.toHaveBeenCalled(); + }, + ); + it('restores a configured endpoint by exact route and uses its registry key', async () => { config([]); const endpoint = 'https://second.test/v1'; diff --git a/packages/cli/src/serve/hosted-harness-model.ts b/packages/cli/src/serve/hosted-harness-model.ts index d4c91ed3e37..53aa772c91d 100644 --- a/packages/cli/src/serve/hosted-harness-model.ts +++ b/packages/cli/src/serve/hosted-harness-model.ts @@ -36,6 +36,7 @@ import type { import { getApiHistoryPromptId } from '@qwen-code/qwen-code-core/utils/api-history-metadata.js'; import { buildSessionHistoryFromConversation } from '@qwen-code/qwen-code-core/services/session-api-history.js'; import { loadCliConfig, type CliArgs } from '../config/config.js'; +import type { HostedModelConfigReader } from './hosted-model-config.js'; import { loadSettings } from '../config/settings.js'; import { writeStderrLineSafe } from '../utils/stdioHelpers.js'; @@ -311,6 +312,7 @@ export async function runHostedHarnessTextTurn(input: { workspaceContext?: { read(): string | undefined }; agentDefinition?: HostedAgentDefinition; modelSelection?: HostedModelSelection; + modelConfig?: HostedModelConfigReader; publicChildModelRoute?: PublicChildModelRoute; agentInstructions?: string; extensionInstructions?: string; @@ -323,6 +325,14 @@ export async function runHostedHarnessTextTurn(input: { model: string, ) => Promise; }): Promise { + const sessionModelConfig = await input.modelConfig?.(); + if ( + sessionModelConfig && + input.modelSelection && + sessionModelConfig.model !== input.modelSelection.modelId + ) { + throw new HostedModelUnavailableError(); + } const settings = loadSettings(input.cwd, { skipLoadEnvironment: true, skipWorkspaceSettings: true, @@ -346,14 +356,19 @@ export async function runHostedHarnessTextTurn(input: { undefined, undefined, true, - { toolInvocationGuard: () => ({ allowed: false }) }, + { + toolInvocationGuard: () => ({ allowed: false }), + ...(sessionModelConfig ? { modelConfiguration: sessionModelConfig } : {}), + }, ); const outputBudget = config.getToolOutputBatchBudget?.(); if (outputBudget !== undefined) input.toolTurn?.setToolOutputBudget?.(outputBudget); - const model = input.modelSelection - ? { id: input.modelSelection.modelId } - : input.agentDefinition?.model; + const model = sessionModelConfig + ? { id: sessionModelConfig.model } + : input.modelSelection + ? { id: input.modelSelection.modelId } + : input.agentDefinition?.model; if (input.publicChildModelRoute) await applyHostedPublicChildModelPin( config, @@ -362,7 +377,7 @@ export async function runHostedHarnessTextTurn(input: { : model, input.publicChildModelRoute.origin ?? input.publicChildModelRoute, ); - else await applyHostedModelPin(config, model); + else if (!sessionModelConfig) await applyHostedModelPin(config, model); if (input.publicChildModelRoute?.origin) { // Config falls back to its own generator until authentication fills this frame. const view = {} as RuntimeContentGeneratorView; @@ -648,14 +663,19 @@ async function runPreparedHostedHarnessTextTurn( .update(selected.baseUrl ?? '') .digest('hex'), providerPin: llmPreparedProviderPin(selected), - origin: { - modelId: config.getModel(), - authType: active.authType!, - endpointDigest: createHash('sha256') - .update(active.baseUrl ?? '') - .digest('hex'), - providerPin: llmPreparedProviderPin(active), - }, + ...(!input.modelConfig || + llmPreparedProviderPin(selected) !== llmPreparedProviderPin(active) + ? { + origin: { + modelId: config.getModel(), + authType: active.authType!, + endpointDigest: createHash('sha256') + .update(active.baseUrl ?? '') + .digest('hex'), + providerPin: llmPreparedProviderPin(active), + }, + } + : {}), }, }; }); diff --git a/packages/cli/src/serve/hosted-harness-session.test.ts b/packages/cli/src/serve/hosted-harness-session.test.ts index 410edfabf77..2b33ef96a28 100644 --- a/packages/cli/src/serve/hosted-harness-session.test.ts +++ b/packages/cli/src/serve/hosted-harness-session.test.ts @@ -262,6 +262,7 @@ const state = vi.hoisted(() => ({ storeOptions: [] as Array>, ownershipReleases: [] as Array>, setLifecycleAuthority: vi.fn(), + modelConfig: vi.fn(), authorizeLifecycle: vi.fn(async () => undefined), authorizeOrdinary: vi.fn(async (_kind?: 'legacy-close') => undefined), approvalSelections: new Map< @@ -280,6 +281,7 @@ const state = vi.hoisted(() => ({ textDeltas?: import('./hosted-text-deltas.js').HostedTextDeltaStream; workspaceContext?: { read(): string | undefined }; modelSelection?: { modelId: string; revision: number }; + modelConfig?: import('./hosted-model-config.js').HostedModelConfigReader; }) => ({ text: 'hello back', model: 'test-model', @@ -404,6 +406,7 @@ vi.mock( assertWritable: state.assertWritable, claimUserMessages: async () => [], setLifecycleAuthority: state.setLifecycleAuthority, + modelConfig: state.modelConfig, authorizeOrdinary: state.authorizeOrdinary, authorizeLifecycle: state.authorizeLifecycle, approvalSelection: async () => { @@ -548,6 +551,7 @@ beforeEach(({ task }) => { ).mockResolvedValue(); state.approvalSelections.clear(); state.approvalQueries.length = 0; + state.modelConfig.mockReset(); vi.spyOn( HostedWorkspaceBroker.prototype, 'capabilityDigest', @@ -1618,6 +1622,94 @@ describe('Hosted Harness no-tool session', () => { } }); + it.each(['create', 'resident-load', 'cold-load'])( + 'refuses an omitted Session model configuration digest on %s', + async (operation) => { + let server = await app(); + const created = await headers(supertest(server).post('/session')).send({ + sessionId: SESSION_ID, + sessionScope: 'thread', + managedSessionStore: store(), + modelConfigDigest: `sha256:${'a'.repeat(64)}`, + }); + expect(created.status).toBe(200); + if (operation === 'cold-load') { + await headers(supertest(server).post(`/session/${SESSION_ID}/detach`)) + .set('X-Qwen-Client-Id', created.body.clientId as string) + .expect(204); + server = await app(); + } + const result = await headers( + supertest(server).post( + operation === 'create' ? '/session' : `/session/${SESSION_ID}/load`, + ), + ).send({ + sessionId: SESSION_ID, + sessionScope: 'thread', + managedSessionStore: store(), + }); + expect(result.status).toBe(409); + expect(result.body.code).toBe('hosted_agent_definition_conflict'); + expect(state.model).not.toHaveBeenCalled(); + }, + ); + + it.each([ + new ManagedSessionStoreHttpError( + 503, + 'store_unavailable', + 'store is unavailable', + ), + new ManagedSessionStoreTransportError('store connection failed'), + ])( + 'preserves a private model configuration Store outage as a Store failure: %s', + async (cause) => { + const server = await app(); + const created = await headers(supertest(server).post('/session')).send({ + sessionId: SESSION_ID, + sessionScope: 'thread', + managedSessionStore: store(), + modelConfigDigest: `sha256:${'a'.repeat(64)}`, + }); + expect(created.status).toBe(200); + state.modelConfig.mockRejectedValueOnce(cause); + state.model.mockImplementationOnce(async (input) => { + await input.modelConfig!(); + return { text: 'unreachable', model: 'test-model' }; + }); + const prompt = [{ type: 'text', text: 'read configuration' }]; + await headers(supertest(server).post(`/session/${SESSION_ID}/prompt`)) + .set('X-Qwen-Client-Id', created.body.clientId as string) + .send({ + prompt, + promptId: PROMPT_ID, + payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`, + }) + .expect(202); + await vi.waitFor( + async () => { + const journal = await LocalJsonlManagedSessionJournalStore.read({ + transcriptPath: path.join(state.root, `${SESSION_ID}.jsonl`), + runtimeBaseDir: state.root, + sessionKey: { + tenantId: 'tenant', + workspaceId: 'workspace', + sessionId: SESSION_ID, + }, + }); + const settled = journal.events.find( + (event) => event.kind === 'turn.settled', + ); + expect(settled?.payload['stopReason']).toBe( + 'managed_session_store_unavailable', + ); + }, + { timeout: settleTimeoutMs() }, + ); + expect(state.modelConfig).toHaveBeenCalledOnce(); + }, + ); + it('persists model selection and refuses changed resident and cold prompt replays', async () => { let server = await app(); const created = await headers(supertest(server).post('/session')).send({ diff --git a/packages/cli/src/serve/hosted-harness-session.ts b/packages/cli/src/serve/hosted-harness-session.ts index e7dfa405236..a8e3f252b2c 100644 --- a/packages/cli/src/serve/hosted-harness-session.ts +++ b/packages/cli/src/serve/hosted-harness-session.ts @@ -15,6 +15,11 @@ import { HostedCompressionRefusal, } from './hosted-context-compression.js'; import { extensionInstructionText } from './hosted-extension-tool-context.js'; +import { + parseHostedModelConfig, + parseHostedModelConfigDigest, + type HostedModelConfigReader, +} from './hosted-model-config.js'; import { parseHostedApprovalSelection, @@ -653,6 +658,8 @@ interface HostedSession { storeBaseUrl: string; definition: Record | null; agentDefinition?: HostedAgentDefinition; + modelConfigDigest?: string; + modelConfig?: HostedModelConfigReader; sessionSkillsRef?: ManagedSessionDurableRef; skills?: HostedSkillCatalog; baseSkills?: HostedSkillCatalog; @@ -886,6 +893,7 @@ function compressionFor(session: HostedSession): HostedContextCompression { session: session.managed, cwd: session.cwd, agentDefinition: session.agentDefinition, + modelConfig: session.modelConfig, instructions: async () => [ await readHostedAgentInstructions( @@ -944,6 +952,7 @@ async function runHostedLifecycleHook( event: HookEventName, operationId: string, fields: Record, + kind?: 'legacy-close', ): Promise { if (!session.hooks) return undefined; await session.hooks.ensureReady(); @@ -968,6 +977,9 @@ async function runHostedLifecycleHook( cwd: session.cwd, signal, scope, + modelConfig: session.modelConfig + ? () => session.modelConfig!(kind) + : undefined, }, run, ); @@ -2695,6 +2707,7 @@ async function verifyWorkspaceRestore( } if (ref.kind === 'managed-definition') { const definition = object(JSON.parse(bytes.toString('utf8'))); + parseHostedModelConfigDigest(definition?.['modelConfigDigest']); const sessionSkills = parseSessionSkillsRef( definition?.['sessionSkillsRef'], ); @@ -3909,6 +3922,7 @@ async function executeHostedTurn( launch: workflowLaunch, signal: abort.signal, agentDefinition: session.agentDefinition, + modelConfig: session.modelConfig, }) : await runHostedHarnessTextTurn({ sessionId, @@ -3967,6 +3981,7 @@ async function executeHostedTurn( modelScope, workspaceContext, agentDefinition: session.agentDefinition, + modelConfig: session.modelConfig, publicChildModelRoute: session.publicChildModelRoute, modelSelection: await readHostedTurnModelSelection( session.managed, @@ -4632,8 +4647,12 @@ export function registerHostedHarnessSessionRoutes( } const sessionId = create ? body?.['sessionId'] : req.params['id']; let agentDefinition: HostedAgentDefinition | undefined; + let modelConfigDigest: string | undefined; let sessionSkillsRef: ManagedSessionDurableRef | undefined; try { + modelConfigDigest = parseHostedModelConfigDigest( + body?.['modelConfigDigest'], + ); sessionSkillsRef = parseSessionSkillsRef(body?.['sessionSkillsRef']); agentDefinition = parseHostedAgentDefinition( body?.['agentDefinition'], @@ -4901,8 +4920,12 @@ export function registerHostedHarnessSessionRoutes( } if ( resident && - !create && - !sameHostedAgentDefinition(resident.agentDefinition, agentDefinition) + ((!create && + !sameHostedAgentDefinition( + resident.agentDefinition, + agentDefinition, + )) || + resident.modelConfigDigest !== modelConfigDigest) ) { error(res, 409, 'hosted_agent_definition_conflict'); return; @@ -5794,6 +5817,7 @@ export function registerHostedHarnessSessionRoutes( engine: 'managed', sessionId, ...(agentDefinition ? { agentDefinition } : {}), + ...(modelConfigDigest ? { modelConfigDigest } : {}), ...(publicChildAgentsVersion === 1 ? { publicChildAgentsVersion } : {}), @@ -5878,6 +5902,12 @@ export function registerHostedHarnessSessionRoutes( let savedSessionSkillsRef: ManagedSessionDurableRef | undefined; let skills: HostedSkillCatalog | undefined; try { + if ( + parseHostedModelConfigDigest(definition?.['modelConfigDigest']) !== + modelConfigDigest + ) { + throw new Error('Session model configuration binding changed.'); + } savedAgentDefinition = parseHostedAgentDefinition( definition?.['agentDefinition'], ); @@ -6020,6 +6050,22 @@ export function registerHostedHarnessSessionRoutes( storeBaseUrl: store.baseUrl, definition, agentDefinition: savedAgentDefinition, + ...(modelConfigDigest + ? { + modelConfigDigest, + modelConfig: async (kind) => { + try { + return parseHostedModelConfig( + await stores.modelConfig(modelConfigDigest, kind), + modelConfigDigest, + ); + } catch (cause) { + if (namedStoreFailure(cause)) throw cause; + throw new HostedModelUnavailableError(); + } + }, + } + : {}), ...(publicChildAgentsVersion === 1 ? { publicChildAgentsVersion: 1 as const } : {}), @@ -11214,6 +11260,7 @@ export function registerHostedHarnessSessionRoutes( signal: abort.signal, workspaceContext, agentDefinition: session.agentDefinition, + modelConfig: session.modelConfig, publicChildModelRoute: session.publicChildModelRoute, modelSelection: await readHostedTurnModelSelection( session.managed, @@ -12674,12 +12721,14 @@ export function registerHostedHarnessSessionRoutes( HookEventName.SessionEnd, `session-end:${req.params['id']}`, { reason: 'other' }, + 'legacy-close', ); await runHostedLifecycleHook( session, HookEventName.SessionDelete, `session-delete:${req.params['id']}`, { deleted_session_id: req.params['id'] }, + 'legacy-close', ); } finally { session.hooksBusy = false; diff --git a/packages/cli/src/serve/hosted-hook-model.test.ts b/packages/cli/src/serve/hosted-hook-model.test.ts index 3dd57da60ea..076bf984ba5 100644 --- a/packages/cli/src/serve/hosted-hook-model.test.ts +++ b/packages/cli/src/serve/hosted-hook-model.test.ts @@ -6,6 +6,7 @@ import type { GenerateContentResponse } from '@google/genai'; import type { Config } from '@qwen-code/qwen-code-core/config/config.js'; +import { AuthType } from '@qwen-code/qwen-code-core/core/contentGenerator.js'; import { TurnBudget } from '@qwen-code/qwen-code-core/core/turn-budget.js'; import { HookEventName, @@ -119,11 +120,26 @@ describe('Hosted Hook model boundary', () => { it('authorizes an isolated prompt evaluation and attributes provider usage to its original operation', async () => { const { config, generateContent, evaluate, usage, scope } = fixture(); const signal = new AbortController().signal; + const modelConfiguration = { + authType: AuthType.USE_OPENAI, + model: 'session-model', + apiKey: 'session-key', + baseUrl: 'https://session.example/v1', + }; const result = await runHostedHookOperation( - { sessionId: 'session-1', cwd: '/workspace', signal, scope }, + { + sessionId: 'session-1', + cwd: '/workspace', + signal, + scope, + modelConfig: async () => modelConfiguration, + }, (runner) => runner(hook, HookEventName.SessionEnd, input, signal), ); expect(result).toMatchObject({ success: true, outcome: 'success' }); + expect( + vi.mocked(loadCliConfig).mock.calls[0]?.[9]?.modelConfiguration, + ).toEqual(modelConfiguration); expect(vi.mocked(loadCliConfig).mock.calls[0]?.[1]).toMatchObject({ sessionId: 'session-1', sessionIdGenerated: true, diff --git a/packages/cli/src/serve/hosted-hook-model.ts b/packages/cli/src/serve/hosted-hook-model.ts index bd82b8bebc7..b326dc8b4f6 100644 --- a/packages/cli/src/serve/hosted-hook-model.ts +++ b/packages/cli/src/serve/hosted-hook-model.ts @@ -10,6 +10,7 @@ import type { ContentGenerator } from '@qwen-code/qwen-code-core/core/contentGen import { PromptHookRunner } from '@qwen-code/qwen-code-core/hooks/promptHookRunner.js'; import type { ManagedHookModelScope } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-activation.js'; import { loadCliConfig, type CliArgs } from '../config/config.js'; +import type { HostedModelConfigReader } from './hosted-model-config.js'; import { loadSettings } from '../config/settings.js'; import { writeStderrLineSafe } from '../utils/stdioHelpers.js'; import type { HostedPromptHookRunner } from './hosted-hook-session.js'; @@ -135,6 +136,7 @@ export async function runHostedHookOperation( cwd: string; signal: AbortSignal; scope: ManagedHookModelScope; + modelConfig?: HostedModelConfigReader; }, run: (runner: HostedPromptHookRunner) => Promise, ): Promise { @@ -143,6 +145,7 @@ export async function runHostedHookOperation( skipWorkspaceSettings: true, workspaceTrusted: false, }); + const sessionModelConfig = await input.modelConfig?.(); const config = await loadCliConfig( settings.merged, { @@ -159,7 +162,10 @@ export async function runHostedHookOperation( undefined, undefined, true, - { toolInvocationGuard: () => ({ allowed: false }) }, + { + toolInvocationGuard: () => ({ allowed: false }), + ...(sessionModelConfig ? { modelConfiguration: sessionModelConfig } : {}), + }, ); try { await config.initialize({ diff --git a/packages/cli/src/serve/hosted-model-config.test.ts b/packages/cli/src/serve/hosted-model-config.test.ts new file mode 100644 index 00000000000..f6438e57aa3 --- /dev/null +++ b/packages/cli/src/serve/hosted-model-config.test.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + parseHostedModelConfig, + parseHostedModelConfigDigest, +} from './hosted-model-config.js'; +import { HostedModelUnavailableError } from './hosted-agent-definition.js'; + +const digest = `sha256:${'a'.repeat(64)}`; +const config = { + protocol: 'openai', + model: 'session-model', + base_url: 'https://model.example/v1', + api_key: 'synthetic-session-key', + generation_config: { temperature: 0.4, max_tokens: 128, seed: -1 }, +}; + +describe('Hosted Session model configuration', () => { + it.each(['openai', 'openai-responses', 'anthropic', 'gemini'])( + 'maps %s without interpreting credentials as environment references', + (protocol) => { + expect( + parseHostedModelConfig( + { digest, config: { ...config, protocol } }, + digest, + ), + ).toEqual({ + authType: protocol, + ...(protocol === 'gemini' ? { vertexai: false } : {}), + model: config.model, + baseUrl: config.base_url, + apiKey: config.api_key, + samplingParams: config.generation_config, + }); + }, + ); + + it.each([undefined, {}])( + 'preserves omitted versus explicit generation parameters: %j', + (generation) => { + const parsed = parseHostedModelConfig( + { + digest, + config: { + protocol: config.protocol, + model: config.model, + base_url: config.base_url, + api_key: config.api_key, + ...(generation === undefined + ? {} + : { generation_config: generation }), + }, + }, + digest, + ); + if (generation === undefined) + expect(parsed).not.toHaveProperty('samplingParams'); + else expect(parsed.samplingParams).toEqual({}); + }, + ); + + it.each([ + { protocol: 'qwen-oauth' }, + { api_key: '' }, + { api_key: 'unsafe\nkey' }, + { base_url: 'https://user:password@model.example/v1' }, + { base_url: 'https://model.example/v1?key=secret' }, + { base_url: 'file:///tmp/model' }, + { model: 'x'.repeat(513) }, + { headers: { authorization: 'secret' } }, + { generation_config: { model: 'override' } }, + { generation_config: { temperature: 3 } }, + { generation_config: { top_p: -1 } }, + { generation_config: { max_tokens: 0.5 } }, + { generation_config: { seed: 2147483648 } }, + { generation_config: null }, + ])('rejects invalid configuration: %j', (overrides) => { + expect(() => + parseHostedModelConfig( + { digest, config: { ...config, ...overrides } }, + digest, + ), + ).toThrow(HostedModelUnavailableError); + }); + + it('fails closed on a different digest or malformed envelope', () => { + expect(parseHostedModelConfigDigest(undefined)).toBeUndefined(); + expect(parseHostedModelConfigDigest(digest)).toBe(digest); + for (const value of [null, '', 'a'.repeat(64)]) { + expect(() => parseHostedModelConfigDigest(value)).toThrow( + HostedModelUnavailableError, + ); + } + expect(() => + parseHostedModelConfig({ digest, config }, `sha256:${'b'.repeat(64)}`), + ).toThrow(HostedModelUnavailableError); + expect(() => + parseHostedModelConfig({ digest, config, extra: true }, digest), + ).toThrow(HostedModelUnavailableError); + }); +}); diff --git a/packages/cli/src/serve/hosted-model-config.ts b/packages/cli/src/serve/hosted-model-config.ts new file mode 100644 index 00000000000..ae8357f9e92 --- /dev/null +++ b/packages/cli/src/serve/hosted-model-config.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + type ContentGeneratorConfig, +} from '@qwen-code/qwen-code-core/core/contentGenerator.js'; +import { HostedModelUnavailableError } from './hosted-agent-definition.js'; + +export type HostedModelConfig = ContentGeneratorConfig & { + authType: AuthType; + apiKey: string; + baseUrl: string; +}; +export type HostedModelConfigReader = ( + kind?: 'legacy-close', +) => Promise; + +export function parseHostedModelConfigDigest( + value: unknown, +): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(value)) { + throw new HostedModelUnavailableError(); + } + return value; +} + +export function parseHostedModelConfig( + value: unknown, + expectedDigest: string, +): HostedModelConfig { + const object = (value: unknown): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new HostedModelUnavailableError(); + } + return value as Record; + }; + const envelope = object(value); + if ( + Object.keys(envelope).length !== 2 || + envelope['digest'] !== expectedDigest + ) { + throw new HostedModelUnavailableError(); + } + const config = object(envelope['config']); + const fields = [ + 'protocol', + 'model', + 'base_url', + 'api_key', + 'generation_config', + ]; + if ( + Object.keys(config).some((key) => !fields.includes(key)) || + Buffer.byteLength(JSON.stringify(config)) > 8192 || + ![ + AuthType.USE_OPENAI, + AuthType.USE_OPENAI_RESPONSES, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + ].includes(config['protocol'] as AuthType) + ) { + throw new HostedModelUnavailableError(); + } + const text = (key: string, bytes: number): string => { + const value = config[key]; + if ( + typeof value !== 'string' || + !value.trim() || + Buffer.byteLength(value) > bytes || + /[\p{Cc}]/u.test(value) + ) { + throw new HostedModelUnavailableError(); + } + return value; + }; + const baseUrl = text('base_url', 2048); + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new HostedModelUnavailableError(); + } + if ( + !['https:', 'http:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new HostedModelUnavailableError(); + } + const samplingParams: Record = {}; + if (config['generation_config'] !== undefined) { + const generation = object(config['generation_config']); + for (const [key, value] of Object.entries(generation)) { + const integer = ['top_k', 'max_tokens', 'seed'].includes(key); + if ( + ![ + 'temperature', + 'top_p', + 'top_k', + 'max_tokens', + 'presence_penalty', + 'frequency_penalty', + 'seed', + ].includes(key) || + typeof value !== 'number' || + !Number.isFinite(value) || + (integer && + (!Number.isInteger(value) || + value < (key === 'seed' ? -2147483648 : 1) || + value > 2147483647)) || + (key === 'temperature' && (value < 0 || value > 2)) || + (key === 'top_p' && (value < 0 || value > 1)) || + (key.endsWith('_penalty') && (value < -2 || value > 2)) + ) { + throw new HostedModelUnavailableError(); + } + samplingParams[key] = value; + } + } + return { + authType: config['protocol'] as AuthType, + // The Gemini SDK otherwise inherits the host's Vertex/Enterprise mode. + ...(config['protocol'] === AuthType.USE_GEMINI ? { vertexai: false } : {}), + model: text('model', 512), + apiKey: text('api_key', 4096), + baseUrl, + ...(config['generation_config'] !== undefined ? { samplingParams } : {}), + }; +} diff --git a/packages/cli/src/serve/hosted-workflow-turn.test.ts b/packages/cli/src/serve/hosted-workflow-turn.test.ts index c002b9ec73e..e14f590b83e 100644 --- a/packages/cli/src/serve/hosted-workflow-turn.test.ts +++ b/packages/cli/src/serve/hosted-workflow-turn.test.ts @@ -5,6 +5,7 @@ */ import { createHash } from 'node:crypto'; +import { AuthType } from '@qwen-code/qwen-code-core/core/contentGenerator.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { DefinitionPin } from '@qwen-code/qwen-code-core/managed-runtime/managed-extension-record.js'; import { loadCliConfig } from '../config/config.js'; @@ -240,6 +241,37 @@ describe('runHostedWorkflowTurn', () => { expect(vi.mocked(loadSettings)).not.toHaveBeenCalled(); }); + it('passes the Session connection to workflow agents without resolving a host model pin', async () => { + start.mockResolvedValue( + handleWith({ + ok: true, + outcome: { + runId: 'wf_0123456789abcdef', + result: 'ok', + phases: [], + logs: [], + meta: {}, + }, + }), + ); + const modelConfiguration = { + authType: AuthType.USE_OPENAI, + model: 'session-model', + apiKey: 'session-key', + baseUrl: 'https://session.example/v1', + }; + await runHostedWorkflowTurn({ + ...base, + launch: { definition: pin(SCRIPT), script: SCRIPT, args: null }, + signal: new AbortController().signal, + modelConfig: async () => modelConfiguration, + }); + expect( + vi.mocked(loadCliConfig).mock.calls[0]?.[9]?.modelConfiguration, + ).toEqual(modelConfiguration); + expect(configDouble.current?.switchModel).not.toHaveBeenCalled(); + }); + it('renders a completed run as the turn content, bounded and readable', async () => { start.mockResolvedValue( handleWith({ @@ -290,7 +322,7 @@ describe('runHostedWorkflowTurn', () => { expect(initOrder).toBeLessThan(authOrder); expect(authOrder).toBeLessThan(runOrder); // R1-14: the per-turn config posture itself: safe-mode argv, a - // throw-on-conflict ninth argument, never a host policy refit; and the + // throw-on-conflict ninth argument, no ambient host policy; and the // settings load is pinned untrusted. const loadCalls = vi.mocked(loadCliConfig).mock.calls; expect(loadCalls).toHaveLength(1); @@ -299,7 +331,7 @@ describe('runHostedWorkflowTurn', () => { sessionIdGenerated: true, chatRecording: false, }); - expect(loadCalls[0]).toHaveLength(9); + expect(loadCalls[0]).toHaveLength(10); expect((loadCalls[0]![1] as { safeMode?: boolean }).safeMode).toBe(true); // #13858: the agents' chats open without this Harness's cwd listing. expect( diff --git a/packages/cli/src/serve/hosted-workflow-turn.ts b/packages/cli/src/serve/hosted-workflow-turn.ts index 980b977b4e5..5c7c38ba484 100644 --- a/packages/cli/src/serve/hosted-workflow-turn.ts +++ b/packages/cli/src/serve/hosted-workflow-turn.ts @@ -11,6 +11,7 @@ import type { DefinitionPin } from '@qwen-code/qwen-code-core/managed-runtime/ma import { WorkflowRunner } from '@qwen-code/qwen-code-core/agents/runtime/workflow-runner.js'; import type { WorkflowRunOutcome } from '@qwen-code/qwen-code-core/agents/runtime/workflow-orchestrator.js'; import { loadCliConfig, type CliArgs } from '../config/config.js'; +import type { HostedModelConfigReader } from './hosted-model-config.js'; import { loadSettings } from '../config/settings.js'; import { writeStderrLineSafe } from '../utils/stdioHelpers.js'; import { @@ -184,6 +185,7 @@ export async function runHostedWorkflowTurn(input: { /** The Session's pinned definition: its model governs every agent() * dispatch of the run, exactly as it governs the sibling text Turn. */ readonly agentDefinition?: HostedAgentDefinition; + readonly modelConfig?: HostedModelConfigReader; }): Promise { // The pin binds the bytes that run: the launch's digest must equal the // script's own, or this Turn never starts (decision 2). @@ -224,6 +226,7 @@ export async function runHostedWorkflowTurn(input: { // filesystem metadata and never the Session's Workspace. The sibling text // turn replaces that prelude with the Session's own history; these agents // have none to replace it with, so they start without it. + const sessionModelConfig = await input.modelConfig?.(); const config = await loadCliConfig( { ...settings.merged, @@ -237,9 +240,12 @@ export async function runHostedWorkflowTurn(input: { undefined, undefined, true, + sessionModelConfig ? { modelConfiguration: sessionModelConfig } : undefined, ); - const model = input.agentDefinition?.model; - await applyHostedModelPin(config, model); + const model = sessionModelConfig + ? { id: sessionModelConfig.model } + : input.agentDefinition?.model; + if (!sessionModelConfig) await applyHostedModelPin(config, model); try { await config.initialize({ signal: input.signal, diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 405a2be5509..140f2c77c23 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -420,10 +420,13 @@ describe('LlmContentGenerator', () => { it('should prioritize contentGeneratorConfig samplingParams over request config', async () => { await withCg({ model: 'gemini-1.5-flash', - samplingParams: { temperature: 0.1, top_p: 0.2 }, - }).generateContent(flashReq({ temperature: 0.9, topP: 0.9 }), 'prompt-id'); + samplingParams: { temperature: 0.1, top_p: 0.2, seed: 42 }, + }).generateContent( + flashReq({ temperature: 0.9, topP: 0.9, seed: 1 }), + 'prompt-id', + ); - expectSentConfig({ temperature: 0.1, topP: 0.2 }); + expectSentConfig({ temperature: 0.1, topP: 0.2, seed: 42 }); }); it.each([ diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index bec405d89e2..d62809b3fc5 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -206,6 +206,12 @@ export class LlmContentGenerator implements ContentGenerator { configSamplingParams?.frequency_penalty, 'frequencyPenalty', ), + seed: getParameterValue( + typeof configSamplingParams?.['seed'] === 'number' + ? configSamplingParams['seed'] + : undefined, + 'seed', + ), thinkingConfig: getParameterValue( this.buildThinkingConfig(request.model, requestConfig.thinkingConfig), 'thinkingConfig', diff --git a/packages/core/src/managed-runtime/http-managed-session-store.test.ts b/packages/core/src/managed-runtime/http-managed-session-store.test.ts index b0c2d57b025..ae523ff6b25 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.test.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.test.ts @@ -91,6 +91,124 @@ const TOKEN_B = 'b'.repeat(32); describe('HTTP Managed Session store', () => { const temporaryDirectories: string[] = []; + it('reads a model configuration only with an acquired writer and a bound digest', async () => { + const server = new FakeManagedSessionStore(); + const digest = `sha256:${'a'.repeat(64)}`; + const response = { digest, config: { api_key: 'synthetic-key' } }; + const fetchFn = vi.fn(async (input, init) => + requestUrl(input).endsWith('/model-config:query') + ? jsonResponse(response) + : server.fetch(input, init), + ); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://127.0.0.1:8080', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn, + }); + try { + await expect(stores.modelConfig(digest)).rejects.toThrow(); + await stores.journalStore.open({ sessionKey: SESSION_KEY }); + await expect(stores.modelConfig('invalid')).rejects.toThrow(); + await expect(stores.modelConfig(digest)).resolves.toEqual(response); + const requests = fetchFn.mock.calls.filter(([input]) => + requestUrl(input).endsWith('/model-config:query'), + ); + expect(requests).toHaveLength(1); + const init = requests[0]![1]; + expect(JSON.parse(String(init?.body))).toEqual({ + workspaceId: SESSION_KEY.workspaceId, + writerId: 'harness-a', + writerGeneration: 1, + expectedDigest: digest, + }); + expect( + new Headers(init?.headers).get('X-Qwen-Managed-Writer-Token'), + ).toBe(TOKEN_A); + await stores.modelConfig(digest, 'legacy-close'); + expect( + JSON.parse(String(fetchFn.mock.calls.at(-1)?.[1]?.body)), + ).toMatchObject({ kind: 'legacy-close', expectedDigest: digest }); + } finally { + await stores.stopLocal(); + } + }); + + it.each([ + [ + 'synthetic-secret-invalid-json', + 'Model configuration response is invalid JSON.', + ], + ['x'.repeat(17000), 'Model configuration response exceeds its byte limit.'], + ])( + 'bounds and redacts an invalid private model response', + async (body, message) => { + const server = new FakeManagedSessionStore(); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://127.0.0.1:8080', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: async (input, init) => + requestUrl(input).endsWith('/model-config:query') + ? new Response(body, { headers: { 'Cache-Control': 'no-store' } }) + : server.fetch(input, init), + }); + try { + await stores.journalStore.open({ sessionKey: SESSION_KEY }); + const failure = await stores + .modelConfig(`sha256:${'a'.repeat(64)}`) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(message); + expect(String(failure)).not.toContain(body); + } finally { + await stores.stopLocal(); + } + }, + ); + + it('retries and classifies private model response stream failures without exposing transport details', async () => { + const server = new FakeManagedSessionStore(); + const fetchFn = vi.fn(async (input, init) => + requestUrl(input).endsWith('/model-config:query') + ? new Response( + new ReadableStream({ + pull() { + throw new TypeError('synthetic-private-transport-detail'); + }, + }), + { headers: { 'Cache-Control': 'no-store' } }, + ) + : server.fetch(input, init), + ); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://127.0.0.1:8080', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn, + }); + try { + await stores.journalStore.open({ sessionKey: SESSION_KEY }); + const failure = await stores + .modelConfig(`sha256:${'a'.repeat(64)}`) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ManagedSessionStoreTransportError); + expect(String(failure)).not.toContain( + 'synthetic-private-transport-detail', + ); + expect( + fetchFn.mock.calls.filter(([input]) => + requestUrl(input).endsWith('/model-config:query'), + ), + ).toHaveLength(3); + } finally { + await stores.stopLocal(); + } + }); + it.each([false, true])( 'claims user messages with the current writer and initialOnly=%s', async (initialOnly) => { diff --git a/packages/core/src/managed-runtime/http-managed-session-store.ts b/packages/core/src/managed-runtime/http-managed-session-store.ts index 68a28f2dfa4..ef8217d8d84 100644 --- a/packages/core/src/managed-runtime/http-managed-session-store.ts +++ b/packages/core/src/managed-runtime/http-managed-session-store.ts @@ -183,6 +183,7 @@ export interface HttpManagedSessionStores { kind?: 'legacy-close' | `configuration:${string}`, ): Promise; approvalSelection(): Promise; + modelConfig(expectedDigest: string, kind?: 'legacy-close'): Promise; claimUserMessages( promptId: string, initialOnly?: boolean, @@ -285,6 +286,8 @@ export function createHttpManagedSessionStores( authorizeLifecycle: (kind) => client.authorizeLifecycle(kind), authorizeOrdinary: (kind) => client.authorizeOrdinary(kind), approvalSelection: () => client.approvalSelection(), + modelConfig: (expectedDigest, kind) => + client.modelConfig(expectedDigest, kind), claimUserMessages: (promptId, initialOnly) => client.claimUserMessages(promptId, initialOnly), }; @@ -894,6 +897,72 @@ class ManagedSessionStoreHttpClient { }); } + async modelConfig( + expectedDigest: string, + kind?: 'legacy-close', + ): Promise { + if (!/^sha256:[0-9a-f]{64}$/u.test(expectedDigest)) { + throw corrupt('model configuration digest is invalid.'); + } + await this.assertWritable(); + return this.withRetry( + true, + 'POST', + '/model-config:query', + async (timeoutMs) => { + const response = await this.request( + '/model-config:query', + 'POST', + { + workspaceId: this.sessionKey.workspaceId, + writerId: this.writerId, + writerGeneration: this.grant!.writerGeneration, + expectedDigest, + ...(kind ? { kind } : {}), + }, + timeoutMs, + ); + const reader = response.body?.getReader(); + if (!reader) throw corrupt('Model configuration response has no body.'); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + let item: ReadableStreamReadResult; + try { + item = await reader.read(); + } catch (error) { + if (error instanceof TypeError || error instanceof DOMException) { + throw new ManagedSessionStoreTransportError( + 'Managed Session Store model configuration response failed.', + ); + } + throw error; + } + if (item.done) break; + length += item.value.byteLength; + if (length > 16 * 1024) { + await reader.cancel(); + throw corrupt( + 'Model configuration response exceeds its byte limit.', + ); + } + chunks.push(item.value); + } + try { + return JSON.parse( + Buffer.concat(chunks).toString('utf8'), + ) as unknown; + } catch { + throw corrupt('Model configuration response is invalid JSON.'); + } + } finally { + reader.releaseLock(); + } + }, + ); + } + async approvalSelection(): Promise< ManagedSessionApprovalSelection | undefined > { diff --git a/packages/managed-agent-server/README.md b/packages/managed-agent-server/README.md index 4c99a4db401..94a1367e8f3 100644 --- a/packages/managed-agent-server/README.md +++ b/packages/managed-agent-server/README.md @@ -948,7 +948,52 @@ Schema V6 preserves the original policy and adds a current override plus per-Tur Workspace-bound WebShell Session summaries can include `modelContext` with the actual observed `modelId`, optional latest-request `inputTokens` and resolved `contextWindowSize`, and `observedAt`. The Hosted CLI records capacity beside successful provider usage; the Java Session Store projects only committed main-model attempts into one private latest-resource pointer. Lists read these observations in one batch without scanning journal history. Older or invalid usage advances the pointer but leaves missing values unknown; cumulative billing totals and inferred capacities never substitute for them. The shared usage popover does not offer manual compression. New tasks continue to select published AgentDefinition presets. This observation projection uses existing SQL tables and resource-reference cleanup without changing the recovery format. -To allow an existing Workspace Session to change its next Turn's model, publish an explicit `qwen.managed-agent.model-catalog` list of `{modelId, label}` entries in the server configuration. The default is empty. IDs must identify configured, cached-credential models in the Hosted CLI; the browser supplies neither provider settings nor credentials. The Harness must advertise `model-selection/1`. `POST /api/agent/web-shell/v1/session-models/query` returns the catalog and current selection; `POST /api/agent/web-shell/v1/session-models/change` takes `sessionId`, `modelId`, `expectedRevision` and `idempotencyKey` and synchronously returns the saved selection. The caller needs Workspace OPERATOR or OWNER and the Session must be idle, without pending approval, context/lifecycle work or recovery. Same-key retries return their original result before mutable catalog and role gates, provided the caller still has read access. They never reapply a change. +To allow an existing Workspace Session to change its next Turn's model, publish an explicit `qwen.managed-agent.model-catalog` list of `{modelId, label}` entries in the server configuration. The default is empty. IDs must identify configured, cached-credential models in the Hosted CLI; this selection path supplies only an ID. The Harness must advertise `model-selection/1`. `POST /api/agent/web-shell/v1/session-models/query` returns the catalog and current selection; `POST /api/agent/web-shell/v1/session-models/change` takes `sessionId`, `modelId`, `expectedRevision` and `idempotencyKey` and synchronously returns the saved selection. The caller needs Workspace OPERATOR or OWNER and the Session must be idle, without pending approval, context/lifecycle work or recovery. Same-key retries return their original result before mutable catalog and role gates, provided the caller still has read access. They never reapply a change. + +Creation can instead supply a complete connection in public `model_config` or +WebShell `modelConfig`: + +```json +{ + "protocol": "openai", + "model": "qwen3-coder-plus", + "base_url": "https://models.example/v1", + "api_key": "application-supplied-key", + "generation_config": { "temperature": 0.7, "max_tokens": 4096 } +} +``` + +Configure `qwen.managed-agent.model-config-base-urls` as a YAML list containing +the exact allowed base URLs; its empty default disables this path. The Session +Store and `session-model-config/1` Harness capability are required. The standard +server also needs the Runtime Broker's `SecretProtector` and credential key ring. +Protocols are `openai`, `openai-responses`, `anthropic` and `gemini`; accepted +generation fields are `temperature`, `top_p`, `top_k`, `max_tokens`, +`presence_penalty`, `frequency_penalty` and `seed`. The Anthropic adapter forwards +only `temperature`, `top_p`, `top_k` and `max_tokens`; it ignores `seed` and both +penalty fields. Other provider support varies, and providers may reject parameters +they do not support. URL userinfo, query strings, fragments, OAuth and arbitrary +headers are not supported. Credentials are literal values, without environment +substitution. + +The complete configuration is encrypted with tenant/Session binding and included +in creation idempotency through its digest. It is immutable, inherited by child +Sessions and restored after Harness reattachment. It overrides host model +settings, including auxiliary and fallback choices, for this Session's execution. +Do not combine it with `modelId`; host-catalog switching is disabled for these +Sessions. Public responses and journal resources never include the credentials. +Retain encryption keys while `ma_session.model_config_key_id` references them, +including for closed Sessions. The unreleased V1 schema gains these columns; +recreate development catalogs under the existing schema policy. No browser +credential editor is included. + +For multi-tenant deployments, a trusted application service or gateway must +authenticate tenant/actor identity and retain the platform signing key. The +endpoint allowlist is deployment-wide; tenant-specific model permissions, +parameter limits, quotas and outbound network restrictions must be enforced by +the caller or deployment. Each Session retains an immutable credential snapshot, +so replacing a provider key requires a new Session. See the +[deployment boundary](../../docs/design/managed-session-model-config.md#multi-tenant-deployment-boundary). Idle Workspace Sessions support manual context compression when the Harness advertises `context-compression/1`. Send `POST /v1/agents/sessions/{sessionId}/compress` with `Idempotency-Key` and `{ "expected_compaction_revision": 0 }`, using the Session's current `compaction_revision`; WebShell uses `/api/agent/web-shell/v1/sessions/compress` and camelCase fields. The public `context_compression` capability advertises deployment support; the WebShell `contextCompression` capability also reflects the current caller grant and execution facts. A Workspace OPERATOR or OWNER can admit the durable `context_compression` operation; the existing operation query returns `pending`, `running`, `recovery_blocked`, `completed` or `failed`, and a completed outcome is `compressed` or `noop`. Same-key retries resolve the original operation before mutable capability, revision and busy checks. Compression refuses active or queued Turns, approvals, unsettled Hooks and conflicting context work. It does not create a user Turn or change approval, Plan or Workspace context revisions. After compression, context usage stays unknown until the next main-model observation. Deploy matching Server/Broker, Harness and WebShell builds using the current V1 baseline; stop new compression admission and settle accepted operations before rollback. Existing retained journals establish their compaction revision on first read; if that evidence is unavailable, the capability is false and the revision is omitted. @@ -1137,8 +1182,12 @@ every tenant and workspace on the host. This does not isolate files: a worker runs as the service account and can read whatever that account can. After a credential-key rotation, keep each prior key in -`previous-credential-keys` until no persisted Runtime binding references its -key ID. The brackets are not decoration: Spring's relaxed binder strips `/`, +`previous-credential-keys` while any persisted row references it: Runtime +bindings, complete Session model configurations and extension settings all +retain key IDs. Use the retirement query in the runbook linked below to check +all three scopes. + +The brackets are not decoration: Spring's relaxed binder strips `/`, `:` and spaces from plain map keys, so an unbracketed ID binds as a key that matches no persisted row. diff --git a/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnector.java b/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnector.java index 21290db8d50..02432a5c7ac 100644 --- a/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnector.java +++ b/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessPoolConnector.java @@ -362,6 +362,11 @@ public class HarnessPoolConnector implements HarnessConnector { return feature("model-selection/1"); } + @Override + public boolean supportsModelConfiguration() { + return feature("session-model-config/1"); + } + @Override public boolean supportsUserMessageQueue() { return feature("user-message-queue/1"); } diff --git a/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java index 487488f3c0e..00e31ab9e7a 100644 --- a/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java +++ b/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -364,6 +364,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector { WorkspaceToolProfiles.isPublicShell(toolProfile(session)) && !publicChildLaunchAvailable(session)) .withHarnessPlacement(placementDescriptor(session)) .withAgentDefinition(agentDefinition(session, false)) + .withModelConfigDigest(modelConfigDigest(session)) .withSessionSkillsRef(sessionSkillsRef(session, false)) .withInitialSelection(initialSelection(session)) .withMcpServers(mcpServers(session)) @@ -862,6 +863,22 @@ public class QwenHostedHarnessConnector implements HarnessConnector { } } + @Override + public boolean supportsModelConfiguration() { + try { + return sessionStore.isEnabled() && client().capabilities().getFeatures().contains("session-model-config/1"); + } catch (RuntimeException unavailable) { + return false; + } + } + + private String modelConfigDigest(SessionRecord session) { + if (session.modelConfigDigest() != null && !supportsModelConfiguration()) { + throw new IllegalStateException("Hosted Session model configuration is unavailable"); + } + return session.modelConfigDigest(); + } + private Admission doSubmit(String tenantId, String sessionId, String promptId, List> input, String payloadDigest) { @@ -1261,6 +1278,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector { WorkspaceToolProfiles.isPublicShell(toolProfile(session)) && !publicChildLaunchAvailable(session)) .withHarnessPlacement(placementDescriptor(session)) .withAgentDefinition(agentDefinition(session, false)) + .withModelConfigDigest(modelConfigDigest(session)) .withSessionSkillsRef(sessionSkillsRef(session, false)) .withInitialSelection(initialSelection(session)) .withMcpServers(mcpServers(session)) @@ -1527,6 +1545,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector { CreateHarnessSession.Builder builder = CreateHarnessSession.builder() .harnessSessionId(session.sessionId()) + .modelConfigDigest(modelConfigDigest(session)) .harnessPlacement(placementDescriptor(session)) .approvalMode( session.workspace() == null @@ -1621,6 +1640,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector { WorkspaceToolProfiles.isPublicShell(profile) && !publicChildLaunchAvailable(session)) .withHarnessPlacement(placementDescriptor(session)) .withAgentDefinition(agentDefinition(session, false)) + .withModelConfigDigest(modelConfigDigest(session)) .withSessionSkillsRef(sessionSkillsRef(session, false)) .withInitialSelection(initialSelection(session)) .withMcpServers(mcpServers(session)) @@ -1783,6 +1803,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector { toolProfile(session), false, false, WorkspaceToolProfiles.isPublicShell(session.toolProfile()) && !publicChildLaunchAvailable(session)) .withHarnessPlacement(placementDescriptor(session)) .withAgentDefinition(agentDefinition(session, false)) + .withModelConfigDigest(modelConfigDigest(session)) .withSessionSkillsRef(sessionSkillsRef(session, false)) .withInitialSelection(initialSelection(session)) .withPublicChildAgentsVersion(session.publicChildAgentsVersion()) diff --git a/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/SdkHarnessBoundary.java b/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/SdkHarnessBoundary.java index 8d08c46f808..bdb13aaeace 100644 --- a/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/SdkHarnessBoundary.java +++ b/packages/managed-agent-server/adapters/src/main/java/com/alibaba/qwen/code/managedagent/harness/SdkHarnessBoundary.java @@ -82,6 +82,11 @@ public final class SdkHarnessBoundary implements HarnessConnector { return call(delegate::supportsModelSelection); } + @Override + public boolean supportsModelConfiguration() { + return call(delegate::supportsModelConfiguration); + } + @Override public boolean supportsUserMessageQueue() { return call(delegate::supportsUserMessageQueue); diff --git a/packages/managed-agent-server/configuration-descriptions.json b/packages/managed-agent-server/configuration-descriptions.json index c7a1a50f12e..462f3599a16 100644 --- a/packages/managed-agent-server/configuration-descriptions.json +++ b/packages/managed-agent-server/configuration-descriptions.json @@ -460,6 +460,12 @@ "zh": "为内置 `qwen-code` agent 的 Session 提供的可选模型,可在创建 Session 时选择,之后也可由 Workspace operator 更改。元素为对象列表,包含 `model-id`(Harness 中配置的模型 ID;必填、唯一、最多 512 字节)和 `label`(显示名称,默认等于 `model-id`,最多 512 个字符);条目无效或重复时启动时拒绝。需在 YAML 配置文件中设置,没有对应的环境变量。模型选择还要求开启 `qwen.managed-agent.harness.workspace-files-enabled`,且 Harness 支持模型选择。", "default": "" }, + "qwen.managed-agent.model-config-base-urls": { + "en": "Exact HTTP(S) base URLs permitted for complete Session model configurations, set as a YAML list. Empty disables admission of new configurations. Public creation accepts `model_config`; WebShell creation accepts `modelConfig`. Requires the Session Store, Harness capability `session-model-config/1` and a `SecretProtector` (provided by the enabled Runtime Broker and its credential key ring in the standard server). Model IDs and API keys need no Harness registration. See the [Session model configuration design](../design/managed-session-model-config.md).", + "zh": "允许完整 Session 模型配置使用的 HTTP(S) API 地址,按完整字符串精确匹配,以 YAML 列表设置。为空时禁止准入新配置。公共创建接口接受 `model_config`,WebShell 创建接口接受 `modelConfig`。要求启用 Session Store、Harness 能力 `session-model-config/1` 和 `SecretProtector`(标准服务由已启用的 Runtime Broker 及其凭据密钥环提供)。模型 ID 和 API key 无需在 Harness 注册。参见 [Session 模型配置设计](../design/managed-session-model-config.zh-CN.md)。", + "type": "yaml-list", + "default": "" + }, "qwen.managed-agent.runtime-broker.allow-non-loopback": { "en": "Whether the embedded Runtime Broker may bind a non-loopback address in `qwen.managed-agent.runtime-broker.host`, such as `0.0.0.0`; without it, startup refuses such an address. The listener serves one global bearer token over plaintext HTTP, with no per-tenant authorization, so enable it only behind a layer that terminates TLS and authorizes callers: restricting the network alone still sends the token in clear text, and whoever reads it can drive every execution the Broker admits. The `managed-local` profile refuses a non-loopback host regardless.", "zh": "是否允许内嵌 Runtime Broker 绑定 `qwen.managed-agent.runtime-broker.host` 中的非回环地址(例如 `0.0.0.0`);未开启时,此类地址启动时拒绝。该监听器通过明文 HTTP 使用同一个全局 bearer token,且没有按租户的鉴权,因此只应在终止 TLS 并对调用方鉴权的前置层之后开启:仅限制网络仍会以明文传输该 token,读到它的人可以操控 Broker 接纳的所有执行。`managed-local` profile 无论此项如何都拒绝非回环 host。" diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/harness/HarnessConnector.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/harness/HarnessConnector.java index 0e60dae5b9f..c6790827b3f 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/harness/HarnessConnector.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/harness/HarnessConnector.java @@ -46,6 +46,8 @@ public interface HarnessConnector extends AutoCloseable { public default boolean supportsModelSelection() { return false; } + public default boolean supportsModelConfiguration() { return false; } + public default boolean supportsUserMessageQueue() { return false; } public default boolean supportsContextCompression() { return false; } diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/session/SessionAdmissionStore.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/session/SessionAdmissionStore.java index 75ad49358a3..7b19d3e51d6 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/session/SessionAdmissionStore.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/session/SessionAdmissionStore.java @@ -32,6 +32,18 @@ public interface SessionAdmissionStore { String agentId, String requestedRevision, String title, List> input, String payloadDigest); + public default Admission insertSessionCommand(String tenantId, String actorId, + String operation, String idempotencyKey, String requestDigest, + String agentId, String requestedRevision, String title, + List> input, String payloadDigest, + com.alibaba.qwen.code.managedagent.model.SessionModelConfig modelConfig) { + if (modelConfig != null) { + throw new UnsupportedOperationException("Session model configuration is unavailable"); + } + return insertSessionCommand(tenantId, actorId, operation, idempotencyKey, + requestDigest, agentId, requestedRevision, title, input, payloadDigest); + } + /** * H4b: creates a child Session under its parent's exact binding, * stamping the lineage in the same transaction. The idempotency key diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/session/SessionCreationCommand.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/session/SessionCreationCommand.java index ba762bb5ca9..43acf60c8a4 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/session/SessionCreationCommand.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/contracts/session/SessionCreationCommand.java @@ -2,6 +2,7 @@ package com.alibaba.qwen.code.managedagent.contracts.session; import com.alibaba.qwen.code.managedagent.contracts.extensions.ManagedExtensionPackages; import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceSelection; +import com.alibaba.qwen.code.managedagent.model.SessionModelConfig; import com.alibaba.qwen.code.managedagent.repository.SessionWorktreeStore; import java.util.List; @@ -31,7 +32,7 @@ public record SessionCreationCommand( boolean publicChildrenSupported, String modelId, List extensionPins, Set extensionLifecycleProfiles, - SessionWorktreeStore.Plan worktree) { + SessionWorktreeStore.Plan worktree, SessionModelConfig modelConfig) { public static Builder builder() { return new Builder(); } @@ -58,6 +59,7 @@ public record SessionCreationCommand( private List extensionPins; private Set extensionLifecycleProfiles = Set.of(); private SessionWorktreeStore.Plan worktree; + private SessionModelConfig modelConfig; public Builder tenantId(String value) { this.tenantId = value; @@ -159,13 +161,18 @@ public record SessionCreationCommand( return this; } + public Builder modelConfig(SessionModelConfig value) { + this.modelConfig = value; + return this; + } + public SessionCreationCommand build() { return new SessionCreationCommand(tenantId, actorId, idempotencyKey, requestDigest, agentId, requestedRevision, title, input, payloadDigest, selection, requestedApprovalMode, approvalSelectionSupported, requestedPlanMode, planModeSupported, skills, publicChildrenSupported, modelId, extensionPins, - extensionLifecycleProfiles, worktree); + extensionLifecycleProfiles, worktree, modelConfig); } } } diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java index f1fadb4ed75..be6fced5dda 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java @@ -73,7 +73,13 @@ public final class ApiModels { Boolean stream, JsonNode workspace, @Size(max = 256) List<@NotNull Map> skills, - @Size(max = 32) List<@NotNull @Valid Pin> extensionPins, JsonNode worktree) { + @Size(max = 32) List<@NotNull @Valid Pin> extensionPins, JsonNode worktree, + @JsonProperty("model_config") JsonNode modelConfig) { + public CreateSessionRequest(String agentId, String agentRevision, List input, + Map metadata, Boolean stream, JsonNode workspace, List> skills, + List extensionPins, JsonNode worktree) { + this(agentId, agentRevision, input, metadata, stream, workspace, skills, extensionPins, worktree, null); + } public CreateSessionRequest(String agentId, String agentRevision, List input, Map metadata, Boolean stream, JsonNode workspace, List> skills, List extensionPins) { @@ -454,7 +460,13 @@ public final class ApiModels { Map metadata, JsonNode workspace, String approvalMode, Boolean planMode, String modelId, @Size(max = 256) List<@NotNull Map> skills, - @Size(max = 32) List<@NotNull @Valid Pin> extensionPins, JsonNode worktree) { + @Size(max = 32) List<@NotNull @Valid Pin> extensionPins, JsonNode worktree, JsonNode modelConfig) { + public WebShellCreateRequest(String requestId, String idempotencyKey, String agentId, String title, + List input, Map metadata, JsonNode workspace, String approvalMode, + Boolean planMode, String modelId, List> skills, List extensionPins, JsonNode worktree) { + this(requestId, idempotencyKey, agentId, title, input, metadata, workspace, approvalMode, + planMode, modelId, skills, extensionPins, worktree, null); + } public WebShellCreateRequest(String requestId, String idempotencyKey, String agentId, String title, List input, Map metadata, JsonNode workspace, String approvalMode, Boolean planMode, String modelId, List> skills, List extensionPins) { diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/SessionModelConfig.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/SessionModelConfig.java new file mode 100644 index 00000000000..911918ca328 --- /dev/null +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/SessionModelConfig.java @@ -0,0 +1,123 @@ +package com.alibaba.qwen.code.managedagent.model; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.fasterxml.jackson.databind.JsonNode; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +public final class SessionModelConfig { + private static final Set FIELDS = Set.of("protocol", "model", "base_url", "api_key", "generation_config"); + private static final Set PROTOCOLS = Set.of("openai", "openai-responses", "anthropic", "gemini"); + private static final Set SAMPLING = Set.of("temperature", "top_p", "top_k", "max_tokens", + "presence_penalty", "frequency_penalty", "seed"); + private static final RequestDigests DIGESTS = new RequestDigests(); + private final Map value; + + private SessionModelConfig(Map value) { + this.value = Collections.unmodifiableMap(value); + if (DIGESTS.byteLength(value) > 8192) { + throw invalid(); + } + } + + public static SessionModelConfig parse(JsonNode node) { + if (node == null) { + return null; + } + if (!node.isObject()) { + throw invalid(); + } + node.fieldNames().forEachRemaining(key -> { + if (!FIELDS.contains(key)) { + throw invalid(); + } + }); + String protocol = text(node, "protocol", 32); + if (!PROTOCOLS.contains(protocol)) { + throw invalid(); + } + var result = new LinkedHashMap(); + result.put("protocol", protocol); + result.put("model", text(node, "model", 512)); + String baseUrl = text(node, "base_url", 2048); + try { + URI uri = URI.create(baseUrl); + if (!("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())) + || uri.getHost() == null || uri.getUserInfo() != null + || uri.getRawQuery() != null || uri.getRawFragment() != null) { + throw invalid(); + } + } catch (IllegalArgumentException invalid) { + throw invalid(); + } + result.put("base_url", baseUrl); + result.put("api_key", text(node, "api_key", 4096)); + JsonNode generation = node.get("generation_config"); + if (generation != null) { + if (!generation.isObject()) { + throw invalid(); + } + var sampling = new TreeMap(); + generation.fields().forEachRemaining(entry -> { + String key = entry.getKey(); + JsonNode number = entry.getValue(); + if (!SAMPLING.contains(key) || !number.isNumber() || !Double.isFinite(number.doubleValue())) { + throw invalid(); + } + double n = number.doubleValue(); + boolean integer = Set.of("top_k", "max_tokens", "seed").contains(key); + if (integer && (n != Math.rint(n) || n < ("seed".equals(key) ? Integer.MIN_VALUE : 1) + || n > Integer.MAX_VALUE) + || "temperature".equals(key) && (n < 0 || n > 2) + || "top_p".equals(key) && (n < 0 || n > 1) + || key.endsWith("_penalty") && (n < -2 || n > 2)) { + throw invalid(); + } + if (integer) { + sampling.put(key, (int) n); + } else { + sampling.put(key, n); + } + }); + result.put("generation_config", Collections.unmodifiableMap(sampling)); + } + return new SessionModelConfig(result); + } + + private static String text(JsonNode node, String name, int maximumBytes) { + JsonNode field = node.get(name); + if (field == null || !field.isTextual() || field.textValue().isBlank() + || field.textValue().getBytes(StandardCharsets.UTF_8).length > maximumBytes + || field.textValue().codePoints().anyMatch(Character::isISOControl)) { + throw invalid(); + } + return field.textValue(); + } + + public String baseUrl() { + return (String) value.get("base_url"); + } + + public String digest() { + return DIGESTS.digest(value); + } + + public Map wireValue() { + return value; + } + + @Override + public String toString() { + return "SessionModelConfig[redacted]"; + } + + private static ManagedAgentException invalid() { + return new ManagedAgentException(400, "invalid_model_config", "Invalid Session model configuration."); + } +} diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/StoreModels.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/StoreModels.java index fffae7a963a..184c9d6a5a1 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/StoreModels.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/model/StoreModels.java @@ -23,7 +23,17 @@ public final class StoreModels { long replayFloorSequence, long createdAt, long updatedAt, Long deletedAt, long version, ContextBinding workspace, String approvalMode, String toolProfile, - String agentDefinitionDigest, int publicChildAgentsVersion) { + String agentDefinitionDigest, int publicChildAgentsVersion, String modelConfigDigest) { + public SessionRecord(String tenantId, String sessionId, String agentId, String agentRevision, + String title, String status, String harnessBootId, String harnessEventEpoch, + long harnessLastEventId, long lastSequence, long replayFloorSequence, + long createdAt, long updatedAt, Long deletedAt, long version, ContextBinding workspace, + String approvalMode, String toolProfile, String agentDefinitionDigest, int publicChildAgentsVersion) { + this(tenantId, sessionId, agentId, agentRevision, title, status, harnessBootId, + harnessEventEpoch, harnessLastEventId, lastSequence, replayFloorSequence, + createdAt, updatedAt, deletedAt, version, workspace, approvalMode, toolProfile, + agentDefinitionDigest, publicChildAgentsVersion, null); + } public SessionRecord(String tenantId, String sessionId, String agentId, String agentRevision, String title, String status, String harnessBootId, String harnessEventEpoch, long harnessLastEventId, long lastSequence, long replayFloorSequence, @@ -64,7 +74,7 @@ public final class StoreModels { harnessEventEpoch, harnessLastEventId, lastSequence, replayFloorSequence, createdAt, updatedAt, deletedAt, version, workspace, approvalMode, toolProfile, - agentDefinitionDigest, publicChildAgentsVersion); + agentDefinitionDigest, publicChildAgentsVersion, modelConfigDigest); } } diff --git a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/settings/ManagedAgentSettings.java b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/settings/ManagedAgentSettings.java index 72962a34b48..700aee7ead8 100644 --- a/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/settings/ManagedAgentSettings.java +++ b/packages/managed-agent-server/contracts/src/main/java/com/alibaba/qwen/code/managedagent/settings/ManagedAgentSettings.java @@ -22,6 +22,7 @@ public interface ManagedAgentSettings { public List getDefinitionToolProfiles(); public List getDefinitionModels(); public List getModelCatalog(); + public default List getModelConfigBaseUrls() { return List.of(); } public interface ModelOption { public String getModelId(); public String getLabel(); diff --git a/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json index ab50b414d7b..15f89f3509f 100644 --- a/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/managed-agent-server/contracts/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Qwen Managed Agent Public and WebShell API", - "version": "1.98.0", + "version": "1.99.0", "description": "Canonical first-release contract for the public Managed Agent resources and the WebShell adapter. Implemented routes and fields describe the current matching Server, Broker, Harness and WebShell build. Partial entries have remaining implementation or contract-test work; planned entries are not current server capabilities and must be excluded from production SDKs. Turn submission and cancellation always return the durable operation identifier and its persisted admission and delivery state. Session-list cursors are bound to the normalized archive state and title query. Earlier development API shapes, checkpoints and database catalogs are unsupported; initialize fresh state for this build. Runtime feature capabilities and deployment policy still govern optional execution modes. Public conversation history reads directly from mutable message projections; persisted conversation snapshots and fixed-content paging are no longer provided." }, "servers": [ @@ -7990,6 +7990,64 @@ } ] }, + "SessionModelConfig": { + "type": "object", + "additionalProperties": false, + "required": ["protocol", "model", "base_url", "api_key"], + "description": "Immutable Session connection. Requires session-model-config/1, encrypted storage and an endpoint in the control-plane allowlist. UTF-8 limits: model 512 bytes, base_url 2048 bytes, api_key 4096 bytes, complete canonical JSON 8192 bytes. Secrets are never returned in public Session responses. Child Sessions inherit the configuration. Cannot be combined with modelId.", + "properties": { + "protocol": { + "type": "string", + "enum": ["openai", "openai-responses", "anthropic", "gemini"] + }, + "model": { "type": "string", "minLength": 1, "maxLength": 512 }, + "base_url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Exact allowlisted HTTP(S) base URL without userinfo, query or fragment." + }, + "api_key": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "writeOnly": true + }, + "generation_config": { + "type": "object", + "additionalProperties": false, + "properties": { + "temperature": { "type": "number", "minimum": 0, "maximum": 2 }, + "top_p": { "type": "number", "minimum": 0, "maximum": 1 }, + "top_k": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "max_tokens": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "presence_penalty": { + "type": "number", + "minimum": -2, + "maximum": 2 + }, + "frequency_penalty": { + "type": "number", + "minimum": -2, + "maximum": 2 + }, + "seed": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + } + } + } + } + }, "CreateSessionRequest": { "type": "object", "additionalProperties": false, @@ -8000,6 +8058,9 @@ "minLength": 1, "maxLength": 128 }, + "model_config": { + "$ref": "#/components/schemas/SessionModelConfig" + }, "agent_revision": { "type": ["string", "null"], "maxLength": 128, @@ -9035,6 +9096,9 @@ "minLength": 1, "description": "Optional host-catalog model for a bound built-in qwen-code Session. Requires modelSelectionSupported. Persisted atomically before the first Turn and included in creation idempotency; omission preserves the default model and old digest." }, + "modelConfig": { + "$ref": "#/components/schemas/SessionModelConfig" + }, "title": { "type": ["string", "null"], "maxLength": 256, diff --git a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java index a74d92832a0..a6a51334a55 100644 --- a/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java +++ b/packages/managed-agent-server/control-plane/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java @@ -200,6 +200,13 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c String idempotencyKey, String agentId, String agentRevision, String title, Map metadata, List blocks) { + return createSession(tenantId, actorId, idempotencyKey, agentId, agentRevision, title, metadata, blocks, null); + } + + public CommandAdmission createSession(String tenantId, String actorId, + String idempotencyKey, String agentId, String agentRevision, + String title, Map metadata, List blocks, + com.alibaba.qwen.code.managedagent.model.SessionModelConfig modelConfig) { validateIdempotencyKey(idempotencyKey); List> input = inputPolicy.input(blocks, false); String effectiveTitle = metadataTitle(title, metadata); @@ -210,6 +217,9 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c } semantic.put("title", effectiveTitle); semantic.put("input", input); + if (modelConfig != null) { + semantic.put("modelConfigDigest", modelConfig.digest()); + } String requestDigest = digests.digest(semantic); // Replay before the harness gate: re-serving an already-recorded // command admits nothing new and must not 503 during a harness @@ -220,6 +230,7 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c dispatch(tenantId, replay); return response(replay); } + requireModelConfiguration(modelConfig); if (!input.isEmpty()) { requireHarness(); } @@ -227,9 +238,11 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c : harnessRules.payloadDigest(input); Admission admission; try { - admission = store.insertSessionCommand(tenantId, actorId, CREATE, + admission = modelConfig == null ? store.insertSessionCommand(tenantId, actorId, CREATE, + idempotencyKey, requestDigest, agentId, agentRevision, effectiveTitle, input, payloadDigest) + : store.insertSessionCommand(tenantId, actorId, CREATE, idempotencyKey, requestDigest, agentId, agentRevision, - effectiveTitle, input, payloadDigest); + effectiveTitle, input, payloadDigest, modelConfig); } catch (DuplicateKeyException error) { admission = store.replayCommand(tenantId, CREATE, idempotencyKey, requestDigest); @@ -266,7 +279,7 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c String sessionId) { requireModelActor(actorId); SessionRecord session = requireReadableSession(tenantId, actorId, sessionId); - boolean supported = session.workspace() != null && store.workspaceFilesEnabled() + boolean supported = session.modelConfigDigest() == null && session.workspace() != null && store.workspaceFilesEnabled() && !store.modelOptions().isEmpty() && harness.supportsModelSelection(); return new WebShellSessionModels(store.modelOptions(), store.findModelSelections(tenantId, List.of(sessionId)).getOrDefault(sessionId, @@ -342,6 +355,12 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c } } + private void requireModelConfiguration(com.alibaba.qwen.code.managedagent.model.SessionModelConfig configuration) { + if (configuration != null && !harness.supportsModelConfiguration()) { + throw new ManagedAgentException(409, "unsupported_feature", "Session model configuration is unavailable."); + } + } + public CommandAdmission createWorkspaceSession(String tenantId, String actorId, String idempotencyKey, String agentId, String agentRevision, String title, Map metadata, @@ -393,7 +412,20 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c List> skills, String modelId, List extensionPins, SessionWorktreeService.Request worktree) { + return createWorkspaceSession(tenantId, actorId, idempotencyKey, agentId, agentRevision, title, + metadata, blocks, selection, approvalMode, planMode, skills, modelId, extensionPins, worktree, null); + } + + public CommandAdmission createWorkspaceSession(String tenantId, String actorId, String idempotencyKey, + String agentId, String agentRevision, String title, Map metadata, + List blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode, + List> skills, String modelId, + List extensionPins, + SessionWorktreeService.Request worktree, com.alibaba.qwen.code.managedagent.model.SessionModelConfig modelConfig) { validateIdempotencyKey(idempotencyKey); + if (modelId != null && modelConfig != null) { + throw new ManagedAgentException(400, "invalid_model_config", "Choose either a model ID or a complete configuration."); + } var additions = AgentDefinitionExtensions.sessionSkills(skills); if (actorId == null || actorId.isEmpty()) { throw new ManagedAgentException(401, @@ -424,6 +456,9 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c if (modelId != null) { semantic.put("modelId", modelId); } + if (modelConfig != null) { + semantic.put("modelConfigDigest", modelConfig.digest()); + } if (extensionPins != null) { semantic.put("extensionPins", extensionPins); } @@ -441,6 +476,7 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c dispatch(tenantId, recorded.get(), true); return response(recorded.get()); } + requireModelConfiguration(modelConfig); if (modelId != null && (!"qwen-code".equals(agentId) || !harness.supportsModelSelection())) { throw new ManagedAgentException(409, "unsupported_feature", "Initial model selection is unavailable for this Agent."); @@ -490,6 +526,7 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c .extensionPins(extensionPins) .extensionLifecycleProfiles(extensionLifecycleProfiles) .worktree(worktreePlan) + .modelConfig(modelConfig) .build()); } catch (DuplicateKeyException error) { admission = store.replayWorkspaceSessionCommand(tenantId, @@ -1468,7 +1505,7 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c hasActions(session), maySubmit && approvalExecutable, close, keep, keep, visible && lifecycleOwner && supportsDelete(session, retention), false, foregroundShell(session, worktree), - visible && modelSelectionSupported && mayChangeContext, + visible && session.modelConfigDigest() == null && modelSelectionSupported && mayChangeContext, visible && approvalSupported && mayChangeContext && !Boolean.FALSE.equals(foregroundShell(session, worktree)), visible && maySubmit && harness.supportsInputAttachments(), visible && "ACTIVE".equals(session.status()) && renameSupported diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionAdmissions.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionAdmissions.java index edb6f20920b..646aa6eaa45 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionAdmissions.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionAdmissions.java @@ -9,6 +9,7 @@ import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceSelection import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelOption; +import com.alibaba.qwen.code.managedagent.model.SessionModelConfig; import com.alibaba.qwen.code.managedagent.model.StoreModels; import com.alibaba.qwen.code.managedagent.model.StoreModels.Admission; import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; @@ -36,6 +37,7 @@ final class AgentSessionAdmissions { private final AgentSessionPolicies sessionPolicies; private final AgentStoreRows storeRows; private final ManagedExtensionRecordStore configRecords; + private final ManagedSessionModelConfigs modelConfigs; private final JdbcTemplate jdbc; private final Clock clock; private final String approvalMode; @@ -79,12 +81,13 @@ final class AgentSessionAdmissions { ResidentWorkspaces residentWorkspaces, WorkspaceCreationRoots createdRoots, JournalEventTransport journalEventTransport, - boolean journalEventTransportEnabled) { + boolean journalEventTransportEnabled, ManagedSessionModelConfigs modelConfigs) { this.eventJournal = eventJournal; this.sessionCommands = sessionCommands; this.sessionPolicies = sessionPolicies; this.storeRows = storeRows; this.configRecords = configRecords; + this.modelConfigs = modelConfigs; this.jdbc = jdbc; this.clock = clock; this.approvalMode = approvalMode; @@ -110,12 +113,25 @@ final class AgentSessionAdmissions { String operation, String idempotencyKey, String requestDigest, String agentId, String requestedRevision, String title, List> input, String payloadDigest) { + return insertSessionCommand(tenantId, actorId, operation, idempotencyKey, + requestDigest, agentId, requestedRevision, title, input, payloadDigest, null); + } + + Admission insertSessionCommand(String tenantId, String actorId, + String operation, String idempotencyKey, String requestDigest, + String agentId, String requestedRevision, String title, + List> input, String payloadDigest, SessionModelConfig modelConfig) { + requireModelConfiguration(modelConfig); AgentSessionPolicies.AgentPin pin = sessionPolicies.resolveAgentPin(tenantId, agentId, requestedRevision, false); requireCreationScope(tenantId, idempotencyKey, false); - return insertSession(tenantId, operation, idempotencyKey, + Admission admission = insertSession(tenantId, operation, idempotencyKey, requestDigest, agentId, title, input, payloadDigest, null, actorId, pin); + if (modelConfig != null) { + modelConfigs.save(tenantId, admission.sessionId(), modelConfig); + } + return admission; } Admission insertWorkspaceSessionCommand(SessionCreationCommand command) { @@ -140,6 +156,7 @@ final class AgentSessionAdmissions { command.extensionPins(); Set extensionLifecycleProfiles = command.extensionLifecycleProfiles(); SessionWorktreeStore.Plan worktree = command.worktree(); + SessionModelConfig modelConfig = command.modelConfig(); if (!input.isEmpty() && !workspaceFilesEnabled) { throw AgentSessionPolicies.workspaceExecutionUnavailable(); } @@ -151,6 +168,9 @@ final class AgentSessionAdmissions { return replayWorkspaceCommand(tenantId, actorId, requestDigest, existing.getFirst(), false); } + if (modelConfig != null && modelId != null) { + throw new ManagedAgentException(400, "invalid_model_config", "Choose either a model ID or a complete configuration."); + } if (modelId != null && (!"qwen-code".equals(agentId) || !workspaceFilesEnabled || modelCatalog.stream().noneMatch(option -> option.modelId().equals(modelId)))) { throw new ManagedAgentException(400, "unsupported_model", @@ -171,6 +191,7 @@ final class AgentSessionAdmissions { requireCreationScope(tenantId, idempotencyKey, true); ResolvedBinding workspace = workspaces.resolveForCreation( tenantId, actorId, selection); + requireModelConfiguration(modelConfig); if (extensionPins != null) { if (extensionPackages == null) { throw AgentSessionPolicies.workspaceExecutionUnavailable(); @@ -219,6 +240,9 @@ final class AgentSessionAdmissions { Admission admission = insertSession(tenantId, "CREATE_SESSION", idempotencyKey, requestDigest, agentId, title, input, payloadDigest, target, actorId, pin, null, planMode, modelId); + if (modelConfig != null) { + modelConfigs.save(tenantId, admission.sessionId(), modelConfig); + } if (worktree != null) { sessionWorktrees.admit(tenantId, actorId, admission.sessionId(), worktree, workspace); } @@ -237,6 +261,15 @@ final class AgentSessionAdmissions { return admission; } + private void requireModelConfiguration(SessionModelConfig config) { + if (config != null) { + if (modelConfigs == null) { + throw new ManagedAgentException(409, "model_config_unavailable", "The Session model configuration is unavailable."); + } + modelConfigs.requireAdmission(config); + } + } + boolean configuredStorage(String tenant, String storage) { return workspaceMounts.stream().anyMatch(mount -> tenant.equals(mount.tenantId()) && storage.equals(mount.storageId())) || createdRoots.resolve(tenant, storage, false) != null; @@ -554,6 +587,12 @@ final class AgentSessionAdmissions { + " covered_sequence, gmt_modified) VALUES" + " (?, ?, 'SESSION', '', 0, ?)", tenantId, sessionId, now); + if (parent.modelConfigDigest() != null) { + if (modelConfigs == null) { + throw new ManagedAgentException(409, "model_config_unavailable", "The Session model configuration is unavailable."); + } + modelConfigs.inherit(tenantId, parentSessionId, sessionId); + } if (snapshot != null) { byte[] owner = jdbc.queryForObject("SELECT COALESCE(owner_actor_key, creator_actor_key) FROM ma_session WHERE tenant_id=? AND session_id=?", byte[].class, tenantId, parentSessionId); jdbc.update("UPDATE ma_session SET public_child_agents_version=1, owner_actor_key=?, approval_mode=?, selected_approval_mode=NULL, approval_revision=0, model_id=?, model_revision=1 WHERE tenant_id=? AND session_id=?", diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionPolicies.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionPolicies.java index 48c10271b39..893e9d0ae82 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionPolicies.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionPolicies.java @@ -549,6 +549,9 @@ final class AgentSessionPolicies { throw new ManagedAgentException(403, "session_operation_forbidden", "Only a Workspace operator may change the model"); } + if (session.modelConfigDigest() != null) { + throw new ManagedAgentException(409, "model_config_immutable", "This Session has an immutable model configuration."); + } if (!supported || modelCatalog.isEmpty() || !workspaceFilesEnabled) { throw new ManagedAgentException(409, "unsupported_feature", "Model selection is unavailable"); } diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentStoreRows.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentStoreRows.java index 5d20bba2ab1..ed102bac9dd 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentStoreRows.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentStoreRows.java @@ -121,7 +121,8 @@ final class AgentStoreRows { readBinding(result), result.getString("approval_mode"), result.getString("tool_profile"), - result.getString("agent_definition_digest"), result.getInt("public_child_agents_version")); + result.getString("agent_definition_digest"), result.getInt("public_child_agents_version"), + result.getString("model_config_digest")); } final RowMapper turnSummaryMapper; diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java index 64329cae1f5..e435ed5a490 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java @@ -19,6 +19,7 @@ import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelOption; import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelSelection; import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellSessionOptions; import com.alibaba.qwen.code.managedagent.model.RequestDigests; +import com.alibaba.qwen.code.managedagent.model.SessionModelConfig; import com.alibaba.qwen.code.managedagent.model.StoreModels; import com.alibaba.qwen.code.managedagent.model.StoreModels.AcceptanceVerdict; import com.alibaba.qwen.code.managedagent.model.StoreModels.Admission; @@ -90,6 +91,20 @@ public class ManagedAgentStore implements AgentStateStore { null, null, null, null, null); } + public ManagedAgentStore(JdbcTemplate jdbc, ObjectMapper objectMapper, + Clock clock, CommittedEventPublisher eventPublisher, + ManagedWorkspaceRegistry workspaces, ManagedAgentSettings properties, HarnessRules harnessRules, + com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles, + ManagedSessionSkills creationSkills, + com.alibaba.qwen.code.managedagent.contracts.extensions.ExtensionPackages extensionPackages, + WorkspaceLifecycleStore lifecycle, ManagedAgentDefinitionStore definitions, + SessionWorktreeStore sessionWorktrees, JournalEventTransport journalEventTransport, + ManagedExtensionRecordStore configRecords) { + this(jdbc, objectMapper, clock, eventPublisher, workspaces, properties, harnessRules, + worktreeFiles, creationSkills, extensionPackages, lifecycle, definitions, + sessionWorktrees, journalEventTransport, configRecords, null); + } + @Autowired public ManagedAgentStore(@Qualifier("managedJdbcTemplate") JdbcTemplate jdbc, @Qualifier("managedAgentObjectMapper") ObjectMapper objectMapper, @Qualifier("managedAgentClock") Clock clock, CommittedEventPublisher eventPublisher, @@ -99,7 +114,7 @@ public class ManagedAgentStore implements AgentStateStore { com.alibaba.qwen.code.managedagent.contracts.extensions.ExtensionPackages extensionPackages, @Nullable WorkspaceLifecycleStore lifecycle, @Nullable ManagedAgentDefinitionStore definitions, @Nullable SessionWorktreeStore sessionWorktrees, @Nullable JournalEventTransport journalEventTransport, - @Lazy @Nullable ManagedExtensionRecordStore configRecords) { + @Lazy @Nullable ManagedExtensionRecordStore configRecords, ManagedSessionModelConfigs modelConfigs) { String approvalMode = properties.getHarness().getApprovalMode().toLowerCase(Locale.ROOT); String agentRevision = properties.getAgentRevision(); @@ -140,7 +155,7 @@ public class ManagedAgentStore implements AgentStateStore { this.sessionPolicies = new AgentSessionPolicies(eventJournal, sessionQueries, storeRows, worktreeFiles, harnessRules, configRecords, jdbc, objectMapper, clock, approvalMode, workspaces, agentRevision, definitionToolProfiles, definitionModels, modelCatalog, definitions, creationSkills, workspaceFilesEnabled, workspaceShellEnabled, residentWorkspaces); this.sessionCommands = new AgentSessionCommands(eventJournal, sessionPolicies, storeRows, worktreeFiles, jdbc, clock, workspaces, workspaceFilesEnabled, workspaceShellEnabled); this.lifecycleOperations = new AgentLifecycleOperations(eventJournal, sessionCommands, sessionPolicies, sessionQueries, storeRows, jdbc, lifecycle, objectMapper, clock, workspaces, workspaceFilesEnabled, lifecycleHandoffMillis); - this.sessionAdmissions = new AgentSessionAdmissions(eventJournal, sessionCommands, sessionPolicies, storeRows, configRecords, jdbc, clock, approvalMode, workspaces, agentRevision, modelCatalog, definitions, creationSkills, sessionWorktrees, extensionPackages, workspaceFilesEnabled, workspaceShellEnabled, workspaceChildAgentsEnabled, managedExtensionExecutionEnabled, workspaceMounts, residentWorkspaces, createdRoots, journalEventTransport, journalEventTransportEnabled); + this.sessionAdmissions = new AgentSessionAdmissions(eventJournal, sessionCommands, sessionPolicies, storeRows, configRecords, jdbc, clock, approvalMode, workspaces, agentRevision, modelCatalog, definitions, creationSkills, sessionWorktrees, extensionPackages, workspaceFilesEnabled, workspaceShellEnabled, workspaceChildAgentsEnabled, managedExtensionExecutionEnabled, workspaceMounts, residentWorkspaces, createdRoots, journalEventTransport, journalEventTransportEnabled, modelConfigs); this.turnExecution = new AgentTurnExecution(eventJournal, messageProjection, sessionCommands, storeRows, jdbc, objectMapper, clock); this.executionOperations = new AgentExecutionOperations(eventJournal, lifecycleOperations, sessionPolicies, sessionQueries, storeRows, worktreeFiles, jdbc, clock, workspaces, workspaceFilesEnabled); } @@ -164,6 +179,15 @@ public class ManagedAgentStore implements AgentStateStore { return sessionAdmissions.insertSessionCommand(tenantId, actorId, operation, idempotencyKey, requestDigest, agentId, requestedRevision, title, input, payloadDigest); } + @Override + @Transactional + public Admission insertSessionCommand(String tenantId, String actorId, + String operation, String idempotencyKey, String requestDigest, + String agentId, String requestedRevision, String title, + List> input, String payloadDigest, SessionModelConfig modelConfig) { + return sessionAdmissions.insertSessionCommand(tenantId, actorId, operation, idempotencyKey, requestDigest, agentId, requestedRevision, title, input, payloadDigest, modelConfig); + } + @Override @Transactional public Admission insertWorkspaceSessionCommand(SessionCreationCommand command) { diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionModelConfigs.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionModelConfigs.java new file mode 100644 index 00000000000..687e3780a45 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionModelConfigs.java @@ -0,0 +1,106 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.SessionModelConfig; +import com.alibaba.qwen.code.managedagent.settings.ManagedAgentSettings; +import com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys; +import com.alibaba.qwen.code.runtimebroker.security.ProtectedSecret; +import com.alibaba.qwen.code.runtimebroker.security.SecretProtector; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class ManagedSessionModelConfigs { + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final ObjectProvider secrets; + private final ManagedAgentSettings settings; + + public ManagedSessionModelConfigs(@Qualifier("managedJdbcTemplate") JdbcTemplate jdbc, + @Qualifier("managedAgentObjectMapper") ObjectMapper json, + ObjectProvider secrets, ManagedAgentSettings settings) { + this.jdbc = jdbc; + this.json = json; + this.secrets = secrets; + this.settings = settings; + } + + public void requireAdmission(SessionModelConfig config) { + if (!settings.getSessionStore().isEnabled() || secrets.getIfAvailable() == null) { + throw unavailable(); + } + if (!settings.getModelConfigBaseUrls().contains(config.baseUrl())) { + throw new ManagedAgentException(400, "model_endpoint_not_allowed", "The model endpoint is not permitted."); + } + } + + public void save(String tenant, String session, SessionModelConfig config) { + try { + SecretProtector protector = secrets.getIfAvailable(); + if (protector == null) { + throw unavailable(); + } + ProtectedSecret encrypted = protector.protect(context(tenant, session), json.writeValueAsBytes(config.wireValue())); + if (jdbc.update("UPDATE ma_session SET model_config_digest=?, model_config_key_id=?, model_config_ciphertext=?" + + " WHERE tenant_id=? AND session_id=? AND model_config_digest IS NULL", + config.digest(), encrypted.getKeyId(), encrypted.getCiphertext(), tenant, session) != 1) { + throw unavailable(); + } + } catch (JsonProcessingException error) { + throw unavailable(); + } + } + + public String digest(String tenant, String session) { + var values = jdbc.query("SELECT model_config_digest FROM ma_session WHERE tenant_id=? AND session_id=?", + (row, index) -> row.getString(1), tenant, session); + return values.isEmpty() ? null : values.getFirst(); + } + + public SessionModelConfig read(String tenant, String session, String expectedDigest) { + var values = jdbc.query("SELECT model_config_digest, model_config_key_id, model_config_ciphertext" + + " FROM ma_session WHERE tenant_id=? AND session_id=?", (row, index) -> { + if (expectedDigest == null || !expectedDigest.equals(row.getString(1))) { + throw unavailable(); + } + SecretProtector protector = secrets.getIfAvailable(); + if (protector == null) { + throw unavailable(); + } + try { + byte[] bytes = protector.unprotect(context(tenant, session), + new ProtectedSecret(row.getString(2), row.getString(3))); + SessionModelConfig config = SessionModelConfig.parse(json.readTree(bytes)); + if (config == null || !expectedDigest.equals(config.digest())) { + throw unavailable(); + } + return config; + } catch (java.io.IOException | RuntimeException error) { + throw unavailable(); + } + }, tenant, session); + if (values.size() != 1) { + throw unavailable(); + } + return values.getFirst(); + } + + public void inherit(String tenant, String parent, String child) { + String digest = digest(tenant, parent); + if (digest != null) { + save(tenant, child, read(tenant, parent, digest)); + } + } + + private static String context(String tenant, String session) { + return "managed-session-model-config/1:" + ManagedSchemaKeys.key(tenant, session); + } + + private static ManagedAgentException unavailable() { + return new ManagedAgentException(409, "model_config_unavailable", "The Session model configuration is unavailable."); + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java index 243b4c55ac0..8061defe236 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java @@ -230,7 +230,9 @@ final class WorkspaceCsiApplicationInventory { + "session_scope_key:CHAR,title_revision:BIGINT,retirement_operation_id:VARBINARY," + "retirement_generation:BIGINT,retired_at:BIGINT,recovery_protected:BOOLEAN," + "resource_gc_completed:BOOLEAN,runtime_request_key:CHAR,csi_guard:BOOLEAN," - + "model_id:VARBINARY,model_revision:BIGINT,selected_approval_mode:VARCHAR,approval_revision:BIGINT," + + "model_id:VARBINARY,model_revision:BIGINT," + + "model_config_digest:VARCHAR,model_config_key_id:VARCHAR,model_config_ciphertext:LONGTEXT," + + "selected_approval_mode:VARCHAR,approval_revision:BIGINT," + "plan_enabled:BOOLEAN,permission_rules_json:LONGTEXT,public_child_agents_version:INT,harness_instance_id:VARBINARY,harness_owner_boot_id:VARBINARY," + "harness_placement_epoch:BIGINT,harness_placement_state:VARCHAR"), declaration("ma_session_head", "tenant_key,harness_key", @@ -2844,6 +2846,8 @@ final class WorkspaceCsiApplicationInventory { expect(table, row, rawSession(row) && "ACTIVE".equals(row.text("status")) && row.cell("deleted_at").isNull() && CsiFilesRetirementProfile.PROFILE.equals(row.text("tool_profile")) && row.cell("model_id").isNull() && Objects.equals(0L, row.number("model_revision")) + && row.cell("model_config_digest").isNull() && row.cell("model_config_key_id").isNull() + && row.cell("model_config_ciphertext").isNull() && row.cell("selected_approval_mode").isNull() && Objects.equals(0L, row.number("approval_revision")) && Boolean.FALSE.equals(row.bool("plan_enabled")) && row.cell("permission_rules_json").isNull() && Objects.equals(0L, row.number("public_child_agents_version")) diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaProjection.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaProjection.java index 0a559a47f37..f2d1a461747 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaProjection.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaProjection.java @@ -124,6 +124,7 @@ public enum ManagedSchemaProjection { + "session_kind, tenant_key, session_key, session_scope_key, title_revision, " + "retirement_operation_id, retirement_generation, retired_at, recovery_protected, " + "resource_gc_completed, runtime_request_key, csi_guard, model_id, model_revision, " + + "model_config_digest, model_config_key_id, model_config_ciphertext, " + "selected_approval_mode, approval_revision, plan_enabled, permission_rules_json, public_child_agents_version, harness_instance_id, " + "harness_owner_boot_id, harness_placement_epoch, harness_placement_state"), SESSION_HEAD( diff --git a/packages/managed-agent-server/persistence/src/main/resources/db/managed-schema/V1__managed_schema.sql b/packages/managed-agent-server/persistence/src/main/resources/db/managed-schema/V1__managed_schema.sql index 2165ccc647c..6c2dcdf7f7e 100644 --- a/packages/managed-agent-server/persistence/src/main/resources/db/managed-schema/V1__managed_schema.sql +++ b/packages/managed-agent-server/persistence/src/main/resources/db/managed-schema/V1__managed_schema.sql @@ -58,6 +58,10 @@ CREATE TABLE ma_session ( csi_guard BOOLEAN GENERATED ALWAYS AS (COALESCE(tool_profile = 'csi-files-retirement/1' OR runtime_request_key IS NOT NULL, FALSE)), model_id VARBINARY(512), model_revision BIGINT NOT NULL DEFAULT 0, + model_config_digest VARCHAR(71), + model_config_key_id VARCHAR(512), + model_config_ciphertext LONGTEXT, + CONSTRAINT ck_ma_session_model_config CHECK ((model_config_digest IS NULL AND model_config_key_id IS NULL AND model_config_ciphertext IS NULL) OR (model_config_digest IS NOT NULL AND model_config_key_id IS NOT NULL AND model_config_ciphertext IS NOT NULL)), CONSTRAINT ck_ma_session_model_selection CHECK ((model_id IS NULL AND model_revision = 0) OR (model_id IS NOT NULL AND model_revision >= 1 AND model_revision <= 9007199254740991)), selected_approval_mode VARCHAR(32), approval_revision BIGINT NOT NULL DEFAULT 0, diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java index beb02b2c377..098a1301ccb 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java @@ -23,6 +23,10 @@ import com.alibaba.qwen.code.managedagent.persistence.ManagedUserMessageStore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonUnwrapped; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -45,6 +49,8 @@ public class ManagedSessionStoreController { private final ManagedSessionStore store; private final ManagedUserMessageStore userMessages; private final ManagedNativeJournalSnapshots snapshots; + @org.springframework.beans.factory.annotation.Autowired + private com.alibaba.qwen.code.managedagent.persistence.ManagedSessionModelConfigs modelConfigs; public ManagedSessionStoreController(ManagedSessionStore store, ManagedUserMessageStore userMessages, ManagedNativeJournalSnapshots snapshots) { @@ -55,6 +61,33 @@ public class ManagedSessionStoreController { public record QueueClaim(String workspaceId, String writerId, long writerGeneration, String promptId, boolean initialOnly) { } + public record ModelConfigQuery( + @NotBlank @Size(max = 512) String workspaceId, + @NotBlank @Size(max = 512) String writerId, + @Min(1) @Max(ManagedSessionStoreModels.MAX_SAFE_COUNTER) long writerGeneration, + String expectedDigest, String kind) { } + + @PostMapping("/model-config:query") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity modelConfig(TenantContext tenant, @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) String writerToken, + @Valid @RequestBody ModelConfigQuery request, @RequestHeader HttpHeaders headers) { + var authority = lifecycleAuthority(headers); + if (request.kind() != null && !"legacy-close".equals(request.kind())) { + throw new ManagedAgentException(400, "invalid_model_config_query", "Invalid model configuration query."); + } + var owner = new ManagedSessionStoreModels.AuthorizeLifecycleRequest( + request.workspaceId(), request.writerId(), request.writerGeneration(), authority == null ? request.kind() : null); + if (authority == null) { + store.authorizeOrdinary(tenant.tenantId(), sessionId, writerToken, owner); + } else { + store.authorizeLifecycle(tenant.tenantId(), sessionId, writerToken, owner, authority); + } + var config = modelConfigs.read(tenant.tenantId(), sessionId, request.expectedDigest()); + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .body(java.util.Map.of("digest", config.digest(), "config", config.wireValue())); + } + @PostMapping("/user-messages:claim") @org.springframework.transaction.annotation.Transactional public Object claimUserMessages(TenantContext tenant, @PathVariable String sessionId, diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java index 26eb25270d2..bacbfeeb3bc 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java @@ -106,14 +106,11 @@ public class PublicAgentController { ? service.createSession(tenant.tenantId(), tenant.actorId(), idempotencyKey, request.agentId(), request.agentRevision(), null, request.metadata(), - request.input()) - : worktree == null ? service.createWorkspaceSession(tenant.tenantId(), - tenant.requireActorId(), idempotencyKey, - request.agentId(), request.agentRevision(), null, - request.metadata(), request.input(), selection, null, null, request.skills(), null, request.extensionPins()) + request.input(), com.alibaba.qwen.code.managedagent.model.SessionModelConfig.parse(request.modelConfig())) : service.createWorkspaceSession(tenant.tenantId(), tenant.requireActorId(), idempotencyKey, request.agentId(), request.agentRevision(), null, request.metadata(), request.input(), - selection, null, null, request.skills(), null, request.extensionPins(), worktree); + selection, null, null, request.skills(), null, request.extensionPins(), worktree, + com.alibaba.qwen.code.managedagent.model.SessionModelConfig.parse(request.modelConfig())); PublicSession session = service.getPublicSession(tenant.tenantId(), tenant.actorId(), admission.sessionId()); return ResponseEntity.status(HttpStatus.ACCEPTED) diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java index ff23f991ffd..0d067428930 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java @@ -249,14 +249,12 @@ public class WebShellAgentController { WebShellAdmission admission = webShell(selection == null ? service.createSession(tenant.tenantId(), tenant.actorId(), request.idempotencyKey(), request.agentId(), null, - request.title(), null, request.input()) - : worktree == null ? service.createWorkspaceSession(tenant.tenantId(), - tenant.requireActorId(), request.idempotencyKey(), - request.agentId(), null, request.title(), null, - request.input(), selection, request.approvalMode(), request.planMode(), request.skills(), request.modelId(), request.extensionPins()) + request.title(), null, request.input(), + com.alibaba.qwen.code.managedagent.model.SessionModelConfig.parse(request.modelConfig())) : service.createWorkspaceSession(tenant.tenantId(), tenant.requireActorId(), request.idempotencyKey(), request.agentId(), null, request.title(), null, request.input(), selection, - request.approvalMode(), request.planMode(), request.skills(), request.modelId(), request.extensionPins(), worktree)); + request.approvalMode(), request.planMode(), request.skills(), request.modelId(), request.extensionPins(), worktree, + com.alibaba.qwen.code.managedagent.model.SessionModelConfig.parse(request.modelConfig()))); return ResponseEntity.accepted().body(admission); } diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java index 9b774760d52..c84f6cec7b1 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java @@ -83,6 +83,7 @@ public class ManagedAgentProperties List.of("hosted-workspace-files/1"); private List definitionModels = List.of(); private List modelCatalog = List.of(); + private List modelConfigBaseUrls = List.of(); private String trustedActorHeader = ""; // The account the server runs as; preflight checks file access as it. private String serviceUser = ""; diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionModelConfigMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionModelConfigMySqlIT.java new file mode 100644 index 00000000000..af514a28968 --- /dev/null +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionModelConfigMySqlIT.java @@ -0,0 +1,18 @@ +package com.alibaba.qwen.code.managedagent; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +class ManagedSessionModelConfigMySqlIT extends ManagedSessionModelConfigTest { + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + String url = System.getProperty("mysql.url"); + if (url == null || !url.startsWith("jdbc:mysql://")) { + throw new IllegalArgumentException("A disposable MySQL test database URL is required"); + } + registry.add("spring.datasource.url", () -> url); + registry.add("spring.datasource.username", () -> System.getProperty("mysql.user", "root")); + registry.add("spring.datasource.password", () -> System.getProperty("mysql.password", "")); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + } +} diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionModelConfigTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionModelConfigTest.java new file mode 100644 index 00000000000..a41f1869e09 --- /dev/null +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionModelConfigTest.java @@ -0,0 +1,328 @@ +package com.alibaba.qwen.code.managedagent; + +import com.alibaba.qwen.code.managedagent.api.ManagedSessionStoreController; +import com.alibaba.qwen.code.managedagent.api.TenantContextArgumentResolver; +import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.contracts.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.AcquireWriterRequest; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionCreationCommand; +import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceSelection; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.harness.SdkHarnessBoundary; +import com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules; +import com.alibaba.qwen.code.managedagent.model.RequestDigests; +import com.alibaba.qwen.code.managedagent.model.SessionModelConfig; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionLineage; +import com.alibaba.qwen.code.managedagent.model.TenantContext; +import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionModelConfigs; +import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; +import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.managedagent.session.HarnessCoordinator; +import com.alibaba.qwen.code.managedagent.session.ManagedAgentService; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.adapter.security.AesGcmSecretProtector; +import com.alibaba.qwen.code.runtimebroker.security.SecretProtector; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:session-model-config;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", "spring.datasource.password=", + "qwen.managed-agent.harness.enabled=false" +}) +class ManagedSessionModelConfigTest { + @Autowired private @Qualifier("managedJdbcTemplate") JdbcTemplate jdbc; + @Autowired private ObjectMapper json; + @Autowired private ManagedWorkspaceRegistry registry; + @Autowired private PlatformTransactionManager manager; + private final String token = "synthetic-writer-token-0123456789012345"; + private String tenant; + private ManagedAgentProperties properties; + private ManagedSessionModelConfigs configs; + private ManagedAgentStore store; + private ManagedAgentService service; + private HarnessConnector harness; + private DefaultListableBeanFactory beans; + + @BeforeEach + void setup() { + tenant = "model-config-" + UUID.randomUUID(); + jdbc.update("INSERT INTO ma_workspace (tenant_id,workspace_id,workspace_generation,storage_id," + + "display_name,config_ref,policy_ref,state) VALUES (?,'ws',1,'storage','Workspace',?,?,'ACTIVE')", + tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF); + jdbc.update("INSERT INTO ma_workspace_access (tenant_id,workspace_id,actor_id,role) VALUES (?,'ws',?,'OWNER')", + tenant, "actor".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + properties = new ManagedAgentProperties(); + properties.getHarness().setWorkspaceFilesEnabled(true); + properties.getSessionStore().setEnabled(true); + properties.setModelConfigBaseUrls(List.of("https://models.example/v1")); + var model = new ManagedAgentProperties.ModelOption(); + model.setModelId("catalog"); + model.setLabel("Catalog model"); + properties.setModelCatalog(List.of(model)); + properties.getRuntimeBroker().setWorkspaceMounts(List.of( + new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(tenant, "storage", "/tmp/model-config"))); + beans = new DefaultListableBeanFactory(); + beans.registerSingleton("protector", new AesGcmSecretProtector("test-key", new byte[32])); + configs = repository(); + store = new ManagedAgentStore(jdbc, json, Clock.systemUTC(), ignored -> {}, + registry, properties, new SdkHarnessRules(), + new com.alibaba.qwen.code.managedagent.adapters.workspace.LocalWorktreeFiles(), + new com.alibaba.qwen.code.managedagent.persistence.ManagedSessionSkills(jdbc, json, new RequestDigests(), null), + null, null, null, null, null, null, configs); + harness = mock(HarnessConnector.class); + when(harness.supportsModelConfiguration()).thenReturn(true); + when(harness.supportsModelSelection()).thenReturn(true); + service = new ManagedAgentService(store, new RequestDigests(), mock(HarnessCoordinator.class), new SdkHarnessBoundary(harness), registry, + new SdkHarnessRules()); + } + + @Test + void legacyCreationPersistsConfigurationAndReplaysBeforeAdmissionPolicy() { + var config = config("legacy-key"); + String session = new TransactionTemplate(manager).execute(status -> service.createSession( + tenant, "actor", "legacy", "qwen-code", null, null, null, List.of(), config)).sessionId(); + assertThat(store.requireSession(tenant, session).modelConfigDigest()).isEqualTo(config.digest()); + assertThat(configs.read(tenant, session, config.digest()).wireValue()).isEqualTo(config.wireValue()); + properties.setModelConfigBaseUrls(List.of()); + when(harness.supportsModelConfiguration()).thenReturn(false); + assertThat(new TransactionTemplate(manager).execute(status -> service.createSession( + tenant, "actor", "legacy", "qwen-code", null, null, null, List.of(), config)).sessionId()).isEqualTo(session); + } + + @Test + void freezesEncryptedConfigurationAndReplaysBeforeMutableAdmissionGates() { + var config = config("synthetic-key-a"); + String session = create("create", config); + assertThat(store.requireSession(tenant, session).modelConfigDigest()).isEqualTo(config.digest()); + var row = jdbc.queryForMap("SELECT model_config_digest, model_config_key_id, model_config_ciphertext" + + " FROM ma_session WHERE tenant_id=? AND session_id=?", tenant, session); + assertThat(row.toString()).doesNotContain("synthetic-key-a", "models.example", "session-model"); + assertThat(repository().read(tenant, session, config.digest()).wireValue()).isEqualTo(config.wireValue()); + when(harness.supportsModelConfiguration()).thenReturn(false); + properties.setModelConfigBaseUrls(List.of()); + assertThat(create("create", config)).isEqualTo(session); + assertCode(() -> create("create", config("different-key")), "idempotency_conflict"); + assertCode(() -> create("new", config), "unsupported_feature"); + assertCode(() -> new TransactionTemplate(manager).execute(status -> + store.changeSessionModel(tenant, "actor", session, "switch", "digest", "catalog", 0, true)), + "model_config_immutable"); + } + + @Test + void requiresAdmissionPolicyAndDoesNotLeavePartialSessions() { + properties.setModelConfigBaseUrls(List.of()); + assertCode(() -> create("denied", config("key")), "model_endpoint_not_allowed"); + properties.setModelConfigBaseUrls(List.of("https://models.example/v1")); + beans.destroySingleton("protector"); + assertCode(() -> create("missing-key", config("key")), "model_config_unavailable"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session WHERE tenant_id=?", Integer.class, tenant)).isZero(); + } + + @Test + void checksWorkspaceAccessBeforeEndpointPolicy() { + properties.setModelConfigBaseUrls(List.of()); + assertCode(() -> new TransactionTemplate(manager).execute(status -> service.createWorkspaceSession( + tenant, "stranger", "unauthorized", "qwen-code", null, null, null, List.of(), + new WorkspaceSelection("ws", "."), null, null, null, null, null, null, config("key"))), + "workspace_not_found"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session WHERE tenant_id=?", Integer.class, tenant)).isZero(); + } + + @Test + void configuredSessionsDoNotAdvertiseCatalogSwitching() { + String configured = create("configured", config("key")); + assertThat(service.getWebShellSession(tenant, "actor", configured).capabilities().modelChange()).isFalse(); + assertThat(service.queryWebShellSessionModels(tenant, "actor", configured).supported()).isFalse(); + String ordinary = create("ordinary", null); + assertThat(service.getWebShellSession(tenant, "actor", ordinary).capabilities().modelChange()).isTrue(); + assertThat(service.queryWebShellSessionModels(tenant, "actor", ordinary).supported()).isTrue(); + } + + @Test + void refusesCatalogSelectionTogetherWithACompleteConfigurationAtBothAdmissionLayers() { + var config = config("key"); + assertCode(() -> new TransactionTemplate(manager).execute(status -> service.createWorkspaceSession( + tenant, "actor", "service-pair", "qwen-code", null, null, null, List.of(), + new WorkspaceSelection("ws", "."), null, null, null, "catalog", null, null, config)), + "invalid_model_config"); + var command = SessionCreationCommand.builder().tenantId(tenant).actorId("actor") + .idempotencyKey("store-pair").requestDigest("pair-digest").agentId("qwen-code") + .selection(new WorkspaceSelection("ws", ".")).modelId("catalog").modelConfig(config).build(); + assertCode(() -> new TransactionTemplate(manager).execute(status -> store.insertWorkspaceSessionCommand(command)), + "invalid_model_config"); + assertThat(store.requireSession(tenant, create("config-only", config)).modelConfigDigest()).isEqualTo(config.digest()); + } + + @Test + void reencryptsInheritedConfigurationForTheChildIdentity() { + var config = config("parent-key"); + String parent = create("parent", config); + var input = List.>of(Map.of("type", "workflow_launch")); + String child = new TransactionTemplate(manager).execute(status -> store.insertChildSessionCommand( + tenant, parent, "child", "child-digest", "Child", input, + com.alibaba.qwen.code.daemon.SubmitHarnessTurn.computePayloadDigest(input), + new SessionLineage(parent, parent, "workflow-run", 1))).sessionId(); + assertThat(configs.read(tenant, child, config.digest()).wireValue()).isEqualTo(config.wireValue()); + String parentCiphertext = jdbc.queryForObject("SELECT model_config_ciphertext FROM ma_session WHERE tenant_id=? AND session_id=?", + String.class, tenant, parent); + assertThat(jdbc.queryForObject("SELECT model_config_ciphertext FROM ma_session WHERE tenant_id=? AND session_id=?", + String.class, tenant, child)).isNotEqualTo(parentCiphertext); + jdbc.update("UPDATE ma_session SET model_config_ciphertext=? WHERE tenant_id=? AND session_id=?", parentCiphertext, tenant, child); + assertCode(() -> configs.read(tenant, child, config.digest()), "model_config_unavailable"); + } + + @Test + void missingEncryptionKeyIsAConfigurationRefusal() { + var config = config("key"); + String session = create("key-rotation", config); + beans.destroySingleton("protector"); + beans.registerSingleton("protector", new AesGcmSecretProtector("replacement-key", new byte[32])); + assertCode(() -> configs.read(tenant, session, config.digest()), "model_config_unavailable"); + } + + @Test + void privateReadValidatesWriterBeforeAuthorization() throws Exception { + var authority = mock(ManagedSessionStore.class); + var modelConfigs = mock(ManagedSessionModelConfigs.class); + var config = config("key"); + when(modelConfigs.read(tenant, "session", config.digest())).thenReturn(config); + var controller = new ManagedSessionStoreController(authority, null, null); + ReflectionTestUtils.setField(controller, "modelConfigs", modelConfigs); + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new TenantContextArgumentResolver()).build(); + var body = json.createObjectNode().put("workspaceId", "ws").put("writerGeneration", 1) + .put("expectedDigest", config.digest()); + mvc.perform(post("/internal/managed-session-store/v1/sessions/session/model-config:query") + .requestAttr(TenantContextFilter.ATTRIBUTE, new TenantContext(tenant, null)) + .header(com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.WRITER_TOKEN_HEADER, token) + .contentType(MediaType.APPLICATION_JSON).content(json.writeValueAsBytes(body))) + .andExpect(status().isBadRequest()); + verifyNoInteractions(authority, modelConfigs); + body.put("writerId", "writer"); + mvc.perform(post("/internal/managed-session-store/v1/sessions/session/model-config:query") + .requestAttr(TenantContextFilter.ATTRIBUTE, new TenantContext(tenant, null)) + .header(com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.WRITER_TOKEN_HEADER, token) + .contentType(MediaType.APPLICATION_JSON).content(json.writeValueAsBytes(body))) + .andExpect(status().isOk()); + } + + @Test + void privateReadRequiresCurrentTenantWorkspaceWriterGenerationAndDigest() { + var config = config("private-key"); + String session = create("private", config); + var sessionStore = VerificationFixtures.managedSessionStore(jdbc); + new TransactionTemplate(manager).execute(status -> sessionStore.acquireWriter(tenant, session, token, + new AcquireWriterRequest("ws", "writer", 60_000L))); + var controller = new ManagedSessionStoreController(sessionStore, null, null); + ReflectionTestUtils.setField(controller, "modelConfigs", configs); + var request = new ManagedSessionStoreController.ModelConfigQuery("ws", "writer", 1, config.digest(), null); + var result = new TransactionTemplate(manager).execute(status -> controller.modelConfig(new TenantContext(tenant, null), session, + token, request, new HttpHeaders())); + assertThat(result.getHeaders().getCacheControl()).isEqualTo("no-store"); + assertThat(result.getBody()).isEqualTo(Map.of("digest", config.digest(), "config", config.wireValue())); + for (var invalid : List.of( + new ManagedSessionStoreController.ModelConfigQuery("other", "writer", 1, config.digest(), null), + new ManagedSessionStoreController.ModelConfigQuery("ws", "other", 1, config.digest(), null), + new ManagedSessionStoreController.ModelConfigQuery("ws", "writer", 2, config.digest(), null), + new ManagedSessionStoreController.ModelConfigQuery("ws", "writer", 1, config.digest(), "unknown"), + new ManagedSessionStoreController.ModelConfigQuery("ws", "writer", 1, "sha256:" + "0".repeat(64), null))) { + assertThatThrownBy(() -> new TransactionTemplate(manager).execute(status -> controller.modelConfig( + new TenantContext(tenant, null), session, token, invalid, new HttpHeaders()))).isInstanceOf(ManagedAgentException.class); + } + assertThatThrownBy(() -> new TransactionTemplate(manager).execute(status -> controller.modelConfig( + new TenantContext("other", null), session, token, request, new HttpHeaders()))).isInstanceOf(ManagedAgentException.class); + assertThatThrownBy(() -> new TransactionTemplate(manager).execute(status -> controller.modelConfig( + new TenantContext(tenant, null), session, "wrong-token", request, new HttpHeaders()))).isInstanceOf(ManagedAgentException.class); + } + + @Test + void secretReadsPreserveLegacyCloseAndLifecycleAuthority() { + var config = config("hook-key"); + String session = create("hooks", config); + var authority = mock(ManagedSessionStore.class); + var controller = new ManagedSessionStoreController(authority, null, null); + ReflectionTestUtils.setField(controller, "modelConfigs", configs); + controller.modelConfig(new TenantContext(tenant, null), session, token, + new ManagedSessionStoreController.ModelConfigQuery("ws", "writer", 1, config.digest(), "legacy-close"), new HttpHeaders()); + verify(authority).authorizeOrdinary(tenant, session, token, + new com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.AuthorizeLifecycleRequest( + "ws", "writer", 1, "legacy-close")); + var headers = new HttpHeaders(); + var lifecycle = new com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority(UUID.randomUUID().toString(), 1); + headers.set(com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority.OPERATION_HEADER, lifecycle.operationId()); + headers.set(com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority.GENERATION_HEADER, "1"); + controller.modelConfig(new TenantContext(tenant, null), session, token, + new ManagedSessionStoreController.ModelConfigQuery("ws", "writer", 1, config.digest(), null), headers); + verify(authority).authorizeLifecycle(tenant, session, token, + new com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.AuthorizeLifecycleRequest( + "ws", "writer", 1, null), lifecycle); + controller.modelConfig(new TenantContext(tenant, null), session, token, + new ManagedSessionStoreController.ModelConfigQuery("ws", "writer", 1, config.digest(), "legacy-close"), headers); + verify(authority, org.mockito.Mockito.times(2)).authorizeLifecycle(tenant, session, token, + new com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.AuthorizeLifecycleRequest( + "ws", "writer", 1, null), lifecycle); + } + + @Test + void validatesConnectionAndGenerationFieldsWithoutLeakingSecrets() { + for (var override : List.of(Map.of("protocol", "qwen-oauth"), Map.of("base_url", "file:///tmp/model"), + Map.of("base_url", "https://user:password@models.example/v1"), Map.of("api_key", "bad\nkey"), + Map.of("generation_config", Map.of("temperature", 3)), Map.of("generation_config", Map.of("max_tokens", 0.5)), + Map.of("unknown", "private-secret"))) { + var value = json.valueToTree(config("private-secret").wireValue()).deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) value).setAll((com.fasterxml.jackson.databind.node.ObjectNode) json.valueToTree(override)); + assertCode(() -> SessionModelConfig.parse(value), "invalid_model_config"); + } + assertThat(config("private-secret").toString()).doesNotContain("private-secret"); + } + + private ManagedSessionModelConfigs repository() { + return new ManagedSessionModelConfigs(jdbc, json, beans.getBeanProvider(SecretProtector.class), properties); + } + + private SessionModelConfig config(String key) { + return SessionModelConfig.parse(json.valueToTree(Map.of("protocol", "openai", "model", "session-model", + "base_url", "https://models.example/v1", "api_key", key, "generation_config", Map.of("temperature", 0.4)))); + } + + private String create(String key, SessionModelConfig config) { + return new TransactionTemplate(manager).execute(status -> service.createWorkspaceSession(tenant, "actor", key, + "qwen-code", null, null, null, List.of(), new WorkspaceSelection("ws", "."), null, null, null, + null, null, null, config)).sessionId(); + } + + private static void assertCode(Runnable work, String code) { + assertThatThrownBy(work::run).isInstanceOfSatisfying(ManagedAgentException.class, + error -> assertThat(error.getCode()).isEqualTo(code)); + } +} diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java index cb965a621de..460c8b9ab35 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java @@ -467,6 +467,10 @@ public enum SurfaceRegistry { "/internal/managed-session-store/v1/sessions/{sessionId}/approval:query", Surface.INTERNAL, RuleClass.INTERNAL_WRITER, EnumSet.of(Capability.STORE_APPROVAL_QUERY)), + INTERNAL_MODEL_CONFIG_QUERY(Method.POST, + "/internal/managed-session-store/v1/sessions/{sessionId}/model-config:query", + Surface.INTERNAL, RuleClass.INTERNAL_WRITER, + EnumSet.of(Capability.STORE_MODEL_CONFIG_QUERY)), INTERNAL_EXECUTION_AUTHORIZE(Method.POST, "/internal/managed-session-store/v1/sessions/{sessionId}" + "/execution:authorize", @@ -777,6 +781,7 @@ public enum SurfaceRegistry { AUTOMATION_RUN, AUTOMATION_RUN_LIST, STORE_APPROVAL_QUERY, + STORE_MODEL_CONFIG_QUERY, STORE_EXECUTION_AUTHORIZE, STORE_LIFECYCLE_AUTHORIZE, STORE_WRITER_ACQUIRE, diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/harness/HarnessSdkValuesTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/harness/HarnessSdkValuesTest.java index 047eaaf5528..d947a4d478c 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/harness/HarnessSdkValuesTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/harness/HarnessSdkValuesTest.java @@ -30,6 +30,15 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class HarnessSdkValuesTest { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void boundaryForwardsModelConfigurationCapability(boolean supported) { + var delegate = mock(HarnessConnector.class); + when(delegate.supportsModelConfiguration()).thenReturn(supported); + assertThat(new SdkHarnessBoundary(delegate).supportsModelConfiguration()).isEqualTo(supported); + verify(delegate).supportsModelConfiguration(); + } + @Test void httpStatusBodyFieldsAndTransportCauseSurviveTheBoundary() throws Exception { var cause = new IOException("disconnected"); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/AgentStateStoreProxyTransactionTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/AgentStateStoreProxyTransactionTest.java index bd8a4d805cf..5c77103a60c 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/AgentStateStoreProxyTransactionTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/AgentStateStoreProxyTransactionTest.java @@ -26,7 +26,7 @@ import static org.mockito.Mockito.mock; class AgentStateStoreProxyTransactionTest { @Test - void bothCreationBoundariesCommitAndRollbackOnTheDedicatedConnection() throws Exception { + void creationBoundariesCommitAndRollbackOnTheDedicatedConnection() throws Exception { var source = new DriverManagerDataSource("jdbc:h2:mem:transaction-boundaries-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); var business = new DriverManagerDataSource("jdbc:h2:mem:host-business-" + UUID.randomUUID(), "sa", ""); var jdbc = new JdbcTemplate(source); @@ -34,7 +34,7 @@ class AgentStateStoreProxyTransactionTest { var fail = new AtomicBoolean(); var target = mock(ManagedAgentStore.class, invocation -> { var method = invocation.getMethod(); - if (method.getName().equals("insertSessionCommand") && method.getParameterCount() == 10 + if (method.getName().equals("insertSessionCommand") && Set.of(10, 11).contains(method.getParameterCount()) || method.getName().equals("insertWorkspaceSessionCommand") && method.getParameterCount() == 1) { assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); assertThat(TransactionSynchronizationManager.hasResource(source)).isTrue(); @@ -61,9 +61,9 @@ class AgentStateStoreProxyTransactionTest { factory.addAdvice(interceptor); var proxy = (AgentStateStore) factory.getProxy(); List entries = Arrays.stream(AgentStateStore.class.getMethods()).filter(method -> - method.getName().equals("insertSessionCommand") && method.getParameterCount() == 9 + method.getName().equals("insertSessionCommand") && Set.of(9, 11).contains(method.getParameterCount()) || method.getName().equals("insertWorkspaceSessionCommand") && method.getParameterCount() == 1).toList(); - assertThat(entries).hasSize(2); + assertThat(entries).hasSize(3); for (var entry : entries) { assertThat(ManagedAgentStore.class.getDeclaredMethod(entry.getName(), entry.getParameterTypes()) .getAnnotation(org.springframework.transaction.annotation.Transactional.class)).isNotNull(); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStoreWiringTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStoreWiringTest.java index d11bbad3f66..d3eef39ca02 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStoreWiringTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStoreWiringTest.java @@ -59,6 +59,9 @@ class ManagedAgentStoreWiringTest { if (!"extensionPackages".equals(missing)) { runner = runner.withBean(ExtensionPackages.class, () -> mock(ExtensionPackages.class)); } + if (!"modelConfigs".equals(missing)) { + runner = runner.withBean(ManagedSessionModelConfigs.class, () -> mock(ManagedSessionModelConfigs.class)); + } return runner; } @@ -75,6 +78,8 @@ class ManagedAgentStoreWiringTest { .isSameAs(context.getBean(WorktreeFiles.class)); assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "extensionPackages")) .isSameAs(context.getBean(ExtensionPackages.class)); + assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "modelConfigs")) + .isSameAs(context.getBean(ManagedSessionModelConfigs.class)); for (String name : List.of("lifecycle", "definitions", "sessionWorktrees", "journalEventTransport")) { assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, name)).as(name).isNull(); } @@ -83,7 +88,7 @@ class ManagedAgentStoreWiringTest { } @ParameterizedTest - @ValueSource(strings = {"worktreeFiles", "creationSkills", "extensionPackages"}) + @ValueSource(strings = {"worktreeFiles", "creationSkills", "extensionPackages", "modelConfigs"}) void missingRequiredCollaboratorsStillFailSpringStartup(String missing) { runner(missing).run(context -> { assertThat(context).hasFailed(); @@ -91,6 +96,7 @@ class ManagedAgentStoreWiringTest { String type = switch (missing) { case "worktreeFiles" -> WorktreeFiles.class.getName(); case "creationSkills" -> ManagedSessionSkills.class.getName(); + case "modelConfigs" -> ManagedSessionModelConfigs.class.getName(); default -> ExtensionPackages.class.getName(); }; assertThat(context.getStartupFailure()).hasStackTraceContaining(type); @@ -119,7 +125,7 @@ class ManagedAgentStoreWiringTest { Clock.systemUTC(), events -> {}, mock(ManagedWorkspaceRegistry.class), new ManagedAgentProperties(), mock(HarnessRules.class)); for (String name : List.of("worktreeFiles", "creationSkills", "extensionPackages", "lifecycle", - "definitions", "sessionWorktrees", "journalEventTransport", "configRecords")) { + "definitions", "sessionWorktrees", "journalEventTransport", "configRecords", "modelConfigs")) { for (Object owner : com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreOwners(store, name)) { var field = owner.getClass().getDeclaredField(name); assertThat(Modifier.isFinal(field.getModifiers())).as(name).isTrue(); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedStoreDecompositionTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedStoreDecompositionTest.java index e5845932fc4..830c9a5afca 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedStoreDecompositionTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedStoreDecompositionTest.java @@ -92,6 +92,7 @@ class ManagedStoreDecompositionTest { void facadesKeepTheExistingEntryAndTransactionPopulation() { assertEntries(ManagedAgentStore.class, """ findSessionWorktree/2:- findSessionWorktrees/2:- insertSessionCommand/10::REQUIRED:DEFAULT:false + insertSessionCommand/11::REQUIRED:DEFAULT:false insertWorkspaceSessionCommand/1::REQUIRED:DEFAULT:false insertInternalTeamLeadSessionCommand/6::REQUIRED:DEFAULT:false findWorkspaceCreateReplay/4:- replayWorkspaceSessionCommand/4::REQUIRED:DEFAULT:false insertCsiSessionCommand/7:- requireCsiRequest/2:- insertChildSessionCommand/8::REQUIRED:DEFAULT:false insertChildSessionCommand/9::REQUIRED:DEFAULT:false findChildWorkspaceCwd/3:- diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java index 341c9be9c95..e27d4a0663b 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java @@ -788,7 +788,7 @@ class WorkspaceCsiApplicationInventoryTest { void inventoriesEveryDeclaredTableAndPreservesRealPrivateCreationFacts() throws Exception { var snapshot = inspect(); assertThat(snapshot.tables()).hasSize(com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.TABLES.size()); - assertThat(snapshot.tables().stream().map(table -> table.columns().size()).mapToInt(Integer::intValue).sum()).isEqualTo(805); + assertThat(snapshot.tables().stream().map(table -> table.columns().size()).mapToInt(Integer::intValue).sum()).isEqualTo(808); assertThat(snapshot.blockers()).noneMatch(blocker -> blocker.code().equals("schema_drift") || blocker.code().equals("query_unavailable")); for (String table : new String[] {"ma_session", "ma_public_event", "ma_event_stream", "ma_storage", "ma_operation", @@ -871,6 +871,34 @@ class WorkspaceCsiApplicationInventoryTest { .hasMessage("Identity is not valid UTF-8"); } + @Test + void modelConfigurationColumnsRetainTypedValuesAndBlockPrivateModelActivity() throws Exception { + var before = inspect(); + var owner = before.table("ma_session").rows().getFirst(); + for (String field : List.of("model_config_digest", "model_config_key_id", "model_config_ciphertext")) { + assertThat(owner.cell(field).isNull()).isTrue(); + } + assertThat(before.blockers()).noneMatch(blocker -> blocker.table().equals("ma_session")); + + String digest = "sha256:" + "a".repeat(64); + jdbc.update("UPDATE ma_session SET model_config_digest = ?, model_config_key_id = ?, model_config_ciphertext = ?", + digest, "encryption-key", "encrypted-connection"); + var after = inspect(); + var configured = after.table("ma_session").rows().getFirst(); + assertThat(configured.text("model_config_digest")).isEqualTo(digest); + assertThat(configured.text("model_config_key_id")).isEqualTo("encryption-key"); + assertThat(configured.text("model_config_ciphertext")).isEqualTo("encrypted-connection"); + assertThat(configured.bytes()).isNotEqualTo(owner.bytes()); + assertThat(after.blockers()).noneMatch(blocker -> blocker.code().equals("schema_drift")); + assertThat(after.blockers()).anyMatch(blocker -> blocker.table().equals("ma_session") + && blocker.code().equals("invalid_original_association")); + + jdbc.update("UPDATE ma_session SET model_config_digest = NULL, model_config_key_id = NULL, model_config_ciphertext = NULL"); + var restored = inspect(); + assertThat(restored.table("ma_session").rows().getFirst().bytes()).isEqualTo(owner.bytes()); + assertThat(restored.blockers()).noneMatch(blocker -> blocker.table().equals("ma_session")); + } + @Test void mainPlanColumnsRetainActualValuesWithoutAdmittingPrivatePlanActivity() throws Exception { var before = inspect(); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionCreationCommandContractTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionCreationCommandContractTest.java index 301a86a11a3..e119c588baf 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionCreationCommandContractTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionCreationCommandContractTest.java @@ -59,7 +59,8 @@ class SessionCreationCommandContractTest { assertThat(command.extensionPins()).isNull(); assertThat(command.extensionLifecycleProfiles()).isEqualTo(Set.of()); assertThat(command.worktree()).isNull(); - assertThat(SessionCreationCommand.class.getRecordComponents()).hasSize(20); + assertThat(command.modelConfig()).isNull(); + assertThat(SessionCreationCommand.class.getRecordComponents()).hasSize(21); } @Test diff --git a/packages/managed-agent-server/verification/src/test/resources/capability-parity/managed-surface.txt b/packages/managed-agent-server/verification/src/test/resources/capability-parity/managed-surface.txt index a04a6db8824..6c1de2ef851 100644 --- a/packages/managed-agent-server/verification/src/test/resources/capability-parity/managed-surface.txt +++ b/packages/managed-agent-server/verification/src/test/resources/capability-parity/managed-surface.txt @@ -1,5 +1,5 @@ # Generated by ManagedSurfaceCaptureTest (capture). -# sha256 of the route lines below: 9888488b554806283a82b7aacf5dcf9adb26bca54c416b54602279ed61dafc51 +# sha256 of the route lines below: dec78cfad795d2fd9db3495722ba8ae51c92a0d25fe61728271cd4f00b23afe5 AUTOMATION_RETIRE DELETE /v1/agent-automations/{automationId} public SESSION_DELETE DELETE /v1/agents/sessions/{sessionId} public STORE_SNAPSHOT_GET GET /internal/managed-session-store/v1/sessions/{sessionId}/journal-snapshots/{snapshotId} internal @@ -124,6 +124,7 @@ STORE_APPROVAL_QUERY POST /internal/managed-session-store/v1/sessions/{sessionId STORE_EXECUTION_AUTHORIZE POST /internal/managed-session-store/v1/sessions/{sessionId}/execution:authorize internal STORE_SNAPSHOT_PUBLISH POST /internal/managed-session-store/v1/sessions/{sessionId}/journal-snapshots:publish internal STORE_LIFECYCLE_AUTHORIZE POST /internal/managed-session-store/v1/sessions/{sessionId}/lifecycle:authorize internal +STORE_MODEL_CONFIG_QUERY POST /internal/managed-session-store/v1/sessions/{sessionId}/model-config:query internal STORE_RECOVERY_BLOCK POST /internal/managed-session-store/v1/sessions/{sessionId}/recovery:block internal STORE_TOOL_RESULT_PUBLISH POST /internal/managed-session-store/v1/sessions/{sessionId}/tool-results:publish internal STORE_TRANSACTION_COMMIT POST /internal/managed-session-store/v1/sessions/{sessionId}/transactions:commit internal diff --git a/packages/managed-agent-server/verification/src/test/resources/lock-contract/entries.txt b/packages/managed-agent-server/verification/src/test/resources/lock-contract/entries.txt index cc90f3eee55..31740bc51c4 100644 --- a/packages/managed-agent-server/verification/src/test/resources/lock-contract/entries.txt +++ b/packages/managed-agent-server/verification/src/test/resources/lock-contract/entries.txt @@ -6,6 +6,7 @@ session|ManagedTurnCommandOperationTest|com.alibaba.qwen.code.managedagent.contr creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.contracts.session.SessionAdmissionStore#insertChildSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String,com.alibaba.qwen.code.managedagent.model.StoreModels$SessionLineage)|inherited|none creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.contracts.session.SessionAdmissionStore#insertChildSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String,com.alibaba.qwen.code.managedagent.model.StoreModels$SessionLineage,java.lang.String)|inherited|none creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.contracts.session.SessionAdmissionStore#insertPublicChildSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String,com.alibaba.qwen.code.managedagent.model.StoreModels$SessionLineage,com.fasterxml.jackson.databind.JsonNode)|inherited|none +creation|ManagedSessionModelConfigTest|com.alibaba.qwen.code.managedagent.contracts.session.SessionAdmissionStore#insertSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String,com.alibaba.qwen.code.managedagent.model.SessionModelConfig)|inherited|none creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.contracts.session.SessionAdmissionStore#insertSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String)|inherited|none session|ManagedTurnCommandOperationTest|com.alibaba.qwen.code.managedagent.contracts.session.SessionAdmissionStore#insertTurnCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String,java.lang.String)|inherited|none creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.contracts.session.SessionAdmissionStore#insertWorkspaceSessionCommand(com.alibaba.qwen.code.managedagent.contracts.session.SessionCreationCommand)|inherited|none @@ -170,6 +171,7 @@ creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.persistence.Ma creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore#insertInternalTeamLeadSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceSelection)|::REQUIRED:DEFAULT:-1::false:[]:[]:[]:[]:[]|PROPAGATION_REQUIRED,ISOLATION_DEFAULT creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore#insertPublicChildSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String,com.alibaba.qwen.code.managedagent.model.StoreModels$SessionLineage,com.fasterxml.jackson.databind.JsonNode)|::REQUIRED:DEFAULT:-1::false:[]:[]:[]:[]:[]|PROPAGATION_REQUIRED,ISOLATION_DEFAULT creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore#insertSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String)|::REQUIRED:DEFAULT:-1::false:[]:[]:[]:[]:[]|PROPAGATION_REQUIRED,ISOLATION_DEFAULT +creation|ManagedSessionModelConfigTest|com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore#insertSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String,com.alibaba.qwen.code.managedagent.model.SessionModelConfig)|::REQUIRED:DEFAULT:-1::false:[]:[]:[]:[]:[]|PROPAGATION_REQUIRED,ISOLATION_DEFAULT creation|AgentLockContractTest|com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore#insertSessionCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String)|::REQUIRED:DEFAULT:-1::false:[]:[]:[]:[]:[]|PROPAGATION_REQUIRED,ISOLATION_DEFAULT session|ManagedTurnCommandOperationTest|com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore#insertTurnCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String)|::REQUIRED:DEFAULT:-1::false:[]:[]:[]:[]:[]|PROPAGATION_REQUIRED,ISOLATION_DEFAULT session|ManagedTurnCommandOperationTest|com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore#insertTurnCommand(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.util.List,java.lang.String,java.lang.String)|::REQUIRED:DEFAULT:-1::false:[]:[]:[]:[]:[]|PROPAGATION_REQUIRED,ISOLATION_DEFAULT diff --git a/packages/managed-agent-server/verification/src/test/resources/lock-contract/sql-owners.txt b/packages/managed-agent-server/verification/src/test/resources/lock-contract/sql-owners.txt index 2d330422778..f28b11605ca 100644 --- a/packages/managed-agent-server/verification/src/test/resources/lock-contract/sql-owners.txt +++ b/packages/managed-agent-server/verification/src/test/resources/lock-contract/sql-owners.txt @@ -201,6 +201,9 @@ com/alibaba/qwen/code/managedagent/persistence/ManagedRuntimeRetentionGuard.java com/alibaba/qwen/code/managedagent/persistence/ManagedRuntimeRetentionGuard.java|SELECT 1 FROM ma_session WHERE tenant_id = ? AND session_id = ? AND csi_guard = TRUE LIMIT 1 FOR UPDATE com/alibaba/qwen/code/managedagent/persistence/ManagedRuntimeRetentionGuard.java|SELECT 1 FROM ma_session_head h WHERE h.tenant_key = ? AND h.harness_key = ? AND h.tenant_id = ? AND h.session_id = ? com/alibaba/qwen/code/managedagent/persistence/ManagedRuntimeRetentionGuard.java|SELECT tenant_id, session_id, parent_session_id, parent_child_run_id FROM ma_session WHERE tenant_id = ? AND session_id = ? +com/alibaba/qwen/code/managedagent/persistence/ManagedSessionModelConfigs.java|SELECT model_config_digest FROM ma_session WHERE tenant_id=? AND session_id=? +com/alibaba/qwen/code/managedagent/persistence/ManagedSessionModelConfigs.java|SELECT model_config_digest, model_config_key_id, model_config_ciphertext FROM ma_session WHERE tenant_id=? AND session_id=? +com/alibaba/qwen/code/managedagent/persistence/ManagedSessionModelConfigs.java|UPDATE ma_session SET model_config_digest=?, model_config_key_id=?, model_config_ciphertext=? WHERE tenant_id=? AND session_id=? AND model_config_digest IS NULL com/alibaba/qwen/code/managedagent/persistence/ManagedSessionRetirementStore.java|SELECT retired_at FROM ma_session WHERE tenant_id = ? AND session_id = ? FOR UPDATE com/alibaba/qwen/code/managedagent/persistence/ManagedSessionRetirementStore.java|SELECT retired_at, retirement_generation, recovery_protected FROM ma_session WHERE tenant_id = ? AND session_id = ? FOR UPDATE com/alibaba/qwen/code/managedagent/persistence/ManagedSessionRetirementStore.java|SELECT stream_kind, stream_id FROM ma_event_stream WHERE tenant_id = ? AND session_id = ? AND artifact_refs_resource_key IS NOT NULL LIMIT ? FOR UPDATE diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateHarnessSession.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateHarnessSession.java index dec4b51f1e8..09284d27f76 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateHarnessSession.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateHarnessSession.java @@ -15,6 +15,7 @@ public final class CreateHarnessSession { private final Long approvalTimeoutMs; private final Long captureBytes; private final Map agentDefinition; + private final String modelConfigDigest; private final Map sessionSkillsRef; private final Map initialSelection; private final List> mcpServers; @@ -38,6 +39,10 @@ public final class CreateHarnessSession { this.approvalTimeoutMs = builder.approvalTimeoutMs; this.captureBytes = builder.captureBytes; this.agentDefinition = builder.agentDefinition; + this.modelConfigDigest = builder.modelConfigDigest; + if (modelConfigDigest != null && managedSessionStore == null) { + throw new IllegalArgumentException("Session model configuration requires a Managed Session Store"); + } this.sessionSkillsRef = builder.sessionSkillsRef; this.initialSelection = builder.initialSelection; this.mcpServers = builder.mcpServers; @@ -97,6 +102,9 @@ public final class CreateHarnessSession { if (agentDefinition != null) { result.put("agentDefinition", agentDefinition); } + if (modelConfigDigest != null) { + result.put("modelConfigDigest", modelConfigDigest); + } if (initialSelection != null) { result.put("initialSelection", initialSelection); } @@ -133,6 +141,7 @@ public final class CreateHarnessSession { private Long approvalTimeoutMs; private Long captureBytes; private Map agentDefinition; + private String modelConfigDigest; private Map sessionSkillsRef; private Map initialSelection; private List> mcpServers; @@ -179,6 +188,11 @@ public final class CreateHarnessSession { return this; } + public Builder modelConfigDigest(String value) { + this.modelConfigDigest = value == null ? null : HostedHarnessClient.requireDigest(value, "modelConfigDigest"); + return this; + } + public Builder mcpServers(List> value) { this.mcpServers = value.stream().map(Map::copyOf).collect(Collectors.toUnmodifiableList()); return this; diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java index c3fbbce6d31..c05894a04e6 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HostedHarnessClient.java @@ -277,6 +277,7 @@ public final class HostedHarnessClient implements AutoCloseable { throw new IllegalArgumentException("request must not be null"); } ensureOpen(); + requireModelConfigurationCapability(request.toJson()); HttpResponse raw; try { raw = send("/session", "POST", request.toJson(), null); @@ -324,6 +325,13 @@ public final class HostedHarnessClient implements AutoCloseable { } } + private void requireModelConfigurationCapability(Map request) { + if (request.containsKey("modelConfigDigest") + && !capabilities.getFeatures().contains("session-model-config/1")) { + throw new DaemonProtocolException("Hosted Session model configuration capability is required"); + } + } + public HarnessSessionRef loadSession(LoadHarnessSession request) { return loadSession(request, false); } @@ -337,6 +345,7 @@ public final class HostedHarnessClient implements AutoCloseable { throw new IllegalArgumentException("request must not be null"); } ensureOpen(); + requireModelConfigurationCapability(request.toJson()); if (request.getRecoveryCleanupPromptId() != null && !capabilities.getFeatures().contains("recovery-cleanup/1")) { throw new DaemonProtocolException("Hosted cleanup-only load capability is required"); diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java index bee5dfac3f8..be98f632c91 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/LoadHarnessSession.java @@ -18,6 +18,7 @@ public final class LoadHarnessSession { private Map lifecycleAuthority; private int lifecycleProtocolVersion = 1; private Map agentDefinition; + private String modelConfigDigest; private Map sessionSkillsRef; private Map initialSelection; private List> mcpServers; @@ -94,6 +95,11 @@ public final class LoadHarnessSession { return this; } + public LoadHarnessSession withModelConfigDigest(String value) { + this.modelConfigDigest = value == null ? null : HostedHarnessClient.requireDigest(value, "modelConfigDigest"); + return this; + } + public LoadHarnessSession withInitialSelection(Map value) { this.initialSelection = value == null ? null : Map.copyOf(value); return this; @@ -133,6 +139,7 @@ public final class LoadHarnessSession { suppressChildAgents); copy.harnessPlacement = harnessPlacement; copy.agentDefinition = agentDefinition; + copy.modelConfigDigest = modelConfigDigest; copy.sessionSkillsRef = sessionSkillsRef; copy.initialSelection = initialSelection; copy.mcpServers = mcpServers; @@ -158,6 +165,7 @@ public final class LoadHarnessSession { suppressChildAgents); copy.harnessPlacement = harnessPlacement; copy.agentDefinition = agentDefinition; + copy.modelConfigDigest = modelConfigDigest; copy.sessionSkillsRef = sessionSkillsRef; copy.initialSelection = initialSelection; copy.mcpServers = mcpServers; @@ -195,6 +203,7 @@ public final class LoadHarnessSession { suppressChildAgents); copy.harnessPlacement = harnessPlacement; copy.agentDefinition = agentDefinition; + copy.modelConfigDigest = modelConfigDigest; copy.sessionSkillsRef = sessionSkillsRef; copy.initialSelection = initialSelection; copy.mcpServers = mcpServers; @@ -221,6 +230,7 @@ public final class LoadHarnessSession { suppressChildAgents); copy.harnessPlacement = harnessPlacement; copy.agentDefinition = agentDefinition; + copy.modelConfigDigest = modelConfigDigest; copy.sessionSkillsRef = sessionSkillsRef; copy.initialSelection = initialSelection; copy.mcpServers = mcpServers; @@ -353,6 +363,9 @@ public final class LoadHarnessSession { if (agentDefinition != null) { result.put("agentDefinition", agentDefinition); } + if (modelConfigDigest != null) { + result.put("modelConfigDigest", modelConfigDigest); + } if (initialSelection != null) { result.put("initialSelection", initialSelection); } diff --git a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java index 6f5b03be53c..72c1ce8991e 100644 --- a/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java +++ b/packages/sdk-java/qwencode/src/test/java/com/alibaba/qwen/code/daemon/HostedHarnessClientTest.java @@ -94,6 +94,26 @@ class HostedHarnessClientTest { } } + @Test + void modelConfigurationRequiresNegotiationAndSurvivesLoadCopies() { + var store = ManagedSessionStoreConnection.builder().baseUri(URI.create("https://store.example/")) + .tenantId("tenant-a").workspaceId("workspace-a").writerId(BOOT_ID) + .writerToken("qwt1_" + "a".repeat(43)).leaseDuration(Duration.ofSeconds(45)).build(); + var create = CreateHarnessSession.builder().harnessSessionId(SESSION_ID) + .managedSessionStore(store).modelConfigDigest(DIGEST).build(); + var load = new LoadHarnessSession(SESSION_ID, store, false).withModelConfigDigest(DIGEST); + assertEquals(DIGEST, create.toJson().get("modelConfigDigest")); + assertEquals(DIGEST, load.withChildWorkspaces(true).withForegroundOnly(true) + .withStoppedMessages().forLifecycle(PROMPT_ID, 1).toJson().get("modelConfigDigest")); + try (HostedHarnessClient client = newClient()) { + assertThrows(DaemonProtocolException.class, () -> client.createSession(create)); + assertThrows(DaemonProtocolException.class, () -> client.loadSession(load)); + } + assertThrows(IllegalArgumentException.class, () -> CreateHarnessSession.builder() + .harnessSessionId(SESSION_ID).modelConfigDigest(DIGEST).build()); + assertThrows(IllegalArgumentException.class, () -> load.withModelConfigDigest("invalid")); + } + @Test void mcpManagementKeepsTheSessionAndOriginalCommandIdentityAcrossReadsAndSubmission() throws Exception { createSessionRoute(); diff --git a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts index 90c8f2b233b..e03a0ffcbad 100644 --- a/packages/web-shell/client/components/managed/generated/managed-agent-api.ts +++ b/packages/web-shell/client/components/managed/generated/managed-agent-api.ts @@ -1200,6 +1200,24 @@ export interface components { prompts: "complete" | "partial" | "failed" | "stale"; }; InputBlock: components["schemas"]["InputTextBlock"] | components["schemas"]["InputFileBlock"] | components["schemas"]["InputImageBlock"]; + /** @description Immutable Session connection. Requires session-model-config/1, encrypted storage and an endpoint in the control-plane allowlist. UTF-8 limits: model 512 bytes, base_url 2048 bytes, api_key 4096 bytes, complete canonical JSON 8192 bytes. Secrets are never returned in public Session responses. Child Sessions inherit the configuration. Cannot be combined with modelId. */ + SessionModelConfig: { + /** @enum {string} */ + protocol: "openai" | "openai-responses" | "anthropic" | "gemini"; + model: string; + /** @description Exact allowlisted HTTP(S) base URL without userinfo, query or fragment. */ + base_url: string; + api_key: string; + generation_config?: { + temperature?: number; + top_p?: number; + top_k?: number; + max_tokens?: number; + presence_penalty?: number; + frequency_penalty?: number; + seed?: number; + }; + }; PublicArtifact: { /** @constant */ object: "agent.artifact"; @@ -1261,6 +1279,7 @@ export interface components { agentId: string; /** @description Optional host-catalog model for a bound built-in qwen-code Session. Requires modelSelectionSupported. Persisted atomically before the first Turn and included in creation idempotency; omission preserves the default model and old digest. */ modelId?: string; + modelConfig?: components["schemas"]["SessionModelConfig"]; /** @description The server enforces the bound in UTF-16 code units (Java String.length()), so an astral character counts as two: a title this schema's code-point maxLength accepts can still exceed the server's 256-unit bound and answer 400 invalid_title. */ title?: string | null; /** @description Text has a 4,000,000-character aggregate budget and per-block 1,000,000-character cap, counted in code points. Hosted also enforces its 64 KiB durable-record limit for text-only inputs and a 32 MiB prepared-snapshot ceiling. A complete canonical input containing file/image attachments must fit within 8 MiB and requires input-attachments/1. */