feat(managed-agent): support full model configuration at session creation (#16153)

* feat(managed-agent): support session model configuration

* codex: fix CI failure on PR #16153

* codex: address PR review feedback (#16153)

* codex: address PR review feedback (#16153)

* codex: address PR review feedback (#16153)

* codex: address PR review feedback (#16153)
This commit is contained in:
jinye authored and GitHub committed 2026-11-03 04:23:45 +00:00
1 parent 9c54abcd3f
commit 07ed0e260e
70 files changed
+2406 -96

No files matched your search

+134
View File
@@ -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.
@@ -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 的凭据更新
需要单独定义修订与恢复契约。
@@ -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. |
@@ -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` 时必须设置本项。切勿在不可信客户端能访问服务端的环境中设置。为空时禁用。 |
@@ -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:<distribution-root>/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:<absolute-path>/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:<absolute-path>/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.
@@ -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。这些诊断不能证明每条持久化记录都能解密。
+107
View File
@@ -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<string, boolean>, 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');
+62 -16
View File
@@ -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<string>,
): Promise<Config> {
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<string, string | undefined>,
});
const resolvedCliConfig: ReturnType<typeof resolveCliGenerationConfig> =
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<string, string | undefined>,
});
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,
@@ -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',
@@ -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();
@@ -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<string>;
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,
@@ -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',
@@ -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',
@@ -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';
+33 -13
View File
@@ -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<void>;
}): Promise<HostedHarnessModelResult> {
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),
},
}
: {}),
},
};
});
@@ -262,6 +262,7 @@ const state = vi.hoisted(() => ({
storeOptions: [] as Array<Record<string, unknown>>,
ownershipReleases: [] as Array<Record<string, unknown>>,
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({
@@ -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<string, unknown> | 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<string, unknown>,
kind?: 'legacy-close',
): Promise<unknown> {
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;
@@ -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,
+7 -1
View File
@@ -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<T>(
cwd: string;
signal: AbortSignal;
scope: ManagedHookModelScope;
modelConfig?: HostedModelConfigReader;
},
run: (runner: HostedPromptHookRunner) => Promise<T>,
): Promise<T> {
@@ -143,6 +145,7 @@ export async function runHostedHookOperation<T>(
skipWorkspaceSettings: true,
workspaceTrusted: false,
});
const sessionModelConfig = await input.modelConfig?.();
const config = await loadCliConfig(
settings.merged,
{
@@ -159,7 +162,10 @@ export async function runHostedHookOperation<T>(
undefined,
undefined,
true,
{ toolInvocationGuard: () => ({ allowed: false }) },
{
toolInvocationGuard: () => ({ allowed: false }),
...(sessionModelConfig ? { modelConfiguration: sessionModelConfig } : {}),
},
);
try {
await config.initialize({
@@ -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:[email protected]/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);
});
});
@@ -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<HostedModelConfig>;
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<string, unknown> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new HostedModelUnavailableError();
}
return value as Record<string, unknown>;
};
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<string, number> = {};
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 } : {}),
};
}
@@ -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(
@@ -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<HostedHarnessModelResult> {
// 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,
@@ -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([
@@ -206,6 +206,12 @@ export class LlmContentGenerator implements ContentGenerator {
configSamplingParams?.frequency_penalty,
'frequencyPenalty',
),
seed: getParameterValue<number>(
typeof configSamplingParams?.['seed'] === 'number'
? configSamplingParams['seed']
: undefined,
'seed',
),
thinkingConfig: getParameterValue(
this.buildThinkingConfig(request.model, requestConfig.thinkingConfig),
'thinkingConfig',
@@ -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<typeof fetch>(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<typeof fetch>(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) => {
@@ -183,6 +183,7 @@ export interface HttpManagedSessionStores {
kind?: 'legacy-close' | `configuration:${string}`,
): Promise<void>;
approvalSelection(): Promise<ManagedSessionApprovalSelection | undefined>;
modelConfig(expectedDigest: string, kind?: 'legacy-close'): Promise<unknown>;
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<unknown> {
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<Uint8Array>;
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
> {
+52 -3
View File
@@ -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.
@@ -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"); }
@@ -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<Map<String, Object>> 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())
@@ -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);
@@ -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。"
@@ -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; }
@@ -32,6 +32,18 @@ public interface SessionAdmissionStore {
String agentId, String requestedRevision, String title,
List<Map<String, Object>> input, String payloadDigest);
public default Admission insertSessionCommand(String tenantId, String actorId,
String operation, String idempotencyKey, String requestDigest,
String agentId, String requestedRevision, String title,
List<Map<String, Object>> 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
@@ -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<ManagedExtensionPackages.Pin> extensionPins,
Set<String> 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<ManagedExtensionPackages.Pin> extensionPins;
private Set<String> 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);
}
}
}
@@ -73,7 +73,13 @@ public final class ApiModels {
Boolean stream,
JsonNode workspace,
@Size(max = 256) List<@NotNull Map<String, Object>> 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<InputBlock> input,
Map<String, Object> metadata, Boolean stream, JsonNode workspace, List<Map<String, Object>> skills,
List<Pin> extensionPins, JsonNode worktree) {
this(agentId, agentRevision, input, metadata, stream, workspace, skills, extensionPins, worktree, null);
}
public CreateSessionRequest(String agentId, String agentRevision, List<InputBlock> input,
Map<String, Object> metadata, Boolean stream, JsonNode workspace, List<Map<String, Object>> skills,
List<Pin> extensionPins) {
@@ -454,7 +460,13 @@ public final class ApiModels {
Map<String, Object> metadata,
JsonNode workspace, String approvalMode, Boolean planMode, String modelId,
@Size(max = 256) List<@NotNull Map<String, Object>> 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<InputBlock> input, Map<String, Object> metadata, JsonNode workspace, String approvalMode,
Boolean planMode, String modelId, List<Map<String, Object>> skills, List<Pin> 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<InputBlock> input, Map<String, Object> metadata, JsonNode workspace, String approvalMode,
Boolean planMode, String modelId, List<Map<String, Object>> skills, List<Pin> extensionPins) {
@@ -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<String> FIELDS = Set.of("protocol", "model", "base_url", "api_key", "generation_config");
private static final Set<String> PROTOCOLS = Set.of("openai", "openai-responses", "anthropic", "gemini");
private static final Set<String> 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<String, Object> value;
private SessionModelConfig(Map<String, Object> 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<String, Object>();
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<String, Object>();
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<String, Object> 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.");
}
}
@@ -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);
}
}
@@ -22,6 +22,7 @@ public interface ManagedAgentSettings {
public List<String> getDefinitionToolProfiles();
public List<String> getDefinitionModels();
public List<? extends ModelOption> getModelCatalog();
public default List<String> getModelConfigBaseUrls() { return List.of(); }
public interface ModelOption {
public String getModelId();
public String getLabel();
@@ -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,
@@ -200,6 +200,13 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c
String idempotencyKey, String agentId, String agentRevision,
String title, Map<String, Object> metadata,
List<InputBlock> 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<String, Object> metadata, List<InputBlock> blocks,
com.alibaba.qwen.code.managedagent.model.SessionModelConfig modelConfig) {
validateIdempotencyKey(idempotencyKey);
List<Map<String, Object>> 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<String, Object> metadata,
@@ -393,7 +412,20 @@ public class ManagedAgentService implements com.alibaba.qwen.code.managedagent.c
List<Map<String, Object>> skills, String modelId,
List<com.alibaba.qwen.code.managedagent.contracts.extensions.ManagedExtensionPackages.Pin> 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<String, Object> metadata,
List<InputBlock> blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode,
List<Map<String, Object>> skills, String modelId,
List<com.alibaba.qwen.code.managedagent.contracts.extensions.ManagedExtensionPackages.Pin> 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
@@ -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<Map<String, Object>> 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<Map<String, Object>> 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<String> 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=?",
@@ -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");
}
@@ -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<TurnSummary> turnSummaryMapper;
@@ -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<Map<String, Object>> 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) {
@@ -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<SecretProtector> secrets;
private final ManagedAgentSettings settings;
public ManagedSessionModelConfigs(@Qualifier("managedJdbcTemplate") JdbcTemplate jdbc,
@Qualifier("managedAgentObjectMapper") ObjectMapper json,
ObjectProvider<SecretProtector> 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.");
}
}
@@ -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"))
@@ -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(
@@ -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,
@@ -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<Object> 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,
@@ -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)
@@ -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);
}
@@ -83,6 +83,7 @@ public class ManagedAgentProperties
List.of("hosted-workspace-files/1");
private List<String> definitionModels = List.of();
private List<ModelOption> modelCatalog = List.of();
private List<String> modelConfigBaseUrls = List.of();
private String trustedActorHeader = "";
// The account the server runs as; preflight checks file access as it.
private String serviceUser = "";
@@ -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");
}
}
@@ -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.<Map<String, Object>>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:[email protected]/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));
}
}
@@ -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,
@@ -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");
@@ -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<Method> 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();
@@ -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();
@@ -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:-
@@ -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();
@@ -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
@@ -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
@@ -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
@@ -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
@@ -15,6 +15,7 @@ public final class CreateHarnessSession {
private final Long approvalTimeoutMs;
private final Long captureBytes;
private final Map<String, Object> agentDefinition;
private final String modelConfigDigest;
private final Map<String, Object> sessionSkillsRef;
private final Map<String, Object> initialSelection;
private final List<Map<String, Object>> 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<String, Object> agentDefinition;
private String modelConfigDigest;
private Map<String, Object> sessionSkillsRef;
private Map<String, Object> initialSelection;
private List<Map<String, Object>> 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<Map<String, Object>> value) {
this.mcpServers = value.stream().map(Map::copyOf).collect(Collectors.toUnmodifiableList());
return this;
@@ -277,6 +277,7 @@ public final class HostedHarnessClient implements AutoCloseable {
throw new IllegalArgumentException("request must not be null");
}
ensureOpen();
requireModelConfigurationCapability(request.toJson());
HttpResponse<HttpSupport.Body> raw;
try {
raw = send("/session", "POST", request.toJson(), null);
@@ -324,6 +325,13 @@ public final class HostedHarnessClient implements AutoCloseable {
}
}
private void requireModelConfigurationCapability(Map<String, Object> 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");
@@ -18,6 +18,7 @@ public final class LoadHarnessSession {
private Map<String, Object> lifecycleAuthority;
private int lifecycleProtocolVersion = 1;
private Map<String, Object> agentDefinition;
private String modelConfigDigest;
private Map<String, Object> sessionSkillsRef;
private Map<String, Object> initialSelection;
private List<Map<String, Object>> 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<String, Object> 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);
}
@@ -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();
@@ -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. */