Merge origin/main into fix/managed-agent-java-gate-flakes

Resolve the openapi description conflict: keep main's v1.64-v1.66 entries,
retain this branch's v1.63 lifecycle-recovery note, and renumber the branch's
own note to v1.67. Bump info.version to 1.67.0 so the contract advances past
main's 1.66.0, which failed "API contract version uniqueness (latest main)".

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmveniuyph4
This commit is contained in:
jinjing.zzjandQwen-Coder committed 2026-10-19 10:53:49 +08:00
commit e62a4c734f
71 files changed
+4585 -172

No files matched your search

+12 -8
View File
@@ -683,13 +683,17 @@ jobs:
# liveness gates to 6083 s, which moves Verify to 115 minutes, the
# FG13d dropped-reply cancel cell to 6233 s, still inside those 115
# minutes, and the FG12b store-failure class to 6353 s; the FG12f
# stream-gap class adds 242 s, so the fork moves to 6585 s and Verify to
# 120 minutes, which wraps its fork + 600 s plus margin; the Broker
# fault gates keep 10, and admission plus O4 keep per-fork ceilings) plus
# ~13 minutes for the uncapped setup steps (Checkout.. cgroup
# delegation, the report checks): 200 + 13. The failover arms run in
# stream-gap class adds 242 s, so the fork moves to 6585 s; the FG13a
# execute lost-reply cell adds 150 s and the FG13a status lost-reply
# cell another 150 s, moving the fork to 6885 s and Verify to 125
# minutes; the FG11c Harness-crash method (144.8 s measured) is budgeted
# at 550 s, so the fork moves to 7435 s and Verify to 134 minutes, which
# wraps its fork + 600 s plus margin; the Broker fault gates
# keep 10, and admission plus O4 keep per-fork ceilings) plus ~13
# minutes for the uncapped setup steps (Checkout.. cgroup delegation,
# the report checks): 214 + 13. The failover arms run in
# hosted-failover-e2e.
timeout-minutes: 213
timeout-minutes: 227
services:
mysql:
image: 'mysql:8.4.6'
@@ -778,8 +782,8 @@ jobs:
# fork leaves no per-test failure lines, so the main-CI failure
# analyzer can only file an undiagnosable per-commit issue (#13503).
# hosted-process-ci.test.js pins this ordering; the fork budget is
# 6585 s, so the step wraps to fork + 600 s plus margin.
timeout-minutes: 120
# 7435 s, so the step wraps to fork + 600 s plus margin.
timeout-minutes: 134
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
MYSQL_PORT: "${{ job.services.mysql.ports['3306'] }}"
@@ -0,0 +1,66 @@
# Hosted Hook execute reply-loss gates FG13a
[English](2026-10-18-hosted-hook-execute-reply-loss-gates-a.md) | [简体中文](2026-10-18-hosted-hook-execute-reply-loss-gates-a.zh-CN.md)
## Problem and scope
Issue #14449 tracks FG13, the Stage F fault-gate round for the Hosted Hook
domains. FG13c covered the owner-restart matrix and FG13d the cancellation
and timeout arms; this slice starts **FG13a, lost replies**, with the arm a
command Hook pays first: the reply to `hook-execute` is lost after the
worker admitted the operation.
The dispatch reply is the one place where a lost answer could look like a
lost call. The worker already owns the receipt, so the retry must resolve
the same operation through the owner's status instead of dispatching a
second command.
## Decisions
1. **Reuse the FG13d rig and broker proxy.** The class borrows the Hook-only
definition, the packaged Harness, the durable worker and the forwarding
proxy from the FG13d stack base; only the `hook-execute` control reply is
dropped, and every other call passes through.
2. **The retry is a status poll, not a second execute.** The Harness falls
back to `hook-status` on the record's own `runtimeSessionId` when the
dispatch reply is lost, which is what keeps the physical call at one.
3. **Two cells, one fixture each.** Both hold a solo command for one second
and allow; a dropped dispatch reply must resolve through status, and a
dropped status reply must stay unknown until a later poll of the same
owner settles it. Neither may add a second physical call.
## Case
| Case | Inject | Expect |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `execute-reply-lost` | A `PreToolUse` command Hook runs; the broker proxy drops the `hook-execute` control reply after the worker admitted it. | Exactly one physical call; the retry resolves the same operation through the owner's status; the record settles `settled` with `outcome=success`; the allow reaches the model once; the proxy log records the drop. |
| `status-reply-lost` | The same solo command; the broker proxy drops the reply to a `hook-status` poll after the dispatch. | The dropped poll cannot prove the end, so the record first reports `recovery_blocked` / `outcome_unknown`; a later poll of the same owner settles it `success`, with exactly one physical call and the drop in the proxy log. |
## Mutation witness
Each witness must turn the cell red before the slice merges.
- `execute-reply-lost`: break the exercised fallback — make the dropped
dispatch reply answer `outcome_unknown` instead of looking up the receipt
(for example, remove the `hook-status` fallback in `fire`). The record
then never settles and the `settled` assertion fails. The execute
operation-id deduplication is a separate witness: explicitly replay the
`hook-execute` operation.
- `status-reply-lost`: drop the honest unknown commit in the `fire` loop
(the branch that records `recovery_blocked` / `outcome_unknown` when the
poll cannot prove the end). The record stays `running`, so the state
assertion fails.
## Non-goals
The rest of FG13a — `hook-catalog`, `hook-cancel`, the store acks of the
`dispatch_started` and result commits, and a lost HTTP Hook response — stays
on #14449. Also out: FG13b, FG13e, FG13f, local non-managed
Hooks, Windows/macOS and #13133's design items.
## Validation
The full `hosted-harness-mysql` lane on Linux with MySQL 8.4 and a delegated
cgroup v2 root is the evidence. Checkstyle, the CI fork-budget guard and the
pinned class list move with the new class. A skipped cell is not a passing
gate.
@@ -0,0 +1,53 @@
# Hosted Hook execute 丢回复门禁 FG13a
[English](2026-10-18-hosted-hook-execute-reply-loss-gates-a.md) | [简体中文](2026-10-18-hosted-hook-execute-reply-loss-gates-a.zh-CN.md)
## 问题与范围
Issue #14449 跟踪 Hosted Hook 两个域的 Stage F 故障门禁轮次。FG13c 覆盖 owner
重启矩阵,FG13d 覆盖取消与超时;本切片开始 **FG13a(丢回复)**,先补命令 Hook
最先遇到的一臂:worker 已接收操作之后,`hook-execute` 的回复丢失。
派发回复是唯一会让“丢失的应答”看起来像“丢失的调用”的地方。回执已经在 worker
手里,因此重试必须通过 owner 的状态解析同一个操作,而不是再派发一条命令。
## 决策
1. **复用 FG13d 的装置与 broker 代理。** 新类沿用只含 Hook 的定义、打包 Harness、
durable worker 与转发代理;只丢弃 `hook-execute` 控制调用的回复,其余请求直通。
2. **重试走状态轮询,而不是第二次 execute。** 派发回复丢失时,Harness 会按记录
自身的 `runtimeSessionId` 回落到 `hook-status`,这正是物理调用保持一次的原因。
3. **两格、各自一套夹具。** 两格都让 solo 命令保持 1 秒后放行;派发回复丢失必须
通过 status 解析,status 回复丢失必须先保持未知,直到同一 owner 的后续轮询
把它结算。两者都不得增加第二次物理调用。
## 用例
| 用例 | 注入 | 期望 |
| -------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `execute-reply-lost` | `PreToolUse` 命令 Hook 运行中;broker 代理在 worker 已接收后丢弃 `hook-execute` 的回复。 | 恰好一次物理调用;重试通过 owner 状态解析同一操作;记录结算 `settled` 且 `outcome=success`;放行结果只到达模型一次;代理日志记录这次丢弃。 |
| `status-reply-lost` | 同样的 solo 命令;broker 代理在派发后丢弃一次 `hook-status` 轮询的回复。 | 丢失的轮询无法证明结束,因此记录先落 `recovery_blocked` / `outcome_unknown`;随后对同一 owner 的轮询把它结算为 `success`,物理调用仍恰好一次,代理日志记录这次丢弃。 |
## 变异见证
每个见证必须在合入前让自己的用例变红。
- `execute-reply-lost`:改破坏这条格真正走到的兜底——让丢失的派发回复直接得到
`outcome_unknown`,而不是回落到回执查询(例如去掉 `fire` 里的 `hook-status`
兜底)。记录因此永不结算,`settled` 断言失败。execute 的 operation-id 幂等
是另一个见证:显式重放 `hook-execute`。
- `status-reply-lost`:去掉 `fire` 循环里如实记录 unknown 的提交分支(轮询无法
证明结束时写 `recovery_blocked` / `outcome_unknown` 的那一支)。记录会停在
`running`,状态断言失败。
## 非目标
FG13a 其余部分——`hook-catalog`、`hook-cancel`、`dispatch_started` 与结果提交的
store ack、以及 HTTP Hook 响应丢失——仍留在 #14449。同样不在范围:FG13b、
FG13e、FG13f、本地非托管 Hook、Windows/macOS,以及 #13133 的设计项。
## 验证
在 Linux、MySQL 8.4 与委派 cgroup v2 根上跑完整的 `hosted-harness-mysql`
车道即证据。Checkstyle、CI fork 预算守卫与 pin 名单随新类一起移动。被跳过的用例
不算通过。
@@ -16,9 +16,9 @@ The whole-Spring/Broker restart cells stay out. A whole-Spring restart replaces
## Gate ledger
| Case | Injection | Expect |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness-kill-releasing` | SIGKILL the packaged Harness after `releaseState: releasing` is committed, while its `mcp-release` dispatch is held at the Broker proxy | The replacement loads the same `runtimeSessionId` and the same lifecycle claim; it looks up `${configurationId}:release` under the original id and finishes it; exactly one `mcp-release`; no second `mcp-configure` or `mcp-invoke`; the final `releaseState` is `released` only after the wire answer is settled, never a fabricated settled |
| Case | Injection | Expect |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness-kill-releasing` | SIGKILL the packaged Harness after `releaseState: releasing` is committed, while its `mcp-release` dispatch is held at the Broker proxy | The replacement loads the same `runtimeSessionId` and the same lifecycle claim; it looks up `${configurationId}:release` under the original id and finishes it; exactly one `mcp-release`; no second `mcp-configure` or `mcp-invoke`; the final `releaseState` is `released` only after the wire answer is settled, never a fabricated settled. The conclusive `mcp-status` reply is held inside the proxy: while it is parked the record stays `releasing` and `settle_resources` is still unanswered; `drained`/`released` appear only after it is delivered. |
## Decisions
@@ -27,6 +27,7 @@ The whole-Spring/Broker restart cells stay out. A whole-Spring restart replaces
3. **Hold the release dispatch, kill after `releasing`.** The proxy parks `mcp-release` before forwarding, the test asserts the database record is `releasing` and the dispatch is in flight, then SIGKILLs the Harness. Releasing the hold lets the original dispatch reach the Runtime and register the operation, so the replacement's lookup has something to settle.
4. **Reload with the original claim.** The load carries `lifecycleProtocolVersion: 2` and `lifecycleAuthority` = the original `{operationId, claimGeneration}`. The replacement retries `settle_resources` (bounded) while the release is `running`; a settled lookup commits `drained`/`released`.
5. **The gate asserts the wire, then the state.** One `mcp-release` for `${configurationId}:release`, a settled `mcp-status` lookup for the same id (the bounded retry may issue more than one), no `mcp-configure`/`mcp-invoke` after the baseline, one `mcp.start`, and `runtimeSessionId` unchanged. `releaseState: released` is asserted only after that sequence. If the release never settles the route answers 503 and the record stays `releasing`/`drained`; the bounded retry fails rather than accepting a fabricated `released`.
6. **The conclusive reply is held, not reconstructed.** A settled lookup in the wire only shows that the answer existed, not that the state waited for it. The proxy therefore parks the settled `mcp-status` answer after the Broker produced it; while it is parked the case asserts the record is still `releasing` and the close route has not answered, then releases the answer and expects the close to complete with `released`. The release dispatch itself is not a witness: it legally answers `running`.
## Fixture design
@@ -39,13 +40,14 @@ The whole-Spring/Broker restart cells stay out. A whole-Spring restart replaces
No new class, so the six fork accounts stay unchanged: the class `@Timeout` 600 s, the shared healthy sum and CI fork (current values in `hosted-process-ci.test.js`) and its literal pin, the local `hosted-mcp-gates` profile (`**/HostedMcp*IT.java`), the workflow comments, and the artifact root `target/fg12-cases/`.
Measured on JDK 21 + MariaDB 10.11: the new case 6.7–9.1 s; the six-case class 33.0–50.5 s (latest 33.9 s). FG12c's 49 s allowance was already budgeted at 2× (~98 s), so the +9 s case stays inside that allowance; the shared healthy sum and the CI fork do not move. The artifact root already covers `fg12d`, so no upload or meta-test edit is needed.
Measured on JDK 21 after the held reply was added: the six-case class is 32–37 s on MySQL 8.0.46 (two runs) and 40.5 s on MariaDB 10.11, with the release case at 6.0–7.0 s. FG12c's 49 s allowance was already budgeted at 2× (~98 s), so the class stays inside that allowance; the shared healthy sum and the CI fork do not move. Both schemas validate on current `main` after #14618. The artifact root already covers `fg12d`, so no upload or meta-test edit is needed.
## Acceptance
- `mvn -q -DskipTests test-compile checkstyle:check` clean.
- `HostedMcpRestartFaultGatesIT` 6/6 green on JDK 21 + MariaDB; the new case proves its injection (`releasing` in the database and a held `mcp-release`) before the kill.
- `HostedMcpRestartFaultGatesIT` 6/6 green on JDK 21 + MySQL 8.0.46 and MariaDB 10.11; the new case proves its injection (`releasing` in the database and a held `mcp-release`) before the kill.
- The `fg12d` evidence shows one `mcp-release`, at least one settled `mcp-status` for the same id, one `mcp.start` and one `mcp.exit`, `released`, and no second configure or invoke.
- While the settled `mcp-status` reply is parked, the record is still `releasing` and `settle_resources` has not answered; both advance only after the reply is delivered.
- No TS mutation witness in this slice (per the FG12d brief). Remaining risk: the green depends on the release settling within the 60×1 s retry window; a slower Runtime fails the gate rather than passing it with an unsettled record.
- The whole-Spring/Broker restart stays out while `WorkspaceStorageGuard` refuses a held storage; it is not faked with a second rig.
@@ -16,9 +16,9 @@ issue #14447 是 #12380 的 Stage F 轮次 FG12:为 Hosted MCP 的两个域 `m
## 门禁账本
| Case | 注入 | 期望 |
| ------------------------ | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness-kill-releasing` | 在 `releaseState: releasing` 已提交、且 `mcp-release` 派发被 Broker 代理持住时 SIGKILL 打包 Harness | 替换 Harness 加载同一 `runtimeSessionId` 与同一 lifecycle claim;按原 id 查询并完成 `${configurationId}:release`;恰好一次 `mcp-release`;没有第二次 `mcp-configure` 或 `mcp-invoke`;最终 `releaseState` 只在线上应答 settled 之后才为 `released`,绝不伪造 settled |
| Case | 注入 | 期望 |
| ------------------------ | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness-kill-releasing` | 在 `releaseState: releasing` 已提交、且 `mcp-release` 派发被 Broker 代理持住时 SIGKILL 打包 Harness | 替换 Harness 加载同一 `runtimeSessionId` 与同一 lifecycle claim;按原 id 查询并完成 `${configurationId}:release`;恰好一次 `mcp-release`;没有第二次 `mcp-configure` 或 `mcp-invoke`;最终 `releaseState` 只在线上应答 settled 之后才为 `released`,绝不伪造 settled。结论性的 `mcp-status` 应答被持在代理内:它被扣住期间记录保持 `releasing`、`settle_resources` 尚未应答;只有应答交付之后才出现 `drained`/`released`。 |
## 决策
@@ -27,6 +27,7 @@ issue #14447 是 #12380 的 Stage F 轮次 FG12:为 Hosted MCP 的两个域 `m
3. **持住 release 派发,在 `releasing` 之后杀进程。** 代理在转发前停住 `mcp-release`;测试先断言数据库记录为 `releasing` 且派发在飞,再 SIGKILL Harness。放开 hold 后,原派发到达 Runtime 并登记该操作,替换 Harness 的查询才有可结算的对象。
4. **带原 claim 重载。** load 携带 `lifecycleProtocolVersion: 2` 与原 `{operationId, claimGeneration}`;替换 Harness 在 release 仍为 `running` 时有界重试 `settle_resources`,settled 查询后提交 `drained`/`released`。
5. **先断言线上序列,再断言状态。** 恰好一次 `${configurationId}:release` 的 `mcp-release`、至少一次同 id 且 settled 的 `mcp-status`(有界重试可能发多次)、基线之后没有 `mcp-configure`/`mcp-invoke`、一次 `mcp.start`、`runtimeSessionId` 不变。只有该序列成立后才断言 `releaseState: released`。若 release 始终不 settle,路由回答 503,记录停在 `releasing`/`drained`;有界重试会失败,而不是接受伪造的 `released`。
6. **结论性应答用 hold 见证,不做事后推断。** 线上出现一次 settled 查询只能说明该应答存在过,不能说明状态等到了它。代理因此在 Broker 产出 settled `mcp-status` 之后扣住这条应答;扣住期间测试断言记录仍为 `releasing`、close 路由尚未应答,放开后才期待 close 完成且记录为 `released`。release 派发本身不能作为见证:它合法地先回答 `running`。
## 夹具设计
@@ -39,13 +40,14 @@ issue #14447 是 #12380 的 Stage F 轮次 FG12:为 Hosted MCP 的两个域 `m
不新增类,因此六处 fork 账目不变:类 `@Timeout` 600 s、共享 healthy 与 CI fork(当前值见 `hosted-process-ci.test.js`)及其字面钉点、本地 `hosted-mcp-gates` profile(`**/HostedMcp*IT.java`)、workflow 注释、artifact 根 `target/fg12-cases/`。
JDK 21 + MariaDB 10.11 实测:新格 6.7–9.1 s;六格整类 33.0–50.5 s(最新 33.9 s)。FG12c 的 49 s 余量本就按 2×(约 98 s)预算,新增的 +9 s 仍在该余量内;共享 healthy 和 CI fork 均不动。artifact 根已覆盖 `fg12d`,无需改上传路径或元测试。
JDK 21 实测(加入应答 hold 之后):六格整类在 MySQL 8.0.46 上 32–37 s(两次)、在 MariaDB 10.11 上 40.5 s,release 格 6.0–7.0 s。FG12c 的 49 s 余量本就按 2×(约 98 s)预算,整类仍在该余量内;共享 healthy 和 CI fork 均不动。当前 `main`(含 #14618)两种 schema 均可校验。artifact 根已覆盖 `fg12d`,无需改上传路径或元测试。
## 验收
- `mvn -q -DskipTests test-compile checkstyle:check` 干净。
- `HostedMcpRestartFaultGatesIT` 在 JDK 21 + MariaDB 上 6/6 绿;新格在杀进程前先自证注入(数据库 `releasing` 与在飞的 `mcp-release`)。
- `HostedMcpRestartFaultGatesIT` 在 JDK 21 + MySQL 8.0.46 与 MariaDB 10.11 上 6/6 绿;新格在杀进程前先自证注入(数据库 `releasing` 与在飞的 `mcp-release`)。
- `fg12d` 证据显示一次 `mcp-release`、至少一次同 id 且 settled 的 `mcp-status`、一次 `mcp.start` 与一次 `mcp.exit`、最终 `released`,且没有第二次 configure/invoke。
- settled 的 `mcp-status` 应答被扣住期间,记录仍为 `releasing`、`settle_resources` 尚未应答;两者只在应答交付后才推进。
- 本切片不跑 TS 变异见证(按 FG12d 任务书)。剩余风险:绿依赖 release 在 60×1 s 重试窗口内 settle;更慢的 Runtime 会让门禁变红,而不是带着未结算记录变绿。
- 整仓 Spring/Broker 重启在 `WorkspaceStorageGuard` 拒绝被占用 storage 期间继续拆出,不用第二套 rig 伪造。
@@ -0,0 +1,158 @@
# Hosted automation fault gates FG11c, slice 1: Harness process crashes
[English](2026-10-19-automation-fault-gates-c.md) | [简体中文](2026-10-19-automation-fault-gates-c.zh-CN.md)
## Problem and scope
Issue #14446 is Stage F round FG11 of #12380. FG11c gates process
crashes. This slice covers the **Harness half**: a SIGKILL at each of a
run's three revisions, on the packaged stack. The Spring half — kill
after the occurrence row is `firing` and before or after the answer, and
two scanners racing the lease — needs an out-of-process Spring fixture
and ships as slice 2. The live-run reconcile cell is already pinned on
the real stack by FG11a's `reconcile-reply` (#14534) and at the route
level by #14480; deleting `session.active === undefined &&` turns that
case red, so this slice does not duplicate it.
The property under gate: a Harness killed at any point in a run's three
revisions either completes on re-drive or settles honestly exactly once.
No second run, no second accepted input, no second model Turn. A crash
after `turn.settled` settles from the journal, not as
`failed`/`outcome_unknown`.
Three cases ship, all in one boot, in
`fg11cHarnessCrashesNeverRunTwiceOnMySql`:
| Case | Injection | Expect |
| --------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `claim-crash` | SIGKILL between revision 1 (the claim) and revision 2 (the dispatch), which the Harness was holding before the store | The scanner re-drives the same claim; the replay completes revision 2 on the reloaded Session. One run chain (`:1`, `:2`, `:3`), one accepted input, one model Turn, occurrence `fired` |
| `turn-crash` | SIGKILL with the wake turn's model call in flight. A later slot of the same every-minute definition then comes due | The later slot's reconcile loads the Session; the aftermath settles the run once as `failed`/`outcome_unknown`. One settle revision, one accepted input, one model Turn; the turn is never re-run |
| `settled-crash` | SIGKILL after `turn.settled`, before revision 3 reached the store | On reload the open path's `reconcileRuns` settles the run from the journal's `turn.settled` as `completed`, exactly once. One settle revision, one accepted input, one model Turn |
Every case ends with the tenant holding no `unknown` occurrence.
## Current state
- **Revisions.** `fire` commits the claim (revision 1) and, in one
transaction with the run's input and wake, the dispatch (revision 2)
(`hosted-automation-session.ts`). `settleRun` commits revision 3.
- **The replay.** A re-driven fire whose run is `intent` completes the
dispatch; one whose run is `dispatch_started` answers the committed run
(`fire`, the `existing !== undefined` branch).
- **The settle.** `settleRunUnserialized` reads the journal's own
`turn.settled` first and the projected turn result second, then commits
revision 3 under `settleRun`. `reconcileRuns` sweeps every dispatched
run whose turn settled when the Session opens.
- **The crash aftermath.** A wake turn that died before its settle leaves
a prior attempt in the transcript. The pump classifies it `recovery`,
and `wakeAftermath` settles it with `settleRunFailedUnknown` — `failed`
with `outcome_unknown` — once, cancelling parked executions and
consuming the input.
- **The writer lease.** The restarted Harness cannot take the Session
over until the dead writer's lease lapses. This method sets it to 15 s.
## Gate ledger
Ground truth is the Session journal (`ma_session_journal_tx` commands and
event ids), the run record (`ma_session_record.task_state`), the
`ma_schedule_occurrence` row and the model stub's prompts. The store
relay records every commit it saw; a crash case checks that the armed
commit was held and never committed.
## Fixture design
- **Store relay.** This method places a `HostedLifecycleRelay` between the
Harness and the Session Store. Its `before` hook parks the one armed
commit and, after the Harness is killed, drops it: the revision never
reached the store.
- **Cold restart.** The Harness is SIGKILLed and started on the same port
with a fresh HOME. The dead writer's 15 s lease is waited out by the
case's later slot, as in FG11b slice 2.
- **The later slot.** `turn-crash` and `settled-crash` use an every-minute
definition: a later slot's count-based refusal makes the scanner ask
`reconcile_run`, which loads the Session and runs the settle paths. The
definition is retired after the target run settles.
- **The model hold.** The stub holds only the manual occurrence's wake
turn (`Occurrence: manual:`), so the later scheduled slot still runs
while the crashed one is reconciled.
## Findings
- **Re-drive counts are not stable.** `claim-crash` can meet transient
409s before the re-drive is accepted: the reloaded Harness's first
loads answer `managed_session_writer_conflict` until the dead lease
lapses, and the connector can answer `Hosted Harness generation
changed`. The case therefore asserts that the occurrence was fired at
least once, never an exact re-drive count; the journal's one run chain
is the invariant.
- **The reconcile may fail once and be retried by the next slot.**
`turn-crash` and `settled-crash` record one `automation reconcile
failed ... Hosted Harness generation changed` WARN in the measured
runs; the next due slot re-drives the repair and the run settles. That
is the #14116 shape the issue names, not a lost slot.
- **`settled-crash` needs no repair from the reconcile route, but the
route still reconciles runs.** Its input was consumed before revision 3,
so `firstPendingWakeInput` is undefined and the route answers
`repaired: false`; the same load still calls `reconcileRuns()`, so
either the route or the open path commits revision 3.
## Mutation witnesses
Each witness has been run against its own case alone and restored byte
for byte; the table records the expected red.
| Witness | Mutation | Expected red at |
| ------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| c1 | A replay of an `intent` run answers the committed run without dispatching | `claim-crash`: revisions stay `:1`; the run never completes |
| c2 | `wakeHasPriorAttempt` always answers false | `turn-crash`: the pump re-runs the turn instead of settling it, so the run is not `failed` and a second model Turn appears |
| c3 | `HostedAutomationSession.reconcileRuns()` returns early | `settled-crash`: revision 3 never commits and the run stays `running` |
**c3 must target the method, not one call site.** The mutation skipped only
the lifecycle handler's call (`POST /session/:id/lifecycle`,
`hosted-harness-session.ts:6206`) and `settled-crash` stayed green (39.6 s).
The surviving path is the session-open call at
`hosted-harness-session.ts:5946`, which runs right after the Session is
registered and which `POST /session/:id/load` reaches through the `open`
helper: the crashed Harness is re-driven by the next slot's `reconcile_run`,
that route loads the Session, and the load reconciles the runs there. The
`reconcile_run` route has no independent `reconcileRuns()` call of its own —
it runs the wake aftermath when needed and reads the run. Short-circuiting
`HostedAutomationSession.reconcileRuns()` turns `settled-crash` red with
`ConditionTimeoutException: expected "completed"` at the case's terminal
state assertion, after 216.5 s. The case gates whether a crashed run gets
reconciled at all, not one particular call site.
## Budget
The three cases measured 48 s, 124 s and 110 s locally on JDK 21 +
MySQL 8.0.46 when selected individually; the whole method measured
144.8 s in its acceptance run. The method is budgeted at 550 s (2×),
composed onto
`main`'s healthy sum: 6285 s → 6835 s, and the Hosted fork from 6885 s to
7435 s with the method's 600 s ceiling. The Verify step moves from 125 to
134 minutes, and the job ceiling from 218 to 227 minutes.
## Acceptance
- `mvn -DskipTests test-compile checkstyle:check` clean. That goal runs only
the default execution; CI also runs
`checkstyle:check@runtime-broker-checkstyle`, which reports 12 indentation
violations in `RuntimeBrokerService.java:602-614` that belong to `main`
(the file is byte-identical here, and `main`'s run 39154767029 fails the
same execution at the same lines). The upstream re-indent PR owns it; it
is outside this slice.
- All three cases green locally on JDK 21 + MySQL 8.0.46, each selected
with `-Dqwen.fg11c.case=<case>`, and every witness red before merge.
- `scripts/tests/hosted-process-ci.test.js` green with the fork budget
composed.
- The three cases share one `@Test`, so a first red names one case and the
cases after it never run. Re-run each with `-Dqwen.fg11c.case=<case>`.
- This design ships in both languages with matching structure.
## Open questions
- Whether a crashed run should be reconciled at boot rather than on a
later slot is #14116, out of scope.
- The Spring SIGKILL cells and the two-scanner lease race remain for
slice 2; they need an out-of-process Spring fixture.
@@ -0,0 +1,134 @@
# Hosted automation 故障门禁 FG11c,第 1 片:Harness 进程崩溃
[English](2026-10-19-automation-fault-gates-c.md) | [简体中文](2026-10-19-automation-fault-gates-c.zh-CN.md)
## 问题与范围
Issue #14446 是 #12380 的 Stage F 第 FG11 轮。FG11c 门控进程崩溃。本片覆盖
**Harness 侧**:在一个 run 的三个 revision 上各做一次 SIGKILL,跑在打包栈上。
Spring 侧 —— occurrence 行为 `firing` 后、应答之前或之后杀 Spring,以及两个
scanner 争 lease —— 需要 Spring 子进程夹具,作为第 2 片交付。存活 run 的
reconcile 格已经在真实栈上由 FG11a 的 `reconcile-reply`(#14534)钉住,路由层
由 #14480 钉住;删掉 `session.active === undefined &&` 会让那个用例变红,所以
本片不重复它。
门控的性质:Harness 在 run 的三个 revision 中任意一点被杀,要么在重新驱动时
补完,要么诚实地恰好结算一次。不得出现第二个 run、第二个被接受的 input、第二次
模型 Turn。`turn.settled` 之后崩溃的 run 必须按 journal 结算为 `completed`,而
不是 `failed`/`outcome_unknown`。
三个用例同一次启动,位于 `fg11cHarnessCrashesNeverRunTwiceOnMySql`:
| 用例 | 注入 | 期望 |
| --------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `claim-crash` | 在 revision 1(claim)与 revision 2(dispatch)之间 SIGKILL;revision 2 被 Harness 停在 store 之前 | scanner 重新驱动同一个 claim;replay 在重新加载的 Session 上补完 revision 2。一条 run 链(`:1`、`:2`、`:3`)、一个被接受的 input、一次模型 Turn,occurrence 记为 `fired` |
| `turn-crash` | 在 wake turn 的模型调用在飞时 SIGKILL;随后同一个每分钟定义的下一个 slot 到期 | 后续 slot 的 reconcile 加载 Session;aftermath 把 run 恰好结算一次为 `failed`/`outcome_unknown`。一条 settle revision、一个被接受的 input、一次模型 Turn;turn 绝不重跑 |
| `settled-crash` | 在 `turn.settled` 之后、revision 3 到达 store 之前 SIGKILL | 重新加载时 open 路径的 `reconcileRuns` 按 journal 的 `turn.settled` 把 run 结算为 `completed`,恰好一次。一条 settle revision、一个被接受的 input、一次模型 Turn |
每个用例结束时,租户下不存在 `unknown` occurrence。
## 当前状态
- **三个 revision。** `fire` 提交 claim(revision 1),并在与 run 的 input、wake
同一个事务里提交 dispatch(revision 2)(`hosted-automation-session.ts`)。
`settleRun` 提交 revision 3。
- **replay。** 重新驱动的 fire 遇到状态为 `intent` 的 run 时补完 dispatch;遇到
`dispatch_started` 的 run 时直接答复已提交的 run(`fire` 的
`existing !== undefined` 分支)。
- **结算。** `settleRunUnserialized` 先读 journal 自己的 `turn.settled`,再退回
投影出的 turn 结果,然后在 `settleRun` 下提交 revision 3。Session 打开时
`reconcileRuns` 会扫过每个已 dispatch 且 turn 已结算的 run。
- **崩溃善后。** 在结算前死亡的 wake turn 会在 transcript 留下一次 prior
attempt。pump 将其归类为 `recovery`,`wakeAftermath` 用
`settleRunFailedUnknown` 把它恰好结算一次 —— `failed` +
`outcome_unknown` —— 同时取消挂起的执行并消费 input。
- **写者租约。** 重启后的 Harness 要等死写者的租约过期才能接管 Session。本方法
把它设为 15 秒。
## 门禁账本
事实来源是 Session journal(`ma_session_journal_tx` 的 command 与 event id)、
run 记录(`ma_session_record.task_state`)、`ma_schedule_occurrence` 行,以及
模型桩收到的 prompt。store relay 记录它见到的每一次 commit;崩溃用例会检查被
arm 的那次 commit 确实被停住且从未提交。
## 夹具设计
- **store relay。** 本方法在 Harness 与 Session Store 之间放一个
`HostedLifecycleRelay`。它的 `before` 钩子停住被 arm 的那次 commit,并在
Harness 被杀后将其丢弃:该 revision 从未到达 store。
- **冷重启。** SIGKILL Harness,然后用新的 HOME 在同一个端口启动。死写者的
15 秒租约由用例的后续 slot 等待,做法与 FG11b 第 2 片一致。
- **后续 slot。** `turn-crash` 与 `settled-crash` 使用每分钟定义:后续 slot 的
计数型拒绝会让 scanner 调用 `reconcile_run`,从而加载 Session 并走结算路径。
目标 run 结算后退役该定义。
- **模型挂起。** 模型桩只挂起手动 occurrence 的 wake turn(`Occurrence:
manual:`),所以后续的定时 slot 仍能在崩溃 run 被 reconcile 期间运行。
## 发现
- **重新驱动次数不稳定。** `claim-crash` 在被接受前可能遇到瞬时 409:重启后的
Harness 在死租约过期前的首次 load 会答复
`managed_session_writer_conflict`,connector 还可能答复 `Hosted Harness
generation changed`。因此用例只断言该 occurrence 至少被 fire 过一次,绝不断言
精确的重驱次数;journal 里的一条 run 链才是真正的不变量。
- **reconcile 可能失败一次,由下一个 slot 重试。** 实测运行中,`turn-crash`
与 `settled-crash` 各记录一条 `automation reconcile failed ... Hosted
Harness generation changed` WARN;下一个到期 slot 会重新驱动修复,run 最终
结算。这正是 issue 点名的 #14116 形态,不是丢 slot。
- **`settled-crash` 的 input 无需 reconcile 路由「修复」,但路由仍会 reconcile run。**
它的 input 在 revision 3 之前已被消费,所以 `firstPendingWakeInput` 为
undefined,路由答复 `repaired: false`;同一次 load 仍会调用
`reconcileRuns()`,因此 revision 3 由路由与 open 路径任一方都能提交。
## 变异见证
每个见证都已单独对各自的用例运行,并逐字节恢复;下表记录预期变红处。
| 见证 | 变异 | 预期变红处 |
| ---- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| c1 | `intent` run 的 replay 直接答复已提交的 run,不再 dispatch | `claim-crash`:revision 停在 `:1`,run 永不完成 |
| c2 | `wakeHasPriorAttempt` 恒答复 false | `turn-crash`:pump 重跑 turn 而不是结算它,run 不是 `failed`,并出现第二次模型 Turn |
| c3 | `HostedAutomationSession.reconcileRuns()` 直接返回空 | `settled-crash`:revision 3 永不提交,run 停在 `running` |
**c3 只能打在方法上,不能只打某一个调用点。** 这次变异只跳过了 lifecycle
处理器里的调用(`POST /session/:id/lifecycle`,
`hosted-harness-session.ts:6206`),`settled-crash` 仍绿(39.6 秒)。存活的是
会话打开路径上的调用(`hosted-harness-session.ts:5946`):它紧接 Session
注册之后执行,`POST /session/:id/load` 经 `open` 辅助函数走到它;崩溃后的
Harness 由后续 slot 驱动 `reconcile_run`,该路由加载 Session,加载路径就在那里
reconcile 了 run。`reconcile_run` 路由本身没有独立的 `reconcileRuns()` 调用——
它按需跑 wake aftermath 并读取 run。把方法整体短路后 `settled-crash` 变红:
`ConditionTimeoutException: expected "completed"`,落在 `settledCrash` 的终态
断言上,耗时 216.5 秒。本用例门控的是「被崩掉的 run 会不会被 reconcile」这一
性质,不是某一个调用点。
## 预算
三个用例在本机 JDK 21 + MySQL 8.0.46 上单独选择时分别测得 48 秒、124 秒与
110 秒;整个方法在验收运行中测得 144.8 秒。方法按 550 秒(2×)计入预算,
叠加到 `main` 的健康总和:6285 秒 → 6835 秒;Hosted
fork 从 6885 秒升到 7435 秒(方法本身 600 秒上限)。Verify 步骤从 125 分钟升到
134 分钟,job 上限从 218 分钟升到 227 分钟。
## 验收
- `mvn -DskipTests test-compile checkstyle:check` 干净。注意它只跑默认
execution;CI 还跑 `checkstyle:check@runtime-broker-checkstyle`,该 execution
命中 `RuntimeBrokerService.java:602-614` 的 12 处缩进违规,属 `main` 自带
(文件与本分支逐字节一致,`main` 的 run 39154767029 同 execution 同处变红),
由上游的缩进修复 PR 收口,不在本片范围。
- 三个用例在本机 JDK 21 + MySQL 8.0.46 上全绿,每个都用
`-Dqwen.fg11c.case=<case>` 单独选择;合入前每个见证都必须变红。
- `scripts/tests/hosted-process-ci.test.js` 在预算叠加后全绿。
- 三个用例共用一个 `@Test`,首次变红只报出一个用例,其后的用例不会运行。用
`-Dqwen.fg11c.case=<case>` 逐个重跑。
- 本设计以中英双语交付,结构一致。
## 待定问题
- 崩溃的 run 是否应该在启动时 reconcile 而不是等后续 slot,是 #14116 的范围,
不在本片。
- Spring SIGKILL 格与两个 scanner 争 lease 留给第 2 片;它们需要 Spring 子进程
夹具。
@@ -0,0 +1,102 @@
# Managed Session static Skills
[English](2026-10-19-managed-session-skills.md) | [简体中文](2026-10-19-managed-session-skills.zh-CN.md)
## Problem and current state
Managed AgentDefinitions already publish bounded static Skill catalogs. Each
Session pins its definition revision and digest, and the Hosted Harness loads
the published bodies through its `skill` tool. Managed Web Shell does not query
this catalog or enable the composer's Skills menu. A leading `/name` is currently
ordinary prompt text, and Plan mode excludes the instruction-only tool.
## Scope and ownership
Expose the existing Session catalog and connect selection to actual loading for
Workspace-bound Sessions, including local and SSH Workspaces. The control plane
reads immutable definition data; the Harness loads instructions; Workspace tools
retain their existing Runtime ownership and approval policy. No filesystem Skill
discovery, installation, dynamic activation, argument expansion, model override,
MCP configuration, or file-reference support is added.
## Proposed changes
### Session catalog
Add `POST /api/agent/web-shell/v1/session-skills/query`, accepting the existing
Session request shape and returning `skills` containing only `name` and
`description`. This is a persisted Session-scoped read with the `READER_ACTOR`
rule: require an actor, visible tenant-scoped Session, and current Workspace read
grant before accessing its pinned definition. Reuse `pinnedContent` and the
existing Skill validator, including digest validation. Never resolve the latest
AgentDefinition or contact a Runtime. Built-in agents and definitions without
Skills return an empty catalog. Snapshot read-only child profiles also return an
empty catalog because their Harness does not expose Skills. Missing or
inconsistent pins fail visibly.
Keep OpenAPI, generated Web Shell types, the surface registry, and admission
contract tests synchronized. There is no schema migration or new durable state.
### Composer
The Java provider exposes an optional catalog reader. A Session-scoped hook loads
on opening the Skills menu, caches successful immutable results, aborts on scope
changes, and suppresses late results from another Session. Reuse ChatEditor's
existing Skills submenu, loading, error, empty states, and `/name` insertion.
Reopening after a failed request retries. New Sessions without an assigned
Session ID do not query a guessed definition. Keep other Workspace capabilities
controlled by their existing interfaces.
### Explicit invocation and loading
Before the first model request, recognize consecutive leading `/name` tokens
that exactly match the pinned catalog. Load distinct selected Skills as a pure
`skill` batch through the existing Hosted tool executor. Preserve the user's
prompt and attachments, journal the synthetic assistant calls and tool results,
and supply those results to the model. A published body must therefore reach
model context even if the model would otherwise answer without calling tools.
Unmatched slash text retains its current meaning. The remaining prompt is task
input, not Skill arguments or an activation configuration.
Run normal Session and prompt Hooks before loading. Reuse PreToolUse,
PostToolUse, batch Hooks, refusal behavior, and inline limits. Loading never runs
scripts, widens permissions, or calls the Runtime's filesystem. Recovery uses
the committed calls and results through the existing Skill recovery path;
resumed model and tool continuations must not synthesize another activation.
Allow `skill` in the public Plan read set while preserving pure-Skill batching
and all write, Shell, and plan-approval restrictions.
## Affected areas
Managed Agent API/service/model records and contract tests; Web Shell generated
API, Java client/provider, Session composer, and a catalog hook; Hosted Skill
catalog, model loop, Plan policy, and focused tests. This extends the existing
[static Skill design](2026-10-15-managed-agent-extension-config.md).
## Verification and acceptance
1. A Session created at revision 1 lists and loads revision 1 after revision 2
changes the catalog; a new Session sees revision 2.
2. Reader access succeeds, absent actors and unauthorized/cross-tenant or deleted
Sessions fail before catalog access, and empty catalogs have an explicit UI
state. No body or host path is returned in the menu response.
3. Menu selection prepends the invocation. The first deterministic model request
contains the selected body's tool result, the original task, and attachments.
4. Explicit invocations obey Hooks, deduplicate selected names, and remain
bounded to the published catalog. Normal prompts and model-selected Skill
calls retain their behavior.
5. Plan mode permits static instruction loading and continues to refuse writes
and Shell. Restore/recovery does not replay a completed activation.
6. Session switching, provider changes, request errors, and late replies cannot
expose a previous Session's Skills.
The executable plan and results live in
`.qwen/e2e-tests/managed-session-skills.md`. Run focused Java and TypeScript
tests, API generation, Java verification, root build/typecheck/bundle, changed
file lint/format checks, and two clean full-diff audits. Report environment and
process-test limits separately from verified behavior.
## Open questions
None for this slice. Publishing and installing new Skills remain administration
tasks outside the composer.
@@ -0,0 +1,82 @@
# Managed Session 静态 Skills
[English](2026-10-19-managed-session-skills.md) | [简体中文](2026-10-19-managed-session-skills.zh-CN.md)
## 问题与当前状态
Managed AgentDefinition 已能发布有界静态 Skill 目录。每个 Session 固定定义
revision 和 digest,Hosted Harness 通过 `skill` 工具加载已发布正文。Managed Web
Shell 尚未查询该目录或启用输入框的 Skills 菜单。开头的 `/name` 当前只是普通
提示词文本,Plan 模式也排除了这个只加载指令的工具。
## 范围与归属
展示已有 Session 目录,并为绑定 Workspace 的 Session 接通选择与实际加载,覆盖
本机和 SSH Workspace。控制面读取不可变定义,Harness 加载指令,Workspace 工具
保留既有 Runtime 归属和审批策略。不增加文件系统 Skill 发现、安装、动态激活、
参数展开、模型覆盖、MCP 配置或文件引用能力。
## 方案
### Session 目录
增加 `POST /api/agent/web-shell/v1/session-skills/query`,接收已有 Session 请求
形状,返回 `skills`,每项只含 `name` 和 `description`。这是持久 Session 作用域
的读取,采用 `READER_ACTOR` 规则:先要求 actor、租户内可见 Session 和当前
Workspace 读取授权,再访问固定定义。复用 `pinnedContent` 和现有 Skill 校验,
包含 digest 校验。绝不查询最新 AgentDefinition,也不连接 Runtime。内置 Agent
和未发布 Skills 的定义返回空目录。快照只读子会话的 Harness 不提供 Skills,
因此也返回空目录。固定版本缺失或不一致时显式失败。
同步 OpenAPI、生成的 Web Shell 类型、接口注册表和准入契约测试。不迁移数据库,
不增加持久状态。
### 输入框
Java provider 提供可选目录读取能力。Session 作用域 Hook 在打开 Skills 菜单时
查询,缓存成功的不可变结果,作用域变化时取消请求,并忽略其他 Session 的迟到
响应。复用 ChatEditor 已有的 Skills 子菜单、加载、错误、空状态和 `/name` 插入。
请求失败后重新打开会重试。尚未分配 Session ID 的新会话不猜测定义进行查询。
其他 Workspace 能力继续由既有接口控制。
### 显式调用与加载
首次模型请求前识别连续位于开头、与固定目录精确匹配的 `/name` token。通过
现有 Hosted 工具执行器,将去重后的选择作为纯 `skill` 批次加载。保留用户提示词
和附件,将合成的 assistant 调用和工具结果写入日志,再把结果交给模型。因此,
即便模型原本会直接回答,也必须先在模型上下文中包含已发布正文。不匹配的 slash
文本保持现有含义。剩余提示词是任务输入,不是 Skill 参数或激活配置。
先执行正常的 Session 和提示词 Hook,再加载技能。复用 PreToolUse、PostToolUse、
批次 Hook、拒绝行为和内联大小限制。加载不执行脚本、不扩大权限,也不调用
Runtime 文件系统。恢复通过现有 Skill 恢复路径使用已提交的调用和结果;续行
模型或工具时不能再次合成激活。将 `skill` 加入公开 Plan 只读集合,同时保留
纯 Skill 批次以及写入、Shell 和计划审批限制。
## 涉及范围
Managed Agent API、服务、模型记录及契约测试;Web Shell 生成 API、Java client、
provider、Session 输入框和目录 Hook;Hosted Skill 目录、模型循环、Plan 策略
及定向测试。本方案扩展已有的[静态 Skill 设计](2026-10-15-managed-agent-extension-config.zh-CN.md)。
## 验证与验收
1. revision 2 修改目录后,revision 1 创建的 Session 仍列出并加载 revision 1;
新 Session 使用 revision 2。
2. reader 可以读取;缺少 actor、无授权、跨租户或已删除的 Session 在访问目录前
被拒绝;空目录有明确 UI 状态。菜单响应不包含正文或宿主路径。
3. 菜单选择前置调用文本。首次确定性模型请求包含选定正文的工具结果、原任务
和附件。
4. 显式调用遵守 Hook,按名称去重,并始终受已发布目录约束。普通提示词和模型
自主调用 Skill 保持原行为。
5. Plan 模式允许静态指令加载,继续拒绝写入和 Shell。恢复不会重放已完成激活。
6. 切换 Session、变更 provider、请求失败及迟到响应都不能显示上个 Session 的
Skills。
可执行计划和结果存放于 `.qwen/e2e-tests/managed-session-skills.md`。执行定向
Java 和 TypeScript 测试、API 生成、Java 验证、根目录 build/typecheck/bundle、
改动文件 lint/格式检查和两轮完整 diff 自审。区分环境及进程测试限制与已验证行为。
## 开放问题
本切片没有待定问题。发布和安装新 Skills 仍是输入框以外的管理任务。
@@ -0,0 +1,34 @@
# Web Shell approval mode allowlist
[English](web-shell-approval-mode-allowlist.md) | [简体中文](web-shell-approval-mode-allowlist.zh-CN.md)
## Problem and scope
Web Shell always offers default, auto-edit, auto and yolo. Hosts and settings.json cannot filter individual choices. Add presentation filtering without changing daemon permissions, CLI/TUI behavior, Plan behavior or the current session mode.
## Design
Register `ui.allowedApprovalModes` as an optional string array in the settings schema, with replacement semantics across settings scopes. The key narrows which approval modes the shell offers, so it is honored from operator scopes only (User, System, SystemDefaults): a value in a repository's workspace settings is stripped with a warning and workspace-scope writes are refused, because a cloned repository must not remove a restriction the operator set. Serve it through the existing workspace-scoped settings response; keep it out of the Settings editor. This reuses the existing runtime, trust and reload behavior without adding a route.
Expose `allowedApprovalModes` on WebShellProps (also inherited by WebShellWithProviders). An explicitly supplied Prop overrides settings, including an empty array. Omission defaults to all four execution modes. Filter unknown values, duplicates and Plan, preserving the built-in ordering. A malformed non-array setting falls back to all modes; an array with no supported modes offers none.
Use the shared `useApprovalModes` hook in App and ChatPane to read the explicitly selected workspace through its qualified settings endpoint. The main view uses `activeWorkspaceCwd`; it must not derive this list from the legacy-primary Settings editor data. Pass the resolved main list to the composer dropdown, approval dialog and custom footer. Share the host Prop through the existing customization context so split and side-task ChatPane composers inherit it. Without a host override, each pane reads its own workspace-qualified settings and refreshes on settings events; standalone and failed reads never fall back to primary settings. The read happens only when the daemon advertises the workspace-qualified settings features; a daemon without them keeps the default list, and a reconnect retries the read. A failed first read against a capable daemon offers no modes rather than the default list — resolving a rejection to the defaults would silently drop the operator's restriction — and a failed reload keeps the last-known-good list; the next settings event retries. Ignore stale responses and cached values after workspace or daemon-client changes. Keyboard cycling uses the same list, wraps, and starts at its first item if the current mode is hidden. An empty list makes cycling a no-op and disables the dropdown trigger. Selecting the already-current mode through cycling is a no-op. Direct Web Shell mode selection also rejects hidden choices; Plan toggling remains independent, including when the execution mode is hidden.
Filtering never automatically changes a restored or externally changed current mode. The trigger still displays the actual mode. This is UI customization, not a security boundary; other clients and daemon APIs may change modes.
## Affected files
CLI settings schema, generated editor schema and workspace settings route; Web Shell App and ChatPane, customization context, shared plan-mode utilities, composer, approval dialog, settings presentation, public exports and README; focused unit and browser tests.
## Validation and acceptance
- Unconfigured dropdown and dialog offer exactly all four modes.
- Settings and Props each filter choices; Props override settings.
- Dropdown, dialog, custom footer and keyboard cycling use the same list.
- Verify wraparound, hidden current mode, empty/single/invalid lists and live Prop changes.
- Verify scoped settings exposure, replacement merging across operator scopes, and that workspace-scope values are stripped. Hosts set per-shell lists — including different lists for different embeds — through the `allowedApprovalModes` Prop, without changing approval enforcement or Plan toggling.
- Build, typecheck, bundle, focused unit tests, browser checks using a clearly identified mock daemon, and two clean self-audit passes.
## Risks and open questions
No open design questions. Settings may not be available for standalone contexts; Props continue to work there. Before settings finish loading, the UI uses the default list. Existing backend permission rules remain authoritative.
@@ -0,0 +1,34 @@
# Web Shell 审批模式白名单
[English](web-shell-approval-mode-allowlist.md) | [简体中文](web-shell-approval-mode-allowlist.zh-CN.md)
## 问题与范围
Web Shell 始终提供 default、auto-edit、auto 和 yolo,宿主及 settings.json 都不能过滤单个选项。本次新增展示过滤,不改变 daemon 权限、CLI/TUI 行为、Plan 行为或当前会话模式。
## 设计
在设置 schema 注册可选字符串数组 `ui.allowedApprovalModes`,不同设置作用域之间使用替换合并。该键收窄 shell 提供的审批模式,因此仅在操作者作用域(User、System、SystemDefaults)生效:仓库工作区设置中的值会被剥离并警告,工作区作用域的写入会被拒绝——克隆下来的仓库不得移除操作者设定的限制。通过现有工作区作用域设置响应提供该值,不在设置编辑器展示。复用已有运行时、信任和重新加载行为,不新增路由。
在 WebShellProps 暴露 `allowedApprovalModes`(WebShellWithProviders 同样继承)。显式传入的 Prop 优先于设置,空数组也算显式配置。未配置时默认提供全部四种执行模式。过滤未知值、重复值和 Plan,保留内置顺序。非数组的错误设置回退到全部模式;没有受支持模式的数组不提供任何选项。
App 和 ChatPane 共用 `useApprovalModes` hook,通过限定设置接口读取明确选中的工作区。主视图使用 `activeWorkspaceCwd`,不得从 legacy-primary 的设置编辑器数据推导此列表。解析后的主列表传给输入框下拉菜单、审批弹窗和自定义页脚。通过现有定制上下文传递宿主 Prop,使分屏和侧边任务的 ChatPane 输入框继承它。未传宿主覆盖值时,每个面板读取自身工作区的限定设置,并在设置事件后刷新;独立会话或读取失败时,不回退到主工作区设置。仅当 daemon 声明支持工作区限定设置能力时才执行读取;不支持该能力的 daemon 保持默认列表,重连后会重试读取。对支持该能力的 daemon,首次读取失败时不提供任何模式,而不是回退到默认列表——把拒绝解析为默认值会悄悄丢弃操作者设定的限制;重新加载失败时保留上一次的有效列表,下一次设置事件会重试。切换工作区或 daemon 客户端后,忽略过期响应和缓存值。快捷键循环使用同一列表,支持回绕;当前模式被隐藏时从列表第一项开始。空列表使循环不执行操作,并禁用下拉入口。快捷键选择已处于的模式不执行操作。Web Shell 直接选择模式也拒绝隐藏选项;Plan 切换保持独立,当前执行模式被隐藏时也不受影响。
过滤不会自动改变恢复或外部修改的当前模式,入口仍显示真实模式。这是 UI 定制,不是安全边界,其他客户端和 daemon API 仍可切换模式。
## 影响文件
CLI 设置 schema、生成的编辑器 schema 和工作区设置路由;Web Shell App 和 ChatPane、定制上下文、共享 plan-mode 工具、输入框、审批弹窗、设置展示、公开导出和 README;定向单元测试和浏览器测试。
## 验证与验收
- 未配置时,下拉菜单和弹窗恰好提供全部四种模式。
- 设置和 Props 均可过滤,Props 优先于设置。
- 下拉菜单、弹窗、自定义页脚和快捷键循环使用同一列表。
- 验证回绕、当前模式被隐藏、空列表、单项、非法列表及 Prop 动态变化。
- 验证作用域设置传递、操作者作用域之间的替换合并,以及工作区作用域的值会被剥离。宿主通过 `allowedApprovalModes` Prop 为不同嵌入实例设置各自的白名单,不改变审批执行约束及 Plan 切换。
- 构建、类型检查、打包、定向单元测试、明确使用模拟 daemon 的浏览器检查,以及两次连续干净的自审。
## 风险与待定问题
没有待定设计问题。独立会话上下文可能不提供设置,Props 在此场景仍可使用。设置加载完成前,UI 使用默认列表。实际权限始终由已有后端规则决定。
+1
View File
@@ -132,6 +132,7 @@ These settings are read from operator scopes only (User, System, and SystemDefau
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
| `ui.brand.name` | string | Product name the Web Shell presents in its sidebar, welcome header, About panel and browser tab title. Sanitized to a single line and capped at 80 characters. Read from operator scopes only (User, System, SystemDefaults) — a workspace `.qwen/settings.json` cannot rebrand the shell. A placeholder value that substitution would change (`$VAR`/`${VAR}` with the variable set) is refused with a warning on the daemon's stderr, because the substitution source is process-wide and a workspace could supply it; an unresolvable placeholder is kept verbatim, so a typo'd variable shows as literal text rather than silently falling back. Not editable from the in-browser Settings page. The terminal banner has its own separate setting, `ui.customBannerTitle`. | `""` |
| `ui.brand.logoPath` | string | Path to an SVG used as the Web Shell sidebar logo and browser favicon. A leading `~` is expanded, and a relative path resolves against the directory of the settings file that declares it. Must be a regular file (not a symlink, and not reachable through more than one hard link) of at most 32 KiB — on disk and once UTF-8-decoded — whose root element is a namespaced `<svg>` (the `xmlns` attribute, or an `xmlns:svg` binding on a prefix-bound root, is what makes it renderable as an image). Environment variable placeholders that would resolve are refused, as with `ui.brand.name`. Read from operator scopes only. A rejected file logs a warning on the daemon's stderr and falls back to the built-in logo. A root `<svg>` with no usable `viewBox` or explicit positive width/height is accepted but logs an advisory, and so is a prefix-bound root whose unprefixed elements have no default-namespace binding, because the browser may render it blank at the sidebar's fixed size. | `""` |
| `ui.allowedApprovalModes` | string[] | Execution approval modes offered by the Web Shell mode picker, `/approval-mode` dialog, custom footer and keyboard cycling. Omit to offer `default`, `auto-edit`, `auto` and `yolo`; an empty list offers none and disables the picker. A Web Shell host's `allowedApprovalModes` prop overrides this setting. User, System, and SystemDefaults scopes only; workspace values are ignored with a warning, because a cloned repository must not remove a restriction the operator set. Presentation only: it does not change the current mode or enforce backend permissions. Not shown in the Settings dialog; edit `settings.json` directly. | `undefined` |
| `ui.statusLine` | object | Custom status line configuration. Supports `command`, `refreshInterval`, `respectUserColors`, and `hideContextIndicator` options. See [Status Line](../features/status-line). | `undefined` |
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
| `ui.hideTips` | boolean | Hide all tips (startup and post-response) in the UI. See [Contextual Tips](../features/tips). | `false` |
+30
View File
@@ -2870,6 +2870,36 @@ describe('Settings Loading and Merging', () => {
});
});
it.each([{ workspaceModes: ['default'] }, { workspaceModes: [] }])(
'ignores the workspace approval mode list $workspaceModes in favor of the user list',
({ workspaceModes }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
p === USER_SETTINGS_PATH ||
p === RESOLVED_MOCK_WORKSPACE_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) =>
JSON.stringify({
ui: {
allowedApprovalModes:
p === USER_SETTINGS_PATH ? ['auto', 'yolo'] : workspaceModes,
},
}),
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.ui?.allowedApprovalModes).toEqual([
'auto',
'yolo',
]);
expect(
getSettingsWarnings(settings).some((warning) =>
warning.includes('ui.allowedApprovalModes'),
),
).toBe(true);
},
);
it('should resolve environment variables in user settings', () => {
process.env['TEST_API_KEY'] = 'user_api_key_from_env';
const userSettingsContent: TestSettings = {
@@ -23,6 +23,7 @@ import {
type Settings,
type SettingsSchema,
} from './settingsSchema.js';
import { DAEMON_APPROVAL_MODES } from '@qwen-code/sdk/daemon';
import { MergeStrategy } from '../utils/deepMerge.js';
import {
MAX_CONCURRENT_SUB_SESSIONS_PER_CALLER,
@@ -31,6 +32,28 @@ import {
} from '../serve/create-sub-session.js';
describe('SettingsSchema', () => {
it('declares a replace-only Web Shell approval mode allowlist', () => {
const { allowedApprovalModes } = getSettingsSchema().ui.properties;
expect(allowedApprovalModes).toMatchObject({
type: 'array',
default: undefined,
mergeStrategy: MergeStrategy.REPLACE,
showInDialog: false,
items: {
type: 'string',
// Pinned to the SDK vocabulary rather than restated as a literal, so
// a permission mode added there without this schema following turns
// this assertion red instead of shipping a stale editor enum.
enum: DAEMON_APPROVAL_MODES.filter((mode) => mode !== 'plan'),
},
});
// The key is workspace-restricted; the description is the only in-editor
// signal, so it must say so (mirrored verbatim into the VSCode schema).
expect(allowedApprovalModes.description).toMatch(
/values set in Workspace settings are ignored/,
);
});
describe('getSettingsSchema', () => {
it('should describe prompt hooks supported by the runtime', () => {
const hookProperties =
+15
View File
@@ -946,6 +946,21 @@ const SETTINGS_SCHEMA = {
description: 'User interface settings.',
showInDialog: false,
properties: {
allowedApprovalModes: {
type: 'array',
label: 'Web Shell Approval Modes',
category: 'UI',
requiresRestart: false,
default: undefined as string[] | undefined,
mergeStrategy: MergeStrategy.REPLACE,
description:
'Execution approval modes offered by Web Shell. Omit to show default, auto-edit, auto and yolo; an empty list offers none. Host allowedApprovalModes overrides this setting. Presentation only: does not change the current mode or enforce backend permissions. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored with a warning. Edit settings.json directly.',
showInDialog: false,
items: {
type: 'string',
enum: ['default', 'auto-edit', 'auto', 'yolo'],
},
},
theme: {
type: 'string',
label: 'Theme',
+1
View File
@@ -286,6 +286,7 @@ export const WORKSPACE_RESTRICTED_SETTINGS = [
{ section: 'goals', key: 'modelProposed' },
{ section: 'outboundCorrelation', key: 'allowDynamicHeaderValues' },
{ section: 'serve', key: 'tokenQr' },
{ section: 'ui', key: 'allowedApprovalModes' },
] as const satisfies ReadonlyArray<{
readonly section: keyof Settings;
readonly key: string;
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { LlmEventType, Turn } from '@qwen-code/qwen-code-core/core/turn.js';
import {
LlmEventType,
Turn,
type ToolCallRequestInfo,
} from '@qwen-code/qwen-code-core/core/turn.js';
import {
llmPreparedProviderPin,
type LlmPreparedRequest,
@@ -2054,3 +2058,87 @@ it('advertises only the published Skill catalog while keeping host Skill discove
);
expect(mocks.refreshSystemInstruction).toHaveBeenCalled();
});
it.each([undefined, 3])(
'loads explicit Skills before the first model request and preserves the task and attachments (initial round: %s)',
async (initialRound) => {
const mocks = config([{ type: LlmEventType.Finished }]);
const skills = new HostedSkillCatalog([
{ name: 'review', description: 'Review.', body: 'PINNED_REVIEW_BODY' },
{ name: 'testing', description: 'Test.', body: 'PINNED_TEST_BODY' },
]);
const setModelRound = vi.fn();
const execute = vi.fn(async (calls: ToolCallRequestInfo[]) => {
expect(setModelRound).toHaveBeenLastCalledWith(initialRound ?? 0);
return calls.map((call) => ({
functionResponse: {
id: call.callId,
name: call.name,
response: { output: skills.response(call.args) },
},
}));
});
const toolTurn = {
execute,
setModelRound,
consumeResults: vi.fn(async () => undefined),
declarations: vi.fn(async () => [skills.declaration()!]),
setPromptHookRunner: vi.fn(),
};
const prompt = '/review /testing /review inspect the patch';
const attachmentParts = [{ text: 'ATTACHMENT_CONTENT' }];
await runHostedHarnessTextTurn({
...input,
prompt,
initialRound,
attachmentParts,
skills,
toolTurn,
});
expect(execute).toHaveBeenCalledOnce();
expect(execute.mock.calls[0][0]).toMatchObject([
{ name: 'skill', args: { skill: 'review' }, isClientInitiated: true },
{ name: 'skill', args: { skill: 'testing' }, isClientInitiated: true },
]);
expect(mocks.setHistory).toHaveBeenLastCalledWith([
{ role: 'user', parts: [{ text: prompt }, ...attachmentParts] },
{ role: 'model', parts: expect.any(Array) },
]);
expect(JSON.stringify(mocks.requests[0])).toContain('PINNED_REVIEW_BODY');
expect(JSON.stringify(mocks.requests[0])).toContain('PINNED_TEST_BODY');
expect(mocks.sendOptions[0]).toMatchObject({
type: SendMessageType.ToolResult,
});
expect(toolTurn.consumeResults).toHaveBeenCalledOnce();
},
);
it('uses saved Skill results on continuation without loading the selection again', async () => {
const mocks = config([{ type: LlmEventType.Finished }]);
const result: Part[] = [
{
functionResponse: {
id: 'saved',
name: 'skill',
response: { output: 'SAVED_BODY' },
},
},
];
const toolTurn = {
execute: vi.fn(),
consumeResults: vi.fn(async () => undefined),
declarations: vi.fn(async () => []),
setPromptHookRunner: vi.fn(),
};
await runHostedHarnessTextTurn({
...input,
prompt: '/review inspect the patch',
skills: new HostedSkillCatalog([
{ name: 'review', description: 'Review.', body: 'NEW_BODY' },
]),
toolTurn,
resumeFromToolResults: result,
});
expect(toolTurn.execute).not.toHaveBeenCalled();
expect(mocks.requests[0]).toEqual(result);
});
+34 -2
View File
@@ -16,7 +16,7 @@ import type { HostedModelRequest } from './hosted-model-recovery.js';
import type { HostedModelSelection } from './hosted-model-selection.js';
import type { Content, Part } from '@google/genai';
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { SendMessageType } from '@qwen-code/qwen-code-core/core/client.js';
import { ToolNames } from '@qwen-code/qwen-code-core/tools/tool-names.js';
import type { ToolCallRequestInfo } from '@qwen-code/qwen-code-core/core/turn.js';
@@ -491,6 +491,38 @@ export async function runHostedHarnessTextTurn(input: {
let pendingToolResults =
input.resumeFromToolResults !== undefined ||
(input.resumeModel?.pendingToolResults ?? false);
const selectedSkills =
!input.resumeFromToolResults && !input.resumeModel && input.toolTurn
? (input.skills?.invocations(effectivePrompt) ?? [])
: [];
if (selectedSkills.length && input.toolTurn) {
const calls: ToolCallRequestInfo[] = selectedSkills.map((skill) => ({
callId: randomUUID(),
name: 'skill',
args: { skill },
isClientInitiated: true,
origin: { kind: 'client' },
prompt_id: input.promptId,
}));
const parts: Part[] = calls.map((call) => ({
functionCall: { id: call.callId, name: call.name, args: call.args },
}));
client
.getChat()
.setHistory([
...history,
{ role: 'user', parts: request },
{ role: 'model', parts },
]);
input.toolTurn.setModelRound?.(input.initialRound ?? 0);
request = await input.toolTurn.execute(
calls,
parts,
config.getModel(),
input.signal,
);
pendingToolResults = true;
}
let preparedRecovery = input.resumeModel?.prepared;
let stopHookActive =
(await input.hooks?.wasStopBlocked(input.promptId)) ?? false;
@@ -608,7 +640,7 @@ export async function runHostedHarnessTextTurn(input: {
)
: client.sendMessageStream(request, input.signal, input.promptId, {
type:
round === 0 && !input.resumeFromToolResults
round === 0 && !pendingToolResults
? SendMessageType.UserQuery
: SendMessageType.ToolResult,
// Published deltas cannot be un-glued after the fact: a cut that
+2 -2
View File
@@ -25,9 +25,9 @@ export const HOSTED_PUBLIC_PLAN_TOOL: FunctionDeclaration = {
},
};
export const HOSTED_PUBLIC_PLAN_READS = new Set(['read_file', 'glob']);
export const HOSTED_PUBLIC_PLAN_READS = new Set(['read_file', 'glob', 'skill']);
export const HOSTED_PUBLIC_PLAN_REFUSAL =
'Plan mode is read-only: only read_file, glob and a single exit_plan_mode call are allowed. Submit a plan with exit_plan_mode and wait for the user to approve it before making changes or running Shell commands.';
'Plan mode is read-only: only read_file, glob, static skill loading and a single exit_plan_mode call are allowed. Submit a plan with exit_plan_mode and wait for the user to approve it before making changes or running Shell commands.';
export async function hostedPublicPlanActive(
session: ManagedSession,
@@ -19,6 +19,20 @@ const skill = {
base_directory: '.qwen/skills/code-review',
};
it.each([
['/code-review inspect this', ['code-review']],
[' /code-review\n/code-review inspect this', ['code-review']],
['/code-reviewer inspect this', []],
['Explain /code-review', []],
['/unknown /code-review', []],
['/code-review/path', []],
])(
'recognizes only exact leading published invocations in %s',
(prompt, names) => {
expect(new HostedSkillCatalog([skill]).invocations(prompt)).toEqual(names);
},
);
it('advertises only published descriptions and loads a fixed Workspace-relative body', () => {
const source = [{ ...skill }];
const catalog = new HostedSkillCatalog(source);
@@ -125,6 +125,18 @@ export class HostedSkillCatalog {
return `The following published skills are available. Use the skill tool to load their instructions when relevant.\n${renderAvailableSkillsBlock([...this.skills])}`;
}
invocations(prompt: string): string[] {
const names = new Set<string>();
let remaining = prompt;
for (;;) {
const match = /^\s*\/([^\s/]+)(?=\s|$)/u.exec(remaining);
if (!match || !this.skills.some((skill) => skill.name === match[1]))
return [...names];
names.add(match[1]);
remaining = remaining.slice(match[0].length);
}
}
declaration(): FunctionDeclaration | undefined {
if (!this.skills.length) return undefined;
return {
@@ -75,6 +75,7 @@ import {
HostedSkillCatalog,
} from './hosted-skill-catalog.js';
import { answerResumedTurnCalls } from './hosted-runtime-recovery.js';
import * as hostedPlan from './hosted-plan-mode.js';
const broker = vi.hoisted(() => ({
capabilityDigest:
@@ -187,6 +188,7 @@ function createTurn(
approval?: { mode: HostedApprovalMode; timeoutMs?: number },
hooks?: HostedHookSession,
skills?: HostedSkillCatalog,
planMode: 'none' | 'public' | 'member' = 'none',
) {
return new HostedWorkspaceToolTurn(
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
@@ -206,7 +208,13 @@ function createTurn(
},
waiters,
},
{ hooks, skills },
{
hooks,
skills,
publicPlanMode: planMode === 'public',
memberPlanMode: planMode === 'member',
...(planMode === 'public' ? { profile: 'hosted-workspace-files/1' } : {}),
},
);
}
const calls = ['read_file', 'edit'].map((name, index) => ({
@@ -326,44 +334,55 @@ const publishedSkills = () =>
},
]);
it('loads a published static Skill durably without Runtime execution or approval', async () => {
const skills = publishedSkills();
turn = createTurn(false, { mode: 'default' }, undefined, skills);
const call = { ...calls[0], name: 'skill', args: { skill: 'review' } };
const original: Part[] = [
{ functionCall: { id: call.callId, name: call.name, args: call.args } },
];
expect(await turn.declarations(new AbortController().signal)).toContainEqual(
skills.declaration(),
);
const responses = await turn.execute(
[call],
original,
'model',
new AbortController().signal,
);
expect(responses[0].functionResponse?.response?.['output']).toBe(
skills.response(call.args),
);
const history = await session.sink.project();
expect(history.map((entry) => entry.type)).toEqual([
'assistant',
'tool_result',
]);
expect(history[1].message?.parts).toEqual(responses);
expect(broker.acquire).not.toHaveBeenCalled();
expect(broker.prepare).not.toHaveBeenCalled();
expect(broker.execute).not.toHaveBeenCalled();
expect(
session.authority
.eventsInSequenceRange(1, session.authority.committedSequence)
.some((event) => event.kind === 'action.changed'),
).toBe(false);
await turn.finish();
const reloaded = createTurn(false, undefined, undefined, skills);
await reloaded.resumeCommittedResults(new AbortController().signal);
expect(broker.acquire).not.toHaveBeenCalled();
});
it.each(['none', 'public', 'member'] as const)(
'loads a published static Skill durably without Runtime execution or approval (Plan: %s)',
async (planMode) => {
if (planMode === 'public')
vi.spyOn(hostedPlan, 'hostedPublicPlanActive').mockResolvedValue(true);
const skills = publishedSkills();
turn = createTurn(false, { mode: 'default' }, undefined, skills, planMode);
const call = { ...calls[0], name: 'skill', args: { skill: 'review' } };
const original: Part[] = [
{ functionCall: { id: call.callId, name: call.name, args: call.args } },
];
expect(
await turn.declarations(new AbortController().signal),
).toContainEqual(skills.declaration());
if (planMode === 'public')
expect(await turn.declarations(new AbortController().signal)).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'write_file' }),
]),
);
const responses = await turn.execute(
[call],
original,
'model',
new AbortController().signal,
);
expect(responses[0].functionResponse?.response?.['output']).toBe(
skills.response(call.args),
);
const history = await session.sink.project();
expect(history.map((entry) => entry.type)).toEqual([
'assistant',
'tool_result',
]);
expect(history[1].message?.parts).toEqual(responses);
expect(broker.acquire).not.toHaveBeenCalled();
expect(broker.prepare).not.toHaveBeenCalled();
expect(broker.execute).not.toHaveBeenCalled();
expect(
session.authority
.eventsInSequenceRange(1, session.authority.committedSequence)
.some((event) => event.kind === 'action.changed'),
).toBe(false);
await turn.finish();
const reloaded = createTurn(false, undefined, undefined, skills);
await reloaded.resumeCommittedResults(new AbortController().signal);
expect(broker.acquire).not.toHaveBeenCalled();
},
);
it('refuses a mixed Skill and Workspace batch before approval Hooks or tool dispatch', async () => {
const fire = vi.fn<HostedHookSession['fire']>().mockResolvedValue(undefined);
@@ -691,6 +691,7 @@ export const HOSTED_INPUT_PREVIEW_TOOLS: readonly string[] = [
const HOSTED_PLAN_MODE_READS: ReadonlySet<string> = new Set([
'read_file',
'glob',
'skill',
'task_list',
'send_message',
]);
@@ -700,7 +701,7 @@ const HOSTED_PLAN_MODE_READS: ReadonlySet<string> = new Set([
* (decision 5): it says the run may only read and never names it.
*/
function hostedPlanModeRefusal(): string {
return `This run is in plan mode until the leader approves its plan: only read_file, glob, task_list${teamPlanAdmissionsEnabled() ? ', exit_plan_mode' : ''} and send_message to the leader are admitted. The decision arrives as your next instruction.`;
return `This run is in plan mode until the leader approves its plan: only read_file, glob, static skill loading, task_list${teamPlanAdmissionsEnabled() ? ', exit_plan_mode' : ''} and send_message to the leader are admitted. The decision arrives as your next instruction.`;
}
export const HOSTED_MEMBER_PLAN_TOOL: FunctionDeclaration = {
@@ -15,6 +15,10 @@ import {
registerWorkspaceSettingsRoutes,
} from './workspace-settings.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import {
WORKSPACE_RESTRICTED_ROOT_SETTINGS,
WORKSPACE_RESTRICTED_SETTINGS,
} from '../../config/settingsUtils.js';
import { WorkspaceGenerationClosedError } from '../workspace-registry.js';
import { ModelsConfig } from '@qwen-code/qwen-code-core/models/modelsConfig.js';
import { buildAcpModelOptions } from '../../utils/acpModelUtils.js';
@@ -147,10 +151,22 @@ function makeApp(
if (overrides.userSettings || overrides.workspaceSettings) {
const user = structuredClone(overrides.userSettings ?? {});
const workspace = structuredClone(overrides.workspaceSettings ?? {});
// Mirror the real merge: a workspace value for a restricted key — either
// list — is stripped before merging, though the raw workspace scope is
// still reported under `workspace`.
const mergeableWorkspace = structuredClone(workspace);
for (const key of WORKSPACE_RESTRICTED_ROOT_SETTINGS) {
delete (mergeableWorkspace as Record<string, unknown>)[key];
}
for (const { section, key } of WORKSPACE_RESTRICTED_SETTINGS) {
const sectionValue = mergeableWorkspace[section];
if (sectionValue && typeof sectionValue === 'object')
delete (sectionValue as Record<string, unknown>)[key];
}
// Mirror the real precedence (workspace over user), merging one level deep
// so sibling keys under `experimental` are not lost.
const merged: Record<string, unknown> = { ...user };
for (const [key, value] of Object.entries(workspace)) {
for (const [key, value] of Object.entries(mergeableWorkspace)) {
const base = merged[key];
merged[key] =
base &&
@@ -224,30 +240,33 @@ function makeQualifiedApp(
overrides.invokeWorkspaceCommand ?? vi.fn().mockResolvedValue(undefined);
const publishWorkspaceEvent = vi.fn();
const invalidateServeFeaturesCache = vi.fn();
const entryFor = (workspaceCwd: string) => ({
state: 'active',
current: {
runtime: {
trusted: true,
workspaceCwd,
routeFileSystemFactory: overrides.ssh
? {
sshWorkspace: { host: 'host', directory: '/srv/project' },
}
: {},
bridge: {
invokeWorkspaceCommand,
publishWorkspaceEvent,
},
workspaceService: { reloadModelProviders },
generationGuard: undefined,
},
},
});
const registry = {
getEntryByWorkspaceId: (selector: string) =>
selector === 'primary'
? {
state: 'active',
current: {
runtime: {
trusted: true,
workspaceCwd: overrides.workspaceCwd ?? '/workspace',
routeFileSystemFactory: overrides.ssh
? {
sshWorkspace: { host: 'host', directory: '/srv/project' },
}
: {},
bridge: {
invokeWorkspaceCommand,
publishWorkspaceEvent,
},
workspaceService: { reloadModelProviders },
generationGuard: undefined,
},
},
}
: undefined,
? entryFor(overrides.workspaceCwd ?? '/workspace')
: selector === 'secondary'
? entryFor('/selected-workspace')
: undefined,
};
registerWorkspaceQualifiedSettingsRoutes(app, {
@@ -1326,3 +1345,80 @@ describe('web-shell settings alias drift', () => {
);
});
});
describe('Web Shell approval mode allowlist settings', () => {
it('serves the effective list and its settings scopes', async () => {
const { app } = makeApp({
userSettings: { ui: { allowedApprovalModes: ['default', 'auto'] } },
workspaceSettings: { ui: { allowedApprovalModes: [] } },
});
const response = await request(app).get('/workspace/settings');
expect(response.status).toBe(200);
expect(
response.body.settings.find(
(setting: { key: string }) => setting.key === 'ui.allowedApprovalModes',
),
).toMatchObject({
values: {
effective: ['default', 'auto'],
user: ['default', 'auto'],
workspace: [],
},
});
});
it('rejects a workspace-scope write of the allowlist', async () => {
const { app, persistSetting } = makeApp();
const response = await request(app).post('/workspace/settings').send({
scope: 'workspace',
key: 'ui.allowedApprovalModes',
value: [],
});
expect(response.status).toBe(400);
expect(response.body).toMatchObject({
code: 'workspace_restricted_setting',
});
expect(persistSetting).not.toHaveBeenCalled();
});
it('reads the list from the selected runtime rather than primary', async () => {
// Primary keeps its own cwd, so a route that falls back to it serves the
// ['yolo'] list instead of the selected workspace's ['default'].
const { app } = makeQualifiedApp();
vi.mocked(loadSettings).mockImplementation(
(cwd) =>
({
merged: {
ui: {
allowedApprovalModes:
cwd === '/selected-workspace' ? ['default'] : ['yolo'],
},
},
user: { settings: {} },
workspace: { settings: {} },
forScope: vi.fn().mockReturnValue({ settings: {} }),
}) as never,
);
const response = await request(app).get('/workspaces/secondary/settings');
expect(response.status).toBe(200);
expect(
response.body.settings.find(
(setting: { key: string }) => setting.key === 'ui.allowedApprovalModes',
)?.values.effective,
).toEqual(['default']);
});
it('serves the operator value for a root-level restricted key set at workspace scope', async () => {
const { app } = makeApp({
userSettings: { advisorMaxUses: 3 },
workspaceSettings: { advisorMaxUses: 9 },
});
const response = await request(app).get('/workspace/settings');
expect(response.status).toBe(200);
expect(
response.body.settings.find(
(setting: { key: string }) => setting.key === 'advisorMaxUses',
)?.values,
).toMatchObject({ effective: 3, user: 3, workspace: 9 });
});
});
@@ -68,6 +68,7 @@ const TUI_ONLY_SETTINGS = new Set([
// their rows and needs to read + persist them; for `voiceModel` the daemon
// `/voice/stream` then reads it back via `loadSettings`.
const WEB_SHELL_SETTINGS = new Set([
'ui.allowedApprovalModes',
'ui.compactMode',
'voiceModel',
'imageModel',
+12 -2
View File
@@ -465,8 +465,18 @@
locally on the merged base (~242 s
budgeted): the healthy sum becomes
5985 s, so the fork moves to 6585 s
and the Verify step to 120 minutes. -->
<forkedProcessTimeoutInSeconds>6585</forkedProcessTimeoutInSeconds>
and the Verify step to 120 minutes.
The FG13a execute lost-reply cell
(issue #14449) adds 150 s: the healthy
sum becomes 6135 s, so the fork moves
to 6735 s. The FG13a status lost-reply
cell (issue #14449) adds 150 s: the
healthy sum becomes 6285 s; the FG11c
Harness-crash method (three cases, 144.8 s
measured, 550 s budgeted) moves it to
6835 s, so the fork moves to 7435 s and
the Verify step to 134 minutes. -->
<forkedProcessTimeoutInSeconds>7435</forkedProcessTimeoutInSeconds>
</configuration>
<executions>
<execution>
@@ -279,6 +279,10 @@ public final class ApiModels {
public record WebShellSessionRequest(@NotBlank String sessionId) {
}
public record WebShellSkill(String name, String description) { }
public record WebShellSessionSkills(List<WebShellSkill> skills) { }
public record WebShellTranscriptRequest(@NotBlank String sessionId,
String cursor, Integer limit) {
}
@@ -0,0 +1,27 @@
package com.alibaba.qwen.code.managedagent.api;
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionRequest;
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionSkills;
import com.alibaba.qwen.code.managedagent.service.ManagedSkillCatalogService;
import jakarta.validation.Valid;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WebShellSkillsController {
private final ManagedSkillCatalogService skills;
public WebShellSkillsController(ManagedSkillCatalogService skills) {
this.skills = skills;
}
@PostMapping("/api/agent/web-shell/v1/session-skills/query")
public ResponseEntity<WebShellSessionSkills> query(TenantContext tenant,
@Valid @RequestBody WebShellSessionRequest request) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.body(skills.get(tenant.tenantId(), tenant.requireActorId(), request.sessionId()));
}
}
@@ -0,0 +1,33 @@
package com.alibaba.qwen.code.managedagent.service;
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionSkills;
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSkill;
import com.alibaba.qwen.code.managedagent.store.AgentDefinitionExtensions;
import com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore;
import com.alibaba.qwen.code.managedagent.store.WorkspaceToolProfiles;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ManagedSkillCatalogService {
private final ManagedAgentService sessions;
private final ManagedAgentDefinitionStore definitions;
public ManagedSkillCatalogService(ManagedAgentService sessions, ManagedAgentDefinitionStore definitions) {
this.sessions = sessions;
this.definitions = definitions;
}
public WebShellSessionSkills get(String tenantId, String actorId, String sessionId) {
var session = sessions.requireReadableSession(tenantId, actorId, sessionId);
if (session.workspace() == null || session.agentDefinitionDigest() == null
|| WorkspaceToolProfiles.READ.equals(session.toolProfile())) {
return new WebShellSessionSkills(List.of());
}
var skills = AgentDefinitionExtensions.skills(definitions.pinnedContent(session).get("skills"), true);
return new WebShellSessionSkills(skills.stream()
.map(skill -> new WebShellSkill((String) skill.get("name"), (String) skill.get("description")))
.toList());
}
}
@@ -2,8 +2,8 @@
"openapi": "3.1.0",
"info": {
"title": "Qwen Managed Agent Public and WebShell API",
"version": "1.66.0",
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
"version": "1.67.0",
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
},
"servers": [
{
@@ -4301,6 +4301,54 @@
}
}
}
},
"/api/agent/web-shell/v1/session-skills/query": {
"post": {
"tags": ["WebShell"],
"operationId": "queryWebShellSessionSkills",
"x-qwen-implementation-status": "implemented",
"description": "Read only names and descriptions from the Session's pinned static Skill catalog. Requires a trusted actor and Workspace read access. Never resolves the latest AgentDefinition or discovers host Skills.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebShellSessionRequest"
}
}
}
},
"responses": {
"200": {
"description": "Session static Skill metadata.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebShellSessionSkills"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"409": {
"$ref": "#/components/responses/Conflict"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
}
}
}
}
},
"components": {
@@ -7466,7 +7514,7 @@
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "Reason a durable response or task cancellation failed. Action response failures come from the original committed Action, or a definitive invalid response. Task cancel failures are task_already_settled (the run ended before the stop request was recorded), task_action_unavailable and task_not_found; a recovery_blocked task cancel reports task_cancel_unconfirmed. A lifecycle operation that exhausted its retry budget fails with the settle's failure: session_lifecycle_delivery_failed, managed_capability_mismatch or hosted_harness_protocol_error when the Hosted Harness refused to negotiate, a workspace close or lifecycle refusal (a workspace_close_* or workspace_lifecycle_* code, or runtime_admission_closed; a Runtime Broker runtime_broker_recovery_blocked refusal is reported as workspace_close_identity_unverified), or another Runtime Broker refusal code; one whose settle reached the Harness but stayed unsettled past the larger settled budget fails with session_close_completion_unsettled. A lifecycle recovery_blocked operation reports the refusal it waits on, original resource identity or unsettled execution for a Workspace close, session_close_writer_live while a Harness holds the journal writer or while that check itself fails, or hosted_harness_generation_mismatch while this replica's view of the Harness boot is stale, or hosted_lifecycle_recovery_required while the daemon settles a previous prompt; the Session remains closing. A submit_input failure carries its Turn's error code, and a recovery_blocked submit_input reports submit_input_unconfirmed; a cancel fails with turn_already_settled when its Turn completed or failed without being cancelled."
"description": "Reason a durable response or task cancellation failed. Action response failures come from the original committed Action, or a definitive invalid response. Task cancel failures are task_already_settled (the run ended before the stop request was recorded), task_action_unavailable and task_not_found; a recovery_blocked task cancel reports task_cancel_unconfirmed. A lifecycle operation that exhausted its retry budget fails with the settle's failure: session_lifecycle_delivery_failed, managed_capability_mismatch or hosted_harness_protocol_error when the Hosted Harness refused to negotiate, a workspace close or lifecycle refusal (a workspace_close_* or workspace_lifecycle_* code, or runtime_admission_closed; a Runtime Broker runtime_broker_recovery_blocked refusal is reported as workspace_close_identity_unverified), or another Runtime Broker refusal code; one whose settle reached the Harness but stayed unsettled past the larger settled budget fails with session_close_completion_unsettled. A lifecycle recovery_blocked operation reports the refusal it waits on, original resource identity or unsettled execution for a Workspace close, session_close_writer_live while a Harness holds the journal writer or while that check itself fails, or hosted_harness_generation_mismatch while this replica's view of the Harness boot is stale; the Session remains closing. A submit_input failure carries its Turn's error code, and a recovery_blocked submit_input reports submit_input_unconfirmed; a cancel fails with turn_already_settled when its Turn completed or failed without being cancelled."
},
"replayed": {
"type": "boolean"
@@ -7850,7 +7898,7 @@
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "Reason a durable response or task cancellation failed. Action response failures come from the original committed Action, or a definitive invalid response. Task cancel failures are task_already_settled (the run ended before the stop request was recorded), task_action_unavailable and task_not_found; a recovery_blocked task cancel reports task_cancel_unconfirmed. A lifecycle operation that exhausted its retry budget fails with the settle's failure: session_lifecycle_delivery_failed, managed_capability_mismatch or hosted_harness_protocol_error when the Hosted Harness refused to negotiate, a workspace close or lifecycle refusal (a workspace_close_* or workspace_lifecycle_* code, or runtime_admission_closed; a Runtime Broker runtime_broker_recovery_blocked refusal is reported as workspace_close_identity_unverified), or another Runtime Broker refusal code; one whose settle reached the Harness but stayed unsettled past the larger settled budget fails with session_close_completion_unsettled. A lifecycle recovery_blocked operation reports the refusal it waits on, original resource identity or unsettled execution for a Workspace close, session_close_writer_live while a Harness holds the journal writer or while that check itself fails, or hosted_harness_generation_mismatch while this replica's view of the Harness boot is stale, or hosted_lifecycle_recovery_required while the daemon settles a previous prompt; the Session remains closing. A submit_input failure carries its Turn's error code, and a recovery_blocked submit_input reports submit_input_unconfirmed; a cancel fails with turn_already_settled when its Turn completed or failed without being cancelled."
"description": "Reason a durable response or task cancellation failed. Action response failures come from the original committed Action, or a definitive invalid response. Task cancel failures are task_already_settled (the run ended before the stop request was recorded), task_action_unavailable and task_not_found; a recovery_blocked task cancel reports task_cancel_unconfirmed. A lifecycle operation that exhausted its retry budget fails with the settle's failure: session_lifecycle_delivery_failed, managed_capability_mismatch or hosted_harness_protocol_error when the Hosted Harness refused to negotiate, a workspace close or lifecycle refusal (a workspace_close_* or workspace_lifecycle_* code, or runtime_admission_closed; a Runtime Broker runtime_broker_recovery_blocked refusal is reported as workspace_close_identity_unverified), or another Runtime Broker refusal code; one whose settle reached the Harness but stayed unsettled past the larger settled budget fails with session_close_completion_unsettled. A lifecycle recovery_blocked operation reports the refusal it waits on, original resource identity or unsettled execution for a Workspace close, session_close_writer_live while a Harness holds the journal writer or while that check itself fails, or hosted_harness_generation_mismatch while this replica's view of the Harness boot is stale; the Session remains closing. A submit_input failure carries its Turn's error code, and a recovery_blocked submit_input reports submit_input_unconfirmed; a cancel fails with turn_already_settled when its Turn completed or failed without being cancelled."
},
"replayed": {
"type": "boolean"
@@ -11269,6 +11317,35 @@
}
},
"description": "Canonical base64 image with a matching signature. The complete canonical input containing attachments is limited to 48 KiB."
},
"WebShellSkill": {
"type": "object",
"additionalProperties": false,
"required": ["name", "description"],
"properties": {
"name": {
"type": "string",
"maxLength": 128
},
"description": {
"type": "string",
"maxLength": 4096
}
}
},
"WebShellSessionSkills": {
"type": "object",
"additionalProperties": false,
"required": ["skills"],
"properties": {
"skills": {
"type": "array",
"maxItems": 16,
"items": {
"$ref": "#/components/schemas/WebShellSkill"
}
}
}
}
}
}
@@ -106,6 +106,9 @@ class HostedAutomationFaultGatesIT {
private HostedLifecycleRelay storeRelay;
private final AtomicReference<String> storeDrop = new AtomicReference<>();
private final List<String> storeCommits = new CopyOnWriteArrayList<>();
// Commits a crash case parks before the store sees them, then drops: the killed Harness
// never committed that revision.
private final Map<String, Hold> storeHolds = new ConcurrentHashMap<>();
private Path decoy;
private String node;
private int port;
@@ -383,6 +386,140 @@ class HostedAutomationFaultGatesIT {
assertNoModelFailure();
}
@Test
@Timeout(600)
void fg11cHarnessCrashesNeverRunTwiceOnMySql() throws Exception {
assumeTrue(anySelected("fg11c", "claim-crash", "turn-crash", "settled-crash"));
// A restarted Harness takes a Session over once its dead writer's lease lapses.
springExtras.add("--qwen.managed-agent.session-store.writer-lease-duration=15s");
storeRelayed = true;
boot("fg11c");
if (selected("fg11c", "claim-crash")) {
claimCrash();
}
if (selected("fg11c", "turn-crash")) {
turnCrash();
}
if (selected("fg11c", "settled-crash")) {
settledCrash();
}
assertThat(storeHolds).as("every store hold was reached").isEmpty();
assertThat(jdbc.queryForList("SELECT CONCAT(occurrence_key, ' ', reason) FROM ma_schedule_occurrence"
+ " WHERE tenant_id = ? AND outcome = 'unknown'", String.class, tenant))
.as("unknown occurrences").isEmpty();
assertNoModelFailure();
}
// claim-crash: the Harness dies between the run's claim (revision 1) and its dispatch
// (revision 2), which it was holding before the store. The scanner re-drives the same
// claim, the replay completes the dispatch on the reloaded Session, and the run executes
// once: one chain, one accepted input and one model Turn.
private void claimCrash() throws Exception {
String session = createSession("claim-crash");
String id = create(session, "FG11C_CLAIM", "0 0 1 1 *", "claim-crash-base");
String key = "claim-crash-run";
String runId = com.alibaba.qwen.code.managedagent.store.AutomationLedgerStore.automationRunId(id,
"manual:" + key);
Hold hold = storeHold("claim-crash", body -> (runId + ":2").equals(body.path("commandId").asText()));
var answer = java.util.concurrent.CompletableFuture.supplyAsync(() -> {
try {
return send("POST", "/v1/agent-automations/" + id + "/runs", null, key);
} catch (Exception error) {
throw new IllegalStateException(error);
}
}, modelExecutor);
assertThat(hold.reached().await(90, TimeUnit.SECONDS)).as("revision 2 reached the store").isTrue();
assertThat(commands(session, "fireRun")).containsExactly(runId + ":1");
long generation = coldRestart(session);
hold.release().countDown();
assertUnknown(answer.get(90, TimeUnit.SECONDS));
waiting().atMost(Duration.ofSeconds(120)).until(() -> "completed".equals(runState(runId)));
assertThat(writerGeneration(session)).as("the reloaded Session's writer").isGreaterThan(generation);
// The first fire never answered; each later re-drive meets the same run.
assertOneRun(session, id, runId, "manual:" + key, -1, 0);
}
// turn-crash: the Harness dies with the wake turn's model call in flight. The later slot's
// reconcile loads the Session, whose aftermath settles the run exactly once as
// failed/outcome_unknown: the input stays consumed and the turn is never re-run.
private void turnCrash() throws Exception {
String session = createSession("turn-crash");
// Park the scanner until the manual run reaches the crash point: a minute boundary
// during setup would otherwise admit a scheduled occurrence of its own.
scannerHold = SchedulerHold.hold(spring.getBean("managedAutomationScheduler",
ThreadPoolTaskScheduler.class), Duration.ofSeconds(30));
String id = create(session, "FG11C_HOLD", "* * * * *", "turn-crash-base");
String key = "turn-crash-run";
String runId = request("POST", "/v1/agent-automations/" + id + "/runs", null, key, 202)
.path("id").asText();
waiting().atMost(Duration.ofSeconds(90)).until(() -> modelPrompts.stream()
.anyMatch(prompt -> prompt.contains("Automation ID: " + id)));
assertThat(commands(session, "fireRun")).filteredOn(command -> command.startsWith(runId + ":"))
.containsExactly(runId + ":1", runId + ":2");
long generation = coldRestart(session);
// Let the next minute slot drive the reconcile now that the crash is in place.
scannerHold.countDown();
waiting().atMost(Duration.ofSeconds(210)).untilAsserted(() -> {
assertNoModelFailure();
assertThat(runState(runId)).isEqualTo("failed");
});
assertThat(writerGeneration(session)).as("the reloaded Session's writer").isGreaterThan(generation);
assertThat(commands(session, "settleRun")).filteredOn(command -> command.startsWith(runId + ":"))
.containsExactly(runId + ":3");
assertThat(events(session, "input.accepted")).filteredOn(event -> event.startsWith(runId + ":"))
.containsExactly(runId + ":input:accepted");
assertThat(modelPrompts).filteredOn(prompt -> prompt.contains("Automation ID: " + id)
&& prompt.contains("Occurrence: manual:" + key)).as("model Turns of the crashed run").hasSize(1);
request("DELETE", "/v1/agent-automations/" + id, null, "turn-crash-retire", 202);
}
// settled-crash: the turn settled, and the Harness died before revision 3 reached the
// store. On reload the run settles from the journal's turn.settled as completed, exactly
// once, and the input is not run again.
private void settledCrash() throws Exception {
String session = createSession("settled-crash");
// Park the scanner until the manual run reaches the crash point: a minute boundary
// during setup would otherwise admit a scheduled occurrence of its own.
scannerHold = SchedulerHold.hold(spring.getBean("managedAutomationScheduler",
ThreadPoolTaskScheduler.class), Duration.ofSeconds(30));
String id = create(session, "FG11C_SETTLED", "* * * * *", "settled-crash-base");
String key = "settled-crash-run";
String runId = com.alibaba.qwen.code.managedagent.store.AutomationLedgerStore.automationRunId(id,
"manual:" + key);
// Arm the hold before the POST: the Harness kicks the wake pump before answering
// fire_run, so revision 3 can otherwise reach the store before the hold exists.
Hold hold = storeHold("settled-crash", body -> (runId + ":3").equals(body.path("commandId").asText()));
assertThat(request("POST", "/v1/agent-automations/" + id + "/runs", null, key, 202)
.path("id").asText()).isEqualTo(runId);
assertThat(hold.reached().await(90, TimeUnit.SECONDS)).as("revision 3 reached the store").isTrue();
assertThat(commands(session, "fireRun")).filteredOn(command -> command.startsWith(runId + ":"))
.containsExactly(runId + ":1", runId + ":2");
assertThat(events(session, "turn.settled")).filteredOn(event -> event.contains(runId))
.containsExactly("turn:" + runId + ":input");
long generation = coldRestart(session);
hold.release().countDown();
// Let the next minute slot drive the reconcile now that the crash is in place.
scannerHold.countDown();
waiting().atMost(Duration.ofSeconds(210)).untilAsserted(() -> {
assertNoModelFailure();
assertThat(runState(runId)).isEqualTo("completed");
});
assertThat(writerGeneration(session)).as("the reloaded Session's writer").isGreaterThan(generation);
assertThat(commands(session, "settleRun")).filteredOn(command -> command.startsWith(runId + ":"))
.containsExactly(runId + ":3");
assertThat(events(session, "input.accepted")).filteredOn(event -> event.startsWith(runId + ":"))
.containsExactly(runId + ":input:accepted");
assertThat(modelPrompts).filteredOn(prompt -> prompt.contains("Automation ID: " + id)
&& prompt.contains("Occurrence: manual:" + key)).as("model Turns of the settled run").hasSize(1);
request("DELETE", "/v1/agent-automations/" + id, null, "settled-crash-retire", 202);
}
private Hold storeHold(String name, Predicate<JsonNode> rule) {
Hold hold = new Hold(rule, new CountDownLatch(1), new CountDownLatch(1));
assertThat(storeHolds.putIfAbsent(name, hold)).as(name).isNull();
return hold;
}
// revision-1 / revision-2: the journal refuses one revision of a manual run's fire. The
// Harness's store client gives up after its bounded retry, the manual run answers a
// retryable 503, and the Session stops writing: once the refusal drops, the same key still
@@ -825,7 +962,13 @@ class HostedAutomationFaultGatesIT {
waiting().atMost(Duration.ofSeconds(60)).until(() -> "completed".equals(runState(runId)));
var fired = operations.stream().filter(operation -> scheduleId.equals(operation.scheduleId())
&& "fire_run".equals(operation.kind()) && occurrenceKey.equals(operation.occurrenceKey())).toList();
assertThat(fired).as("fires of %s", occurrenceKey).hasSize(fires);
// A negative count asks only that the occurrence was fired at least once: a crash
// case cannot know how many transient refusals its re-drive met first.
if (fires < 0) {
assertThat(fired).as("fires of %s", occurrenceKey).isNotEmpty();
} else {
assertThat(fired).as("fires of %s", occurrenceKey).hasSize(fires);
}
assertThat(fired).filteredOn(Operation::dropped).hasSize(dropped);
assertThat(commands(session, "fireRun")).filteredOn(command -> command.startsWith(runId + ":"))
.containsExactly(runId + ":1", runId + ":2");
@@ -942,6 +1085,11 @@ class HostedAutomationFaultGatesIT {
storeRelay = new HostedLifecycleRelay();
storeRelay.backend(URI.create("http://127.0.0.1:" + port));
storeRelay.fault(new HostedLifecycleRelay.Fault() {
@Override
public boolean before(HostedLifecycleRelay.Request request) throws Exception {
return storeHeld(request);
}
@Override
public boolean after(HostedLifecycleRelay.Request request, int status) throws Exception {
return storeAnswered(request, status);
@@ -983,6 +1131,23 @@ class HostedAutomationFaultGatesIT {
return !drop;
}
// Parks the armed journal commit before the store sees it, then drops it: the Harness that
// sent it is killed while it waits, and the revision never commits.
private boolean storeHeld(HostedLifecycleRelay.Request request) throws Exception {
if (!"POST".equals(request.method()) || !request.path().contains("/transactions:commit")) {
return true;
}
JsonNode body = json.readTree(request.body());
for (var hold : storeHolds.entrySet()) {
if (hold.getValue().rule().test(body) && storeHolds.remove(hold.getKey()) != null) {
hold.getValue().reached().countDown();
assertThat(hold.getValue().release().await(90, TimeUnit.SECONDS)).as(hold.getKey()).isTrue();
return false;
}
}
return true;
}
// Parks the first automation operation a hold rule matches until its case releases it, then
// forwards it.
private boolean held(HostedLifecycleRelay.Request request) throws Exception {
@@ -1166,8 +1331,13 @@ class HostedAutomationFaultGatesIT {
}
}
modelPrompts.add(prompt);
if (prompt.contains("FG11A_HOLD")) {
heldModel.await(120, TimeUnit.SECONDS);
// FG11c's turn-crash holds only its manual run's wake turn: later slots of the
// same definition must keep running while the crashed one is reconciled.
if (prompt.contains("FG11A_HOLD")
|| (prompt.contains("FG11C_HOLD") && prompt.contains("Occurrence: manual:"))) {
// Outlive the whole method: a crash case releases this in teardown, and a
// timeout here would surface as a broken pipe during the NEXT case.
heldModel.await(900, TimeUnit.SECONDS);
}
var chunk = json.createObjectNode().put("id", "fg11").put("object", "chat.completion.chunk")
.put("created", 1).put("model", "fg11-fixture");
@@ -1193,6 +1363,9 @@ class HostedAutomationFaultGatesIT {
assertThat(modelFailure.get()).as("model stub").isNull();
assertThat(harnessRelay.failure()).as("Harness relay").isNull();
assertThat(publicRelay.failure()).as("public relay").isNull();
if (storeRelay != null) {
assertThat(storeRelay.failure()).as("store relay").isNull();
}
}
private JsonNode request(String method, String path, Object body, String key, int expected) throws Exception {
@@ -1256,6 +1429,9 @@ class HostedAutomationFaultGatesIT {
scannerHold.countDown();
}
holds.values().forEach(hold -> hold.release().countDown());
// A crash case that failed before its SIGKILL must not leave the relay waiting on a
// commit the store will never see.
storeHolds.values().forEach(hold -> hold.release().countDown());
Throwable failure = null;
List<org.junit.jupiter.api.function.Executable> cleanup = List.of(
this::writeEvidence,
@@ -0,0 +1,541 @@
package com.alibaba.qwen.code.managedagent;
import com.alibaba.qwen.code.daemon.HarnessSessionRef;
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
import com.alibaba.qwen.code.managedagent.store.WorkspaceStorageGuard;
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.apache.commons.codec.digest.DigestUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.CleanupMode;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertAll;
// FG13a (issues #14054/#14449): lost replies for the managed command Hook.
// The dispatch reply is dropped after the worker admitted the operation, so
// the retry must resolve the same operation through the owner's status, keep
// exactly one physical call, and apply its single result.
@EnabledOnOs(OS.LINUX)
class HostedHookReplyLossFaultGatesIT {
private static final String PROFILE = "hosted-workspace-files/1";
private static final String TOKEN = "hook-fault-fixture-token";
private static final String CAPABILITY = "sha256:" + "a".repeat(64);
private static final String PROOF = "FG13_HOOK_TOOL_PROOF";
private final ObjectMapper json = new ObjectMapper();
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
private final String tenant = "hook-fault-" + UUID.randomUUID();
private final List<JsonNode> modelRequests = new CopyOnWriteArrayList<>();
private final AtomicReference<Throwable> modelFailure = new AtomicReference<>();
@TempDir(cleanup = CleanupMode.ON_SUCCESS)
private Path temporary;
private Path cli;
private Path workspace;
private Path evidence;
private Path hookRoot;
private String node;
private String hookDigest;
private int port;
private int harnessPort;
private int brokerPort;
private int boots;
private HttpServer model;
private ExecutorService modelExecutor;
private ServletWebServerApplicationContext spring;
private JdbcTemplate jdbc;
private Process harness;
private HostedSessionRelayForwardingProxy proxy;
@Test
@Timeout(300)
void fg13aExecuteReplyLossKeepsOnePhysicalCallOnMySql() throws Exception {
boot("60000", "1000", "hook-solo");
proxy.arm(HostedSessionRelayForwardingProxy.Rule.drop(
(method, path, body) -> path.contains("/control") && body.contains("\"kind\":\"hook-execute\""),
"hook-execute-reply"));
JsonNode definition = api("POST", "/v1/agents", definition(), "definition", 202);
String session = createSession(definition.path("id").asText(), "initial");
submit(session, "FG13_EXECUTE_LOSS");
waitFor(() -> assertThat(events("hook.call")).hasSize(1));
// The dispatch reply is gone, but the retry resolves the same
// operation through the owner's status: one physical call, one allow.
waitFor(() -> {
JsonNode execution = hookExecution(session);
assertThat(execution.at("/run/state").asText()).isEqualTo("settled");
assertThat(execution.at("/run/execution").asText()).isEqualTo("settled");
});
assertThat(events("hook.call")).hasSize(1);
assertThat(events("hook.allow")).hasSize(1);
assertThat(hookResult(session).path("outcome").asText()).isEqualTo("success");
waitFor(() -> assertThat(modelRequests.toString()).contains(PROOF));
// The completion request must carry exactly one result for the call
// the fixture emitted; a duplicate delivery would otherwise pass.
long results = 0;
for (JsonNode message : modelRequests.getLast().path("messages")) {
if ("tool".equals(message.path("role").asText())
&& "call_FG13_EXECUTE_LOSS".equals(message.path("tool_call_id").asText())) {
results++;
}
}
assertThat(results).as("tool results for the emitted call").isEqualTo(1);
assertThat(Files.readString(temporary.resolve("proxy.log"))).contains("PROXY dropped hook-execute-reply");
assertThat(modelFailure.get()).isNull();
}
private void boot(String timeoutMs, String holdMs, String signalMode) throws Exception {
assertThat(System.getProperty("mysql.url")).as("Hook fault gates require disposable MySQL 8.4")
.startsWith("jdbc:mysql:");
node = System.getProperty("node.executable");
assertThat(node).isNotBlank();
cli = Path.of(System.getProperty("qwen.cli.entry", "../../dist/cli.js")).toAbsolutePath().normalize();
assertThat(cli).isRegularFile();
temporary = temporary.toRealPath();
workspace = Files.createDirectories(temporary.resolve("workspace/child"));
Files.writeString(workspace.resolve("proof.txt"), PROOF);
Files.setLastModifiedTime(workspace.getParent(), FileTime.fromMillis(1));
Files.createDirectory(temporary.resolve("decoy"));
Files.createDirectory(temporary.resolve("broker"),
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")));
evidence = temporary.resolve("processes.jsonl");
String delegated = System.getenv("QWEN_MANAGED_HOOK_CGROUP_ROOT");
assertThat(delegated).as("Run Maven inside a delegated Linux cgroup v2 subtree").isNotBlank();
hookRoot = Files.createDirectory(Path.of(delegated).resolve("hook-fault-" + UUID.randomUUID()));
Path program = temporary.resolve("hook-fault-process.mjs");
try (var resource = getClass().getResourceAsStream("/hosted-hook-fault-process.mjs")) {
assertThat(resource).isNotNull();
Files.copy(resource, program);
}
hookDigest = DigestUtils.sha256Hex("hook:" + Files.readString(program));
Map<String, Object> hook = new java.util.LinkedHashMap<>(Map.of("hookId", "pre", "eventName", "PreToolUse",
"sequential", false, "failClosed", true, "async", false, "config", Map.of("type", "command", "command",
quote(node) + " " + quote(program.toString()) + " hook " + quote(evidence.toString())
+ " " + quote(holdMs) + " " + quote(signalMode), "timeout", Integer.parseInt(timeoutMs))));
hook.put("onceKey", null);
json.writeValue(temporary.resolve("hooks.json").toFile(), Map.of("version", 1, "catalogs", List.of(
Map.of("tenantId", tenant, "workspaceId", "workspace", "catalogId", "fg13-hooks",
"catalogRevision", 1, "definitionDigest", catalogDigest(), "hooks", List.of(hook)))));
Path worker = temporary.resolve("worker.mjs");
Files.writeString(worker, "process.env.QWEN_MANAGED_HOOK_CONFIG="
+ json.writeValueAsString(temporary.resolve("hooks.json").toString())
+ "; process.env.QWEN_MANAGED_HOOK_CGROUP_ROOT=" + json.writeValueAsString(hookRoot.toString())
+ "; process.argv[1]=" + json.writeValueAsString(cli.toString())
+ "; await import(" + json.writeValueAsString(cli.toUri().toString()) + ");\n");
try (var control = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"));
var hosted = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"));
var runtime = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
port = control.getLocalPort();
harnessPort = hosted.getLocalPort();
brokerPort = runtime.getLocalPort();
}
model = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
modelExecutor = Executors.newVirtualThreadPerTaskExecutor();
model.setExecutor(modelExecutor);
model.createContext("/v1/chat/completions", this::modelReply);
model.start();
spring = (ServletWebServerApplicationContext) new SpringApplicationBuilder(ManagedAgentServerApplication.class)
.run("--server.address=127.0.0.1", "--server.port=" + port,
"--spring.datasource.url=" + System.getProperty("mysql.url"),
"--spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
"--spring.datasource.username=" + System.getProperty("mysql.user"),
"--spring.datasource.password=" + System.getProperty("mysql.password", ""),
"--qwen.managed-agent.trusted-actor-header=X-Qwen-Actor-Id",
"--qwen.managed-agent.definition-tool-profiles=" + PROFILE,
"--qwen.managed-agent.session-store.enabled=true",
"--qwen.managed-agent.session-store.base-url=http://127.0.0.1:" + port,
"--qwen.managed-agent.session-store.workspace-id=unused-global-workspace",
"--qwen.managed-agent.session-store.writer-lease-duration=3s",
"--qwen.managed-agent.harness.enabled=true",
"--qwen.managed-agent.harness.workspace-files-enabled=true",
"--qwen.managed-agent.harness.base-url=http://127.0.0.1:" + harnessPort,
"--qwen.managed-agent.harness.token=" + TOKEN,
"--qwen.managed-agent.harness.capability-digest=" + CAPABILITY,
"--qwen.managed-agent.runtime-broker.enabled=true",
"--qwen.managed-agent.runtime-broker.port=" + brokerPort,
"--qwen.managed-agent.runtime-broker.token=" + TOKEN,
"--qwen.managed-agent.runtime-broker.durable-local-process=true",
"--qwen.managed-agent.runtime-broker.trusted-local-reboot-recovery=false",
"--qwen.managed-agent.runtime-broker.verified-workspace-recovery-enabled=true",
"--qwen.managed-agent.runtime-broker.workspace-cwd=" + temporary.resolve("decoy"),
"--qwen.managed-agent.runtime-broker.state-directory=" + temporary.resolve("broker"),
"--qwen.managed-agent.runtime-broker.credential-key-id=hook-fault",
"--qwen.managed-agent.runtime-broker.credential-key=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"--qwen.managed-agent.runtime-broker.node-executable=" + node,
"--qwen.managed-agent.runtime-broker.worker-entry=" + worker,
"--qwen.managed-agent.runtime-broker.workspace-mounts[0].tenant-id=" + tenant,
"--qwen.managed-agent.runtime-broker.workspace-mounts[0].storage-id=storage",
"--qwen.managed-agent.runtime-broker.workspace-mounts[0].root=" + workspace.getParent());
jdbc = spring.getBean(JdbcTemplate.class);
spring.getBean(WorkspaceStorageGuard.class).register(tenant, "storage", UUID.randomUUID().toString());
jdbc.update("INSERT INTO ma_workspace (tenant_id, workspace_id, workspace_generation, storage_id,"
+ " display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, 'storage', 'Hooks', ?, ?, 'ACTIVE')",
tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
jdbc.update("INSERT INTO ma_workspace_access (tenant_id, workspace_id, actor_id, role)"
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, "actor".getBytes(StandardCharsets.UTF_8));
proxy = HostedSessionRelayForwardingProxy.start("hook-broker", temporary.resolve("proxy.log"));
proxy.forwardTo(URI.create("http://127.0.0.1:" + brokerPort));
startHarness();
}
private String catalogDigest() {
return DigestUtils.sha256Hex(hookDigest + 1);
}
private Map<String, Object> definition() {
return Map.of("model", Map.of(), "instructions", "Exercise the pinned Hook gate.",
"tools", List.of(Map.of("type", "hosted_profile", "profile", PROFILE)),
"permission_policy", Map.of("approval_mode", "yolo"),
"hook_catalog", Map.of("catalog_id", "fg13-hooks", "catalog_revision", 1,
"definition_digest", catalogDigest()));
}
private String createSession(String agent, String key) throws Exception {
return api("POST", "/v1/agents/sessions", Map.of("agent_id", agent, "input", List.of(),
"workspace", Map.of("workspace_id", "workspace", "cwd_relative", "child")), key, 202)
.path("id").asText();
}
private String submit(String session, String prompt) throws Exception {
return api("POST", "/v1/agents/sessions/" + session + "/events", Map.of("type", "agent.session.input.message",
"input", List.of(Map.of("type", "input_text", "text", prompt))), prompt, 202).path("turn_id").asText();
}
private JsonNode hookExecution(String session) {
List<JsonNode> records = HostedGateTestSupport.extensionRecords(jdbc, tenant, session, "hook_execution").stream()
.filter(record -> !"__plan__".equals(record.path("hookId").asText()))
.toList();
assertThat(records).as("hook execution records").hasSize(1);
assertThat(records.getFirst().path("eventName").asText()).isEqualTo("PreToolUse");
return records.getFirst();
}
private JsonNode hookResult(String session) throws Exception {
JsonNode ref = hookExecution(session).path("resultRef");
assertThat(ref.isNull()).as("hook result ref").isFalse();
String body = jdbc.queryForObject("SELECT CONVERT(inline_bytes USING utf8mb4) FROM ma_resource"
+ " WHERE session_scope_key = ? AND resource_id = ? AND tenant_id = ? AND session_id = ?",
String.class, ManagedSessionStore.sessionScopeKey(tenant, session),
ref.path("resourceId").asText(), tenant, session);
return json.readTree(body);
}
@Test
@Timeout(300)
void fg13aStatusReplyLossStaysUnknownUntilTheNextPollOnMySql() throws Exception {
boot("60000", "1000", "hook-solo");
proxy.arm(HostedSessionRelayForwardingProxy.Rule.drop(
(method, path, body) -> path.contains("/control") && body.contains("\"kind\":\"hook-status\""),
"hook-status-reply"));
JsonNode definition = api("POST", "/v1/agents", definition(), "definition", 202);
String session = createSession(definition.path("id").asText(), "initial");
submit(session, "FG13_STATUS_LOSS");
waitFor(() -> assertThat(events("hook.call")).hasSize(1));
// The dropped poll answer cannot prove the end, so the gate records
// the honest unknown instead of guessing.
waitFor(() -> {
JsonNode execution = hookExecution(session);
assertThat(execution.at("/run/state").asText()).isEqualTo("recovery_blocked");
assertThat(execution.at("/run/execution").asText()).isEqualTo("outcome_unknown");
});
assertThat(events("hook.call")).hasSize(1);
// A later poll of the same owner resolves the receipt the worker
// still holds, without a second physical call.
String executionId = hookExecution(session).path("hookExecutionId").asText();
HarnessAttach attach = attachment(spring.getBean(HarnessConnector.class), session);
assertThat(attach).as("Session attachment").isNotNull();
waitFor(() -> {
var status = hookStatus(session, executionId, attach);
assertThat(status.statusCode()).as("hook status: %s", status.body()).isEqualTo(200);
assertThat(json.readTree(status.body()).path("state").asText()).isEqualTo("settled");
});
waitFor(() -> assertThat(hookExecution(session).at("/run/state").asText()).isEqualTo("settled"));
assertThat(hookResult(session).path("outcome").asText()).isEqualTo("success");
// The allow lands when the one-second hold ends, after the first
// unknown observation, so it is asserted only past the settlement.
waitFor(() -> assertThat(events("hook.allow")).hasSize(1));
assertThat(events("hook.call")).hasSize(1);
assertThat(Files.readString(temporary.resolve("proxy.log"))).contains("PROXY dropped hook-status-reply");
assertThat(modelFailure.get()).isNull();
}
// The Harness status route identifies the caller by the client id its
// attach minted and the process generation it attached to; the connector
// keeps both on the private attachment ref.
private record HarnessAttach(String clientId, String bootId) {}
private HarnessAttach attachment(HarnessConnector connector, String session) throws Exception {
Field field = connector.getClass().getDeclaredField("attachments");
field.setAccessible(true);
for (Object value : ((Map<?, ?>) field.get(connector)).values()) {
if (value instanceof HarnessSessionRef ref && session.equals(ref.getHarnessSessionId())) {
return new HarnessAttach(ref.getHarnessClientId(), ref.getHarnessBootId());
}
}
return null;
}
private HttpResponse<String> hookStatus(String session, String executionId, HarnessAttach attach) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + harnessPort
+ "/session/" + session + "/hooks/operations/" + executionId))
.timeout(Duration.ofSeconds(10)).header("Authorization", "Bearer " + TOKEN)
.header("X-Qwen-Client-Id", attach.clientId())
.header("X-Qwen-Harness-Protocol-Version", "1")
.header("X-Qwen-Harness-Boot-Id", attach.bootId()).GET().build();
return http.send(request, HttpResponse.BodyHandlers.ofString());
}
private void startHarness() throws Exception {
Path home = Files.createDirectory(temporary.resolve("home-" + ++boots));
Path config = Files.createDirectory(home.resolve(".qwen"));
String modelUrl = "http://127.0.0.1:" + model.getAddress().getPort() + "/v1";
json.writeValue(config.resolve("settings.json").toFile(), Map.of("security", Map.of("auth", Map.of("selectedType", "openai")),
"telemetry", Map.of("enabled", false), "model", Map.of("name", "hook-fault-fixture"),
"modelProviders", Map.of("openai", List.of(Map.of("id", "hook-fault-fixture", "envKey", "OPENAI_API_KEY", "baseUrl", modelUrl)))));
ProcessBuilder builder = new ProcessBuilder(node, cli.toString(), "serve", "--profile", "hosted-harness",
"--http-bridge", "--hostname", "127.0.0.1", "--port", Integer.toString(harnessPort), "--require-auth",
"--no-web", "--workspace", temporary.resolve("decoy").toString(), "--managed-runtime-broker-url",
proxy.baseUrl(), "--managed-runtime-broker-token", TOKEN)
.directory(temporary.resolve("decoy").toFile()).redirectErrorStream(true)
.redirectOutput(temporary.resolve("harness-" + boots + ".log").toFile());
var env = builder.environment();
env.clear();
env.put("PATH", System.getenv("PATH"));
env.putAll(Map.of("HOME", home.toString(), "QWEN_HOME", config.toString(), "QWEN_RUNTIME_DIR", home.resolve("runtime").toString(),
"OPENAI_API_KEY", "fixture-only", "OPENAI_BASE_URL", modelUrl, "QWEN_SERVER_TOKEN", TOKEN,
"QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST", CAPABILITY,
"QWEN_CODE_MODELS_DEV_REFRESH", "off"));
for (String key : List.of("QWEN_CODE_SYSTEM_SETTINGS_PATH", "QWEN_CODE_SYSTEM_DEFAULTS_PATH", "QWEN_CODE_TRUSTED_FOLDERS_PATH")) {
env.put(key, home.resolve(key + ".json").toString());
}
harness = builder.start();
waitFor(() -> {
assertThat(harness.isAlive()).as("Harness log: %s", temporary.resolve("harness-" + boots + ".log")).isTrue();
assertThat(http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + harnessPort + "/capabilities"))
.timeout(Duration.ofSeconds(2)).header("Authorization", "Bearer " + TOKEN).build(),
HttpResponse.BodyHandlers.discarding()).statusCode()).isEqualTo(200);
});
}
private void modelReply(HttpExchange exchange) throws IOException {
try {
JsonNode request = json.readTree(exchange.getRequestBody());
modelRequests.add(request);
String prompt = "";
List<JsonNode> results = new ArrayList<>();
for (JsonNode message : request.path("messages")) {
if ("user".equals(message.path("role").asText()) && message.path("content").toString().contains("FG13_")) {
String text = message.path("content").toString();
var matcher = java.util.regex.Pattern.compile("FG13_[A-Z_]+").matcher(text);
prompt = "";
while (matcher.find()) {
prompt = matcher.group();
}
results.clear();
} else if ("tool".equals(message.path("role").asText())) {
results.add(message);
}
}
assertThat(prompt).isNotBlank();
List<String> names = new ArrayList<>();
request.path("tools").forEach(tool -> names.add(tool.at("/function/name").asText()));
assertThat(names).contains("read_file");
boolean call = results.isEmpty()
&& List.of("FG13_EXECUTE_LOSS", "FG13_STATUS_LOSS").contains(prompt);
var chunk = json.createObjectNode().put("id", "hook-fault").put("object", "chat.completion.chunk")
.put("created", 0).put("model", "hook-fault-fixture");
var choice = chunk.putArray("choices").addObject().put("index", 0);
var delta = choice.putObject("delta").put("role", "assistant");
if (call) {
delta.putArray("tool_calls").addObject().put("index", 0).put("id", "call_" + prompt)
.put("type", "function").putObject("function").put("name", "read_file")
.put("arguments", json.writeValueAsString(Map.of("file_path", "proof.txt")));
} else {
delta.put("content", "FG13_DONE_" + prompt);
}
choice.putNull("finish_reason");
String first = "data: " + json.writeValueAsString(chunk) + "\n\n";
choice.putObject("delta");
choice.put("finish_reason", call ? "tool_calls" : "stop");
byte[] response = (first + "data: " + json.writeValueAsString(chunk) + "\n\ndata: [DONE]\n\n")
.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.sendResponseHeaders(200, response.length);
exchange.getResponseBody().write(response);
} catch (Exception | AssertionError failure) {
modelFailure.compareAndSet(null, failure);
} finally {
exchange.close();
}
}
private boolean running(JsonNode process) throws Exception {
if (!Files.readString(Path.of("/proc/sys/kernel/random/boot_id")).strip().equals(process.path("bootId").asText())) {
return false;
}
Path stat = Path.of("/proc", process.path("pid").asText(), "stat");
String value;
try {
value = Files.readString(stat);
} catch (NoSuchFileException stopped) {
return false;
}
String[] fields = value.substring(value.lastIndexOf(')') + 2).split(" ");
return !"Z".equals(fields[0]) && fields[19].equals(process.path("startTicks").asText());
}
private Path cgroup(JsonNode process) {
Path path = Path.of("/sys/fs/cgroup" + process.path("cgroup").asText().substring(3));
assertThat(path).startsWith(hookRoot);
return path;
}
private List<JsonNode> events(String kind) throws Exception {
if (!Files.exists(evidence)) {
return List.of();
}
List<JsonNode> result = new ArrayList<>();
for (String line : Files.readAllLines(evidence)) {
JsonNode event = json.readTree(line);
if (kind.equals(event.path("event").asText())) {
result.add(event);
}
}
return result;
}
private JsonNode api(String method, String path, Object body, String key, int... expected) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
.timeout(Duration.ofSeconds(60)).header("X-Qwen-Tenant-Id", tenant).header("X-Qwen-Actor-Id", "actor")
.header("Content-Type", "application/json").method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)));
if (key != null) {
request.header("Idempotency-Key", key);
}
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).as("%s %s: %s", method, path, response.body()).isIn(java.util.Arrays.stream(expected).boxed().toList());
return json.readTree(response.body());
}
private void waitFor(org.junit.jupiter.api.function.Executable assertion) {
await().atMost(Duration.ofSeconds(60)).ignoreException(java.net.ConnectException.class).failFast(() -> {
if (modelFailure.get() != null) {
throw new AssertionError("Model fixture: " + temporary, modelFailure.get());
}
}).untilAsserted(assertion::execute);
}
private static String quote(String text) {
return "'" + text.replace("'", "'\"'\"'") + "'";
}
@AfterEach
void stop() {
assertAll(this::saveEvidence,
() -> { if (model != null) {
model.stop(0);
} },
() -> { if (modelExecutor != null) {
modelExecutor.close();
} },
() -> { if (harness != null) {
harness.destroyForcibly();
assertThat(harness.waitFor(10, TimeUnit.SECONDS)).isTrue();
} },
() -> { if (evidence != null && Files.exists(evidence)) {
for (String kind : List.of("hook.call", "hook.child")) {
for (JsonNode process : events(kind)) {
if (running(process)) {
ProcessHandle.of(process.path("pid").asLong()).orElseThrow().destroyForcibly();
await().atMost(Duration.ofSeconds(10)).until(() -> !running(process));
}
}
}
} },
() -> { if (spring != null) {
spring.close();
} },
() -> HostedDurableWorkers.stop(temporary.resolve("broker"), json),
() -> {
if (hookRoot == null) {
return;
}
try (var units = Files.list(hookRoot)) {
for (Path unit : units.toList()) {
if (Files.exists(unit.resolve("cgroup.kill"))) {
Files.writeString(unit.resolve("cgroup.kill"), "1");
}
}
}
try (var entries = Files.walk(hookRoot)) {
for (Path entry : entries.filter(Files::isDirectory).sorted(Comparator.reverseOrder()).toList()) {
Files.delete(entry);
}
}
}, () -> {
if (proxy != null) {
proxy.close();
}
}, http::close);
}
private void saveEvidence() throws Exception {
if (temporary == null) {
return;
}
Path destination = Files.createDirectories(Path.of("target/failsafe-reports/hosted-hook-fault-evidence", tenant));
System.out.println("HOSTED_HOOK_FAULT_EVIDENCE " + destination.toAbsolutePath());
json.writeValue(destination.resolve("model-requests.json").toFile(), modelRequests);
for (String name : List.of("processes.jsonl", "harness-1.log", "harness-2.log", "proxy.log")) {
Path source = temporary.resolve(name);
if (Files.exists(source)) {
Files.copy(source, destination.resolve(source.getFileName()));
}
}
if (jdbc != null) {
json.writeValue(destination.resolve("bindings.json").toFile(), jdbc.queryForList(
"SELECT binding_id, runtime_generation, binding_state, isolation_key FROM ma_runtime_binding WHERE tenant_id = ?", tenant));
json.writeValue(destination.resolve("hook-executions.json").toFile(),
jdbc.queryForList("SELECT record_id, domain, state, runtime_state FROM ma_session_record"
+ " WHERE tenant_id = ? AND domain = 'hook_execution'", tenant));
}
}
}
@@ -96,6 +96,10 @@ class HostedMcpRestartFaultGatesIT {
Executors.newVirtualThreadPerTaskExecutor();
private volatile CountDownLatch controlHold;
private volatile String controlHoldKind;
private volatile CountDownLatch replyHold;
private volatile String replyHoldKind;
private volatile String replyHoldTarget;
private volatile CountDownLatch replyParked;
@TempDir(cleanup = CleanupMode.ON_SUCCESS)
private Path temporary;
private Path cli;
@@ -112,6 +116,7 @@ class HostedMcpRestartFaultGatesIT {
private HttpServer model;
private ExecutorService modelExecutor;
private HttpServer brokerProxy;
private ExecutorService brokerExecutor;
private ServletWebServerApplicationContext spring;
private JdbcTemplate jdbc;
private ManagedAgentStore store;
@@ -386,15 +391,42 @@ class HostedMcpRestartFaultGatesIT {
settle.cancel(true);
SessionRef reloaded = reload(session, closeId, claimGeneration);
// The conclusive answer is the settled `mcp-status`, not the release
// dispatch, which legally answers `running`. Park that reply inside
// the proxy: the record must stay `releasing` and the close route
// must stay unanswered until the Harness receives it.
holdSettledReply("mcp-status", releaseId);
HttpResponse<String> settled = null;
for (int attempt = 0; attempt < 60; attempt++) {
settled = rawLifecycle(reloaded, closeId, claimGeneration,
"settle_resources");
if (settled.statusCode() == 200) {
for (int attempt = 0; attempt < 60 && settled == null; attempt++) {
Future<HttpResponse<String>> pending = caseExecutor.submit(
() -> rawLifecycle(reloaded, closeId, claimGeneration,
"settle_resources"));
await().atMost(Duration.ofSeconds(60)).until(() ->
replyParked.getCount() == 0 || pending.isDone());
if (replyParked.getCount() == 0) {
try {
assertThat(pending.isDone())
.as("settle must wait for the held status reply")
.isFalse();
assertThat(configuration(session.id()).path("releaseState")
.asText())
.as("released must not be committed before the "
+ "settled answer reaches the Harness")
.isEqualTo("releasing");
} finally {
releaseReply();
}
settled = pending.get(30, TimeUnit.SECONDS);
break;
}
if (settled.statusCode() == 409) {
JsonNode refusal = json.readTree(settled.body());
HttpResponse<String> response = pending.get(30,
TimeUnit.SECONDS);
if (response.statusCode() == 200) {
settled = response;
break;
}
if (response.statusCode() == 409) {
JsonNode refusal = json.readTree(response.body());
String code = refusal.path("error").asText(
refusal.path("code").asText());
if ("hosted_turn_active".equals(code)) {
@@ -402,12 +434,17 @@ class HostedMcpRestartFaultGatesIT {
continue;
}
}
assertThat(settled.statusCode()).as("settle: %s", settled.body())
.isEqualTo(503);
assertThat(response.statusCode()).as("settle: %s",
response.body()).isEqualTo(503);
Thread.sleep(1_000);
}
assertThat(settled).as("settle must complete").isNotNull();
assertThat(settled.statusCode()).as("settle: %s", settled.body())
.isEqualTo(200);
assertThat(replyParked.getCount())
.as("the settled status reply must be parked, or the ordering "
+ "witness never ran")
.isZero();
JsonNode released = configuration(session.id());
assertThat(released.path("releaseState").asText())
@@ -645,6 +682,27 @@ class HostedMcpRestartFaultGatesIT {
}
}
// A release dispatch answers `running`; only the later `mcp-status`
// lookup is conclusive. Parking that answer inside the proxy separates
// "the Broker settled it" from "the Harness received it", which is what
// proves `released` cannot be committed before it.
private void holdSettledReply(String kind, String targetOperationId) {
replyHoldKind = kind;
replyHoldTarget = targetOperationId;
replyParked = new CountDownLatch(1);
replyHold = new CountDownLatch(1);
}
private void releaseReply() {
CountDownLatch latch = replyHold;
replyHold = null;
replyHoldKind = null;
replyHoldTarget = null;
if (latch != null) {
latch.countDown();
}
}
private String writeRelease() throws IOException {
Path release = Path.of(evidence + ".release");
Files.createFile(release);
@@ -1166,6 +1224,8 @@ class HostedMcpRestartFaultGatesIT {
private void startBrokerProxy() throws IOException {
brokerProxy = HttpServer.create(new InetSocketAddress("127.0.0.1", 0),
0);
brokerExecutor = Executors.newVirtualThreadPerTaskExecutor();
brokerProxy.setExecutor(brokerExecutor);
brokerProxy.createContext("/", this::relay);
brokerProxy.start();
}
@@ -1238,13 +1298,34 @@ class HostedMcpRestartFaultGatesIT {
HttpResponse.BodyHandlers.ofByteArray());
proxyCalls.set(index, call.complete(response.statusCode()));
entry.put("status", response.statusCode());
JsonNode answer;
try {
entry.put("answer", json.readTree(response.body()));
answer = json.readTree(response.body());
} catch (IOException unparsable) {
entry.put("answer", new String(response.body(),
StandardCharsets.UTF_8));
answer = null;
}
entry.put("answer", answer != null ? answer
: new String(response.body(), StandardCharsets.UTF_8));
wire.add(entry);
CountDownLatch reply = replyHold;
if (reply != null && parsed != null && replyHoldKind != null
&& replyHoldKind.equals(kind)
&& replyHoldTarget != null
&& replyHoldTarget.equals(parsed.at(
"/operation/targetOperationId").asText())
&& answer != null
&& "settled".equals(answer.path("result").path("state")
.asText())) {
CountDownLatch parked = replyParked;
if (parked != null) {
parked.countDown();
}
try {
reply.await(120, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
exchange.getResponseHeaders().set("Content-Type",
"application/json");
exchange.sendResponseHeaders(response.statusCode(),
@@ -1343,6 +1424,9 @@ class HostedMcpRestartFaultGatesIT {
spring.close();
}
} finally {
if (brokerExecutor != null) {
brokerExecutor.shutdownNow();
}
if (brokerProxy != null) {
brokerProxy.stop(0);
}
@@ -263,6 +263,8 @@ class ManagedAgentApiContractTest {
entry(ApiModels.WebShellApprovalSelection.class, List.of("WebShellApprovalSelection")),
entry(ApiModels.WebShellSessionModelsRequest.class, List.of("WebShellSessionModelsRequest")),
entry(ApiModels.WebShellSessionModels.class, List.of("WebShellSessionModels")),
entry(ApiModels.WebShellSkill.class, List.of("WebShellSkill")),
entry(ApiModels.WebShellSessionSkills.class, List.of("WebShellSessionSkills")),
entry(ApiModels.WebShellChangeModelRequest.class, List.of("WebShellChangeModelRequest")),
entry(ApiModels.WebShellModelChangeResult.class, List.of("WebShellModelChangeResult")),
entry(WebShellSessionCapabilities.class,
@@ -1282,6 +1284,12 @@ class ManagedAgentApiContractTest {
exchange(drift, "queryWebShellSessionModels", 200,
post(WEB_SHELL + "/session-models/query").header(TENANT, workspaceTenant).principal(actor),
"{\"sessionId\":\"" + webBoundId + "\"}");
exchange(drift, "queryWebShellSessionSkills", 200,
post(WEB_SHELL + "/session-skills/query").header(TENANT, workspaceTenant).principal(actor),
"{\"sessionId\":\"" + webBoundId + "\"}");
exchange(drift, "queryWebShellSessionSkills", 401,
post(WEB_SHELL + "/session-skills/query").header(TENANT, workspaceTenant),
"{\"sessionId\":\"" + webBoundId + "\"}");
exchange(drift, "queryWebShellSessionModels", 401,
post(WEB_SHELL + "/session-models/query").header(TENANT, workspaceTenant),
"{\"sessionId\":\"" + webBoundId + "\"}");
@@ -485,6 +485,82 @@ class ManagedAgentDefinitionAdmissionTest {
.isInstanceOf(ApiException.class);
}
@Test
void listsOnlyMetadataFromTheSessionsPinnedSkillRevision() throws Exception {
String tenant = tenant();
register(tenant, "ws-a", "storage-a");
grant(tenant, "ws-a", "actor", true);
grant(tenant, "ws-a", "reader", false);
var content = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(FILES_DEFINITION);
content.putArray("skills").addObject().put("name", "review").put("description", "Original review")
.put("body", "PRIVATE_PINNED_BODY").put("base_directory", "docs");
String agentId = define(tenant, "definition", content.toString()).get("id").asText();
String first = mapper.readTree(createBound(tenant, agentId, null, "first")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
((com.fasterxml.jackson.databind.node.ObjectNode) content.at("/skills/0"))
.put("name", "testing").put("description", "New testing").put("body", "NEW_BODY");
define(tenant, "update", agentId, content.toString());
String second = mapper.readTree(createBound(tenant, agentId, null, "second")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
JsonNode original = mapper.readTree(querySkills(tenant, "reader", first)
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString());
assertThat(original).isEqualTo(mapper.readTree(
"{\"skills\":[{\"name\":\"review\",\"description\":\"Original review\"}]}"));
querySkills(tenant, "actor", second).andExpect(status().isOk())
.andExpect(jsonPath("$.skills[0].name").value("testing"));
querySkills(tenant, null, first).andExpect(status().isUnauthorized());
querySkills(tenant, "outsider", first).andExpect(status().isNotFound());
querySkills(tenant(), "actor", first).andExpect(status().isNotFound());
jdbc.update("DELETE FROM ma_workspace_access WHERE tenant_id = ? AND actor_id = ?", tenant,
"reader".getBytes(StandardCharsets.UTF_8));
querySkills(tenant, "reader", first).andExpect(status().isNotFound());
jdbc.update("UPDATE ma_session SET agent_definition_digest = ? WHERE tenant_id = ? AND session_id = ?",
"0".repeat(64), tenant, first);
querySkills(tenant, "actor", first).andExpect(status().isConflict())
.andExpect(jsonPath("$.error.code").value("hosted_agent_definition_conflict"));
jdbc.update("UPDATE ma_session SET status = 'DELETED' WHERE tenant_id = ? AND session_id = ?", tenant, second);
querySkills(tenant, "actor", second).andExpect(status().isNotFound());
}
@Test
void returnsAnEmptyCatalogForBuiltInAndSnapshotReadSessions() throws Exception {
String tenant = tenant();
register(tenant, "ws-a", "storage-a");
grant(tenant, "ws-a", "actor", true);
String builtin = mapper.readTree(createBound(tenant, "qwen-code", null, "builtin-skills")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
querySkills(tenant, "actor", builtin).andExpect(status().isOk())
.andExpect(jsonPath("$.skills").isEmpty());
String agentId = define(tenant, "empty-definition", FILES_DEFINITION).get("id").asText();
String custom = mapper.readTree(createBound(tenant, agentId, null, "custom-skills")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
querySkills(tenant, "actor", custom).andExpect(status().isOk())
.andExpect(jsonPath("$.skills").isEmpty());
var content = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(FILES_DEFINITION);
content.putArray("skills").addObject().put("name", "review").put("description", "Review")
.put("body", "SNAPSHOT_SKILL_BODY");
define(tenant, "snapshot-definition", agentId, content.toString());
String snapshot = mapper.readTree(createBound(tenant, agentId, null, "snapshot-skills")
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString()).get("id").asText();
querySkills(tenant, "actor", snapshot).andExpect(status().isOk())
.andExpect(jsonPath("$.skills[0].name").value("review"));
jdbc.update("UPDATE ma_session SET tool_profile = 'hosted-workspace-read/1'"
+ " WHERE tenant_id = ? AND session_id = ?", tenant, snapshot);
querySkills(tenant, "actor", snapshot).andExpect(status().isOk())
.andExpect(jsonPath("$.skills").isEmpty());
}
private org.springframework.test.web.servlet.ResultActions querySkills(String tenant, String actorId,
String sessionId) throws Exception {
var request = post("/api/agent/web-shell/v1/session-skills/query")
.header(TenantContextFilter.HEADER, tenant).contentType(MediaType.APPLICATION_JSON)
.content("{\"sessionId\":\"" + sessionId + "\"}");
if (actorId != null) {
request.principal(actor(tenant, actorId));
}
return mvc.perform(request);
}
private JsonNode define(String tenant, String key, String definition)
throws Exception {
return define(tenant, key, null, definition);
@@ -1613,7 +1613,7 @@ class SurfaceAdmissionAcceptanceTest {
case WORKSPACE_CREATE -> "{\"displayName\":\"New workspace\",\"idempotencyKey\":\"create-key\"}";
case WORKSPACE_DIRECTORIES -> "{\"workspaceId\":\"workspace\",\"cwdRelative\":\".\"}";
case SESSION_OPTIONS -> "{\"agentId\":\"qwen-code\"}";
case SESSION_APPROVAL, SESSION_MODELS -> "{\"sessionId\":\"" + session + "\"}";
case SESSION_APPROVAL, SESSION_MODELS, SESSION_SKILLS -> "{\"sessionId\":\"" + session + "\"}";
case SESSION_APPROVAL_CHANGE -> "{\"sessionId\":\"" + session
+ "\",\"idempotencyKey\":\"approval-change\",\"approvalMode\":\"yolo\",\"expectedRevision\":0}";
case SESSION_MODEL_CHANGE -> "{\"sessionId\":\"" + session
@@ -309,6 +309,10 @@ public enum SurfaceRegistry {
"/api/agent/web-shell/v1/session-models/query",
Surface.WEBSHELL, RuleClass.READER_ACTOR,
EnumSet.of(Capability.SESSION_MODELS)),
WEBSHELL_SESSION_SKILLS(Method.POST,
"/api/agent/web-shell/v1/session-skills/query",
Surface.WEBSHELL, RuleClass.READER_ACTOR,
EnumSet.of(Capability.SESSION_SKILLS)),
WEBSHELL_SESSION_MODEL_CHANGE(Method.POST,
"/api/agent/web-shell/v1/session-models/change",
Surface.WEBSHELL, RuleClass.OPERATOR,
@@ -567,6 +571,7 @@ public enum SurfaceRegistry {
SESSION_APPROVAL,
SESSION_APPROVAL_CHANGE,
SESSION_MODELS,
SESSION_SKILLS,
SESSION_MODEL_CHANGE,
WORKSPACE_LIST,
WORKSPACE_GET,
@@ -323,6 +323,19 @@
"description": "User interface settings.",
"type": "object",
"properties": {
"allowedApprovalModes": {
"description": "Execution approval modes offered by Web Shell. Omit to show default, auto-edit, auto and yolo; an empty list offers none. Host allowedApprovalModes overrides this setting. Presentation only: does not change the current mode or enforce backend permissions. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored with a warning. Edit settings.json directly.",
"type": "array",
"items": {
"type": "string",
"enum": [
"default",
"auto-edit",
"auto",
"yolo"
]
}
},
"theme": {
"description": "The color theme for the UI.",
"type": "string",
+40
View File
@@ -502,6 +502,46 @@ const projection = projectChatRecordsToDaemonTranscript(records);
- 已完成的 assistant 消息支持复制;具备持久化 checkpoint 时还支持分支。
- 终态 turn error 支持复制显示的错误文本。重试入口保持独立,错误轮次不支持分支。
## 审批模式白名单
`settings.json` 可限制 Web Shell 提供的执行审批模式:
```json
{
"ui": {
"allowedApprovalModes": ["default", "auto"]
}
}
```
嵌入宿主可通过同名 Prop 配置,适用于 `WebShell` 和 `WebShellWithProviders`:
```tsx
<WebShellWithProviders
{...connectionProps}
allowedApprovalModes={['default', 'auto']}
/>
```
宿主显式传入时优先于设置,包括空数组。不配置时仍显示全部四种模式:
`default`、`auto-edit`、`auto`、`yolo`。不同设置作用域的列表使用替换合并,
不拼接;该键仅在用户、系统等操作者作用域生效,仓库工作区设置中的值会被忽略并警告。
支持导入 `WebShellApprovalMode` 类型。
主会话、分屏和侧边会话均受控:宿主 Prop 在各会话生效;未传时,各会话读取所属
工作区的设置接口(生效值仅来自操作者作用域)。下拉菜单、审批弹窗、快捷键循环和自定义页脚使用同一列表,保留内置顺序,
忽略重复值、未知值和 `plan`。空数组禁用下拉入口,弹窗无选项,快捷键不切换;
仅有一个且已处于该模式时,快捷键也不重复切换。非数组的错误设置回退到默认列表。
宿主 Prop 变化会更新打开的菜单和弹窗;settings.json 的编辑在下次加载
Web Shell 时生效。配置变化不会自动改变当前会话模式;当前模式被隐藏时,
入口仍显示真实模式,下一次快捷键切换到列表第一项。Plan 开关独立,不受白名单影响。
直接通过 Web Shell 斜杠命令选择隐藏模式也不会切换。
这是界面选项配置,不是后端权限约束,其他客户端和 daemon API 仍可切换模式。
该设置不在浏览器设置编辑器显示,请编辑配置文件;设置加载前使用默认列表,
独立会话无法读取工作区设置时可使用宿主 Prop。
## Props
### WebShellWithProviders
+198
View File
@@ -10,6 +10,30 @@ import {
import { createRoot, type Root } from 'react-dom/client';
import { createPortal } from 'react-dom';
const notificationScrollToBottom = vi.hoisted(() => vi.fn());
const approvalModesProbe = vi.hoisted(() => ({
cwd: undefined as string | undefined,
setting: undefined as unknown,
}));
vi.mock('./hooks/useApprovalModes', async () => {
const { resolveApprovalModes } = await import('./utils/planMode');
const { useMemo } = await import('react');
return {
useApprovalModes: (
cwd: string | undefined,
allowed: readonly string[] | undefined,
) => {
approvalModesProbe.cwd = cwd;
// Identity-stable like the real hook's memo, so a fresh-but-equal host
// array only churns the result when App drops its stabilization.
const setting = approvalModesProbe.setting;
return useMemo(
() => resolveApprovalModes(allowed ?? (cwd ? setting : undefined)),
[allowed, cwd, setting],
);
},
};
});
const editorAddTags = vi.hoisted(() => vi.fn());
import type {
ContextUsageControls,
@@ -200,6 +224,8 @@ type ChatEditorTestProps = {
skillsLoadError?: boolean;
skillsLoaded?: boolean;
currentMode?: string;
availableModes?: readonly string[];
onCycleMode?: () => void;
planMode?: boolean;
modeControlsDisabled?: boolean;
onTogglePlan?: () => void;
@@ -11954,6 +11980,7 @@ beforeEach(() => {
mockWorkspace.client.liveSetupStatus
.mockReset()
.mockResolvedValue({ enabled: false, install: { state: 'missing' } });
approvalModesProbe.setting = undefined;
mockWorkspace.client.workspaceByCwd.mockReset();
mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({
getSessionToolCalls: mockWorkspace.client.getSessionToolCalls,
@@ -48294,6 +48321,177 @@ describe('App sidebar toggle shortcut (#5074 rail follow-ups)', () => {
});
});
describe('approval mode allowlist', () => {
function configureModes(value: unknown) {
approvalModesProbe.setting = value;
}
it('defaults to all modes and resolves settings before passing choices to the composer', () => {
const { rerender } = renderApp();
expect(testState.latestChatEditorProps?.availableModes).toEqual([
'default',
'auto-edit',
'auto',
'yolo',
]);
configureModes(['auto', 'default', 'plan', 'bogus', 'auto']);
rerender();
expect(testState.latestChatEditorProps?.availableModes).toEqual([
'default',
'auto',
]);
expect(mockSessionActions.setApprovalMode).not.toHaveBeenCalled();
});
it('passes the active workspace to the shared reader instead of using primary settings', () => {
testState.settings = [
{
key: 'ui.allowedApprovalModes',
type: 'array',
label: 'Modes',
category: 'UI',
requiresRestart: false,
default: [],
values: { effective: ['default'] },
},
];
configureModes(['auto']);
mockConnection.workspaceCwd = '/secondary';
const { rerender } = renderApp();
expect(approvalModesProbe.cwd).toBe('/secondary');
expect(testState.latestChatEditorProps?.availableModes).toEqual(['auto']);
mockConnection.sessionContext = { kind: 'standalone' };
rerender();
expect(approvalModesProbe.cwd).toBeUndefined();
expect(testState.latestChatEditorProps?.availableModes).toEqual([
'default',
'auto-edit',
'auto',
'yolo',
]);
});
it('lets a host override settings and update the choices to empty', async () => {
configureModes(['default']);
const { rerender } = renderApp({ allowedApprovalModes: ['auto'] });
expect(testState.latestChatEditorProps?.availableModes).toEqual(['auto']);
rerender({ allowedApprovalModes: [] });
expect(testState.latestChatEditorProps?.availableModes).toEqual([]);
act(() => testState.latestChatEditorProps?.onCycleMode?.());
await flush();
expect(mockSessionActions.setApprovalMode).not.toHaveBeenCalled();
});
it('cycles the visible list from a hidden current mode and wraps', async () => {
mockConnection.currentMode = 'yolo';
const { rerender } = renderApp({
allowedApprovalModes: ['default', 'auto'],
});
expect(testState.latestChatEditorProps?.currentMode).toBe('yolo');
act(() => testState.latestChatEditorProps?.onCycleMode?.());
await flush();
expect(mockSessionActions.setApprovalMode).toHaveBeenLastCalledWith(
'default',
{ planMode: false },
);
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledTimes(1);
mockConnection.currentMode = 'auto';
rerender();
act(() => testState.latestChatEditorProps?.onCycleMode?.());
await flush();
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledTimes(2);
expect(mockSessionActions.setApprovalMode).toHaveBeenLastCalledWith(
'default',
{ planMode: false },
);
});
it('skips a singleton current mode and rejects hidden direct selections', async () => {
mockConnection.currentMode = 'auto';
renderApp({ allowedApprovalModes: ['auto'] });
act(() => {
testState.latestChatEditorProps?.onCycleMode?.();
testState.latestChatEditorProps?.onSelectMode?.('yolo');
});
await flush();
expect(mockSessionActions.setApprovalMode).not.toHaveBeenCalled();
});
it('keeps the resolved list identity stable across fresh-but-equal host arrays', () => {
const { rerender } = renderApp({
allowedApprovalModes: ['default', 'auto'],
});
const first = testState.latestChatEditorProps?.availableModes;
expect(first).toEqual(['default', 'auto']);
rerender({ allowedApprovalModes: ['default', 'auto'] });
expect(testState.latestChatEditorProps?.availableModes).toBe(first);
});
it('reports when a directly selected mode is not offered', async () => {
const onToast = vi.fn();
renderApp({ allowedApprovalModes: ['auto'], onToast });
act(() => testState.latestChatEditorProps?.onSelectMode?.('yolo'));
await flush();
expect(mockSessionActions.setApprovalMode).not.toHaveBeenCalled();
expect(onToast).toHaveBeenCalledWith(
'error',
expect.stringContaining('not offered here: yolo'),
);
});
it('keeps reporting plan as unsupported when an allowlist is set', async () => {
const onToast = vi.fn();
renderApp({ allowedApprovalModes: ['auto'], onToast });
await act(async () => {
testState.latestChatEditorProps?.onSelectMode?.('plan');
await Promise.resolve();
});
expect(mockSessionActions.setApprovalMode).not.toHaveBeenCalled();
expect(onToast).toHaveBeenCalledWith(
'error',
expect.stringContaining('Unsupported execution approval mode: plan'),
);
});
it('keeps Plan toggling independent when no execution choices are offered', async () => {
mockConnection.currentMode = 'yolo';
renderApp({ allowedApprovalModes: [] });
act(() => testState.latestChatEditorProps?.onTogglePlan?.());
await flush();
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('yolo', {
planMode: true,
});
});
// The two CustomFooter sites sit in different layout branches, so each is
// rendered and observed separately: the mobile-bottom branch needs the
// empty-state welcome layout, the other is the default chat footer.
for (const mobileWelcomeFooterMiddle of [false, true]) {
it(`passes the restricted list to a host footer's mode choices (mobile welcome middle: ${mobileWelcomeFooterMiddle})`, async () => {
if (mobileWelcomeFooterMiddle) mockConnection.sessionId = undefined;
let footerModes: readonly string[] | undefined;
const renderFooter = (props: { availableModes: readonly string[] }) => {
footerModes = props.availableModes;
return null;
};
const { rerender } = renderApp({
allowedApprovalModes: ['default', 'auto'],
mobileWelcomeFooterMiddle,
renderFooter,
});
await flush();
expect(footerModes).toEqual(['default', 'auto']);
rerender({
allowedApprovalModes: [],
mobileWelcomeFooterMiddle,
renderFooter,
});
await flush();
expect(footerModes).toEqual([]);
});
}
});
describe('Managed home integration', () => {
function autoApprovableLocalPermission() {
return makePendingPermissionBlock({
+35 -12
View File
@@ -200,7 +200,12 @@ import {
} from './components/panels/EnvironmentPanel';
import { ChatContextHeader } from './components/ChatContextHeader';
import { WelcomeHeader } from './components/WelcomeHeader';
import { EXECUTION_APPROVAL_MODES, parsePlanCommand } from './utils/planMode';
import {
nextApprovalMode,
parsePlanCommand,
type WebShellApprovalMode,
} from './utils/planMode';
import { useApprovalModes } from './hooks/useApprovalModes';
import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog';
import { ResumeDialog } from './components/dialogs/ResumeDialog';
import { DialogShell } from './components/dialogs/DialogShell';
@@ -561,7 +566,6 @@ import { WebShellPortalRootContext } from './portalRoot';
import { CompactModeContext, TodoContextsProvider } from './WebShellContexts';
import styles from './App.module.css';
const MODES_CYCLE = EXECUTION_APPROVAL_MODES;
const MAX_TOASTS = 4;
const TOAST_AUTO_DISMISS_MS = 5000;
const DEFAULT_REVIEW_PANEL_WIDTH = 500;
@@ -1461,6 +1465,8 @@ export interface WebShellProps {
* needs them. Plan must be explicitly included.
*/
composerToolbarActions?: readonly ComposerToolbarAction[];
/** Execution mode choices. Overrides ui.allowedApprovalModes; [] offers none. Presentation only. */
allowedApprovalModes?: readonly WebShellApprovalMode[];
/** Optionally filter main-model entries without changing shared defaults. */
mainModelFilter?: (model: ModelDialogModel) => boolean;
/** Stack completion details vertically for narrow embedded hosts. */
@@ -3440,6 +3446,7 @@ export function App({
filterArtifact,
shellRef,
composerToolbarActions,
allowedApprovalModes: allowedApprovalModesProp,
mainModelFilter,
compactComposerOverlays = false,
autoSubmitSlashCommands = false,
@@ -3735,8 +3742,10 @@ export function App({
[],
);
const allowedApprovalModes = useShallowMemo(allowedApprovalModesProp);
const customization = useMemo(
() => ({
allowedApprovalModes,
artifact,
filterArtifact,
askUserFreeTextLabel,
@@ -3771,6 +3780,7 @@ export function App({
fileDropAction,
}),
[
allowedApprovalModes,
artifact,
filterArtifact,
askUserFreeTextLabel,
@@ -13163,6 +13173,10 @@ export function App({
() => (projectFeaturesAvailable ? loadedWorkspaceSettings : []),
[loadedWorkspaceSettings, projectFeaturesAvailable],
);
const availableApprovalModes = useApprovalModes(
activeWorkspaceCwd,
allowedApprovalModes,
);
const showResponseTokensPerSecond =
workspaceSettings.find(
(setting) => setting.key === 'ui.showResponseTokensPerSecond',
@@ -13965,9 +13979,20 @@ export function App({
);
const handleSetMode = useCallback(
(modeId: string) => {
if (
isDaemonApprovalMode(modeId) &&
modeId !== 'plan' &&
!availableApprovalModes.some((mode) => mode === modeId)
) {
reportError(
new Error(`Approval mode not offered here: ${modeId}`),
t('local.approvalMode'),
);
return;
}
void setComposerMode(modeId, currentModeRef.current === 'plan');
},
[setComposerMode],
[availableApprovalModes, setComposerMode, reportError, t],
);
const handleTogglePlan = useCallback(() => {
void setComposerMode(
@@ -14545,13 +14570,9 @@ export function App({
]);
const handleCycleMode = useCallback(() => {
// findIndex, not indexOf: narrowing currentMode to the tuple member type
// silently degrades when the SDK's declaration bundle leaves its
// permission-mode import dangling, and the build must survive both states.
const idx = MODES_CYCLE.findIndex((mode) => mode === executionMode);
const next = MODES_CYCLE[(idx + 1) % MODES_CYCLE.length];
handleSetMode(next);
}, [executionMode, handleSetMode]);
const next = nextApprovalMode(availableApprovalModes, executionMode);
if (next) handleSetMode(next);
}, [availableApprovalModes, executionMode, handleSetMode]);
// Shared by the /context slash command and the status-bar context
// indicator. Echoes the command when idle — that also makes the transcript
@@ -21030,6 +21051,7 @@ export function App({
>
<ApprovalModeDialog
currentMode={executionMode}
availableModes={availableApprovalModes}
sessionWorkflowEnabled={sessionWorkflowEnabled}
onSelect={(modeId) => {
handleSetMode(modeId);
@@ -23552,6 +23574,7 @@ export function App({
onPopQueuedMessages={editLastQueuedPrompt}
onClearQueuedMessages={clearQueuedPrompts}
currentMode={executionMode}
availableModes={availableApprovalModes}
modeControlsDisabled={modeControlsBusy}
planMode={currentMode === 'plan'}
onTogglePlan={handleTogglePlan}
@@ -23759,7 +23782,7 @@ export function App({
goalSnapshot={goalSnapshot}
activeGoal={activeGoal}
tasks={footerTasks}
availableModes={MODES_CYCLE}
availableModes={availableApprovalModes}
availableModels={(connection.models ?? [])
.filter(isVisibleComposerModel)
.map((m) => ({
@@ -23787,7 +23810,7 @@ export function App({
goalSnapshot={goalSnapshot}
activeGoal={activeGoal}
tasks={footerTasks}
availableModes={MODES_CYCLE}
availableModes={availableApprovalModes}
availableModels={(connection.models ?? [])
.filter(isVisibleComposerModel)
.map((m) => ({
@@ -428,7 +428,7 @@ interface ChatEditorRenderProps
renderComposerTagTooltip?: ComposerTagRenderer;
onComposerTagClick?: ComposerTagClickHandler;
currentMode?: string;
availableModes?: readonly string[];
availableModes?: ComponentProps<typeof ChatEditor>['availableModes'];
planMode?: boolean;
modeControlsDisabled?: boolean;
onTogglePlan?: () => void;
@@ -2962,6 +2962,514 @@ describe('ChatEditor Plan toolbar switch fallback', () => {
).toBe('true');
});
it('renders only the supplied mode choices and retains the actual current mode', () => {
const container = renderChatEditor({
visibleToolbarActions: ['approvalMode'],
currentMode: 'yolo',
availableModes: ['auto', 'plan', 'default', 'unsupported', 'auto'],
});
const trigger = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
expect(trigger.textContent).toContain('Full Access');
act(() => trigger.click());
const labels = Array.from(
document.querySelectorAll('[data-web-shell-toolbar-popover] button'),
).map((button) => button.textContent);
expect(labels).toHaveLength(2);
expect(labels[0]).toContain('(default)');
expect(labels[1]).toContain('(auto)');
});
it('disables the mode picker for an empty list', () => {
const container = renderChatEditor({
visibleToolbarActions: ['approvalMode'],
availableModes: [],
});
expect(
container.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')
?.disabled,
).toBe(true);
});
it('closes an open mode dropdown when the list becomes empty', () => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default', 'auto'],
};
const container = renderChatEditor(props);
const modeButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
act(() => modeButton.click());
expect(
document.querySelector('[data-web-shell-toolbar-popover]'),
).not.toBeNull();
// The list emptying while the trigger holds focus must hand focus to the
// composer; a disabled button cannot keep it and the body must not get it.
act(() => modeButton.focus());
// A real browser blurs a focused button to the body the moment it becomes
// disabled; jsdom never does, so emulate the loss explicitly. The handoff
// must still find the composer.
act(() => modeButton.blur());
composerCoreState.focus.mockClear();
rerenderChatEditor(container, { ...props, availableModes: [] });
expect(
document.querySelector('[data-web-shell-toolbar-popover]'),
).toBeNull();
expect(
container.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')
?.disabled,
).toBe(true);
expect(composerCoreState.focus).toHaveBeenCalled();
});
it('leaves focus alone when the list is empty from the start', () => {
renderChatEditor({
visibleToolbarActions: ['approvalMode'],
availableModes: [],
});
expect(composerCoreState.focus).not.toHaveBeenCalled();
});
it('hands focus to the composer when a narrowing list unmounts the focused row', () => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default', 'auto', 'yolo'],
};
const container = renderChatEditor(props);
act(() =>
container
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')!
.click(),
);
const rows = () =>
document.querySelectorAll<HTMLElement>(
'[data-web-shell-mode-popover] button[title]',
);
expect(rows()).toHaveLength(3);
act(() => rows()[2]!.focus());
composerCoreState.focus.mockClear();
rerenderChatEditor(container, {
...props,
availableModes: ['default', 'auto'],
});
// The picker stays open with the surviving rows; focus cannot stay on the
// removed row, so it is handed on rather than dropped to the body.
expect(rows()).toHaveLength(2);
expect(composerCoreState.focus).toHaveBeenCalled();
});
it('keeps focus on a row that survives a narrowing', () => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default', 'auto', 'yolo'],
};
const container = renderChatEditor(props);
act(() =>
container
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')!
.click(),
);
const rows = () =>
document.querySelectorAll<HTMLElement>(
'[data-web-shell-mode-popover] button[title]',
);
expect(rows()).toHaveLength(3);
act(() => rows()[0]!.focus());
composerCoreState.focus.mockClear();
rerenderChatEditor(container, {
...props,
availableModes: ['default', 'auto'],
});
expect(rows()).toHaveLength(2);
expect(document.activeElement).toBe(rows()[0]);
expect(composerCoreState.focus).not.toHaveBeenCalled();
});
it('leaves the composer unfocused when the mode list widens with no row lost', () => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default'],
};
const container = renderChatEditor(props);
const modeButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
act(() => modeButton.focus());
// A blur that lands nowhere (reading transcript text, say) keeps the
// ownership record; a widening loses no row and must not steal focus
// back to the composer.
act(() => modeButton.blur());
composerCoreState.focus.mockClear();
rerenderChatEditor(container, {
...props,
availableModes: ['default', 'auto'],
});
expect(composerCoreState.focus).not.toHaveBeenCalled();
});
it('hands focus to the composer when the list empties after a narrowing kept the focused row', () => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default', 'auto', 'yolo'],
};
const container = renderChatEditor(props);
act(() =>
container
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')!
.click(),
);
const rows = () =>
document.querySelectorAll<HTMLElement>(
'[data-web-shell-mode-popover] button[title]',
);
expect(rows()).toHaveLength(3);
act(() => rows()[0]!.focus());
composerCoreState.focus.mockClear();
// The narrowing keeps the focused row: focus stays put, and the
// ownership record must survive for the handoff a later emptying owes.
rerenderChatEditor(container, {
...props,
availableModes: ['default', 'auto'],
});
expect(rows()).toHaveLength(2);
expect(document.activeElement).toBe(rows()[0]);
expect(composerCoreState.focus).not.toHaveBeenCalled();
// A real browser drops an unmounted row's focus to the body; emulate the
// loss explicitly, as jsdom never fires that blur.
act(() => rows()[0]!.blur());
rerenderChatEditor(container, { ...props, availableModes: [] });
expect(composerCoreState.focus).toHaveBeenCalled();
});
it.each([
{
flip: { modeControlsDisabled: true },
label: 'the controls lock',
},
{
flip: {
modeControlsDisabled: true,
availableModes: [] as string[],
},
label: 'the controls lock and the list empties',
},
])(
'hands focus from an open picker row to the composer when $label',
({ flip }) => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default', 'auto'],
};
const container = renderChatEditor(props);
act(() =>
container
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')!
.click(),
);
const row = document.querySelector<HTMLElement>(
'[data-web-shell-mode-popover] button[title]',
)!;
act(() => row.focus());
composerCoreState.focus.mockClear();
// The picker's rows are portaled, so they are never inside the toolbar;
// the handoff must still find them through the ownership record.
rerenderChatEditor(container, { ...props, ...flip });
expect(composerCoreState.focus).toHaveBeenCalled();
},
);
it('leaves an open model dropdown alone when the mode list empties', () => {
const props = {
visibleToolbarActions: ['approvalMode', 'model'] as const,
availableModes: ['default', 'auto'],
availableModels: [{ id: 'qwen' }, { id: 'other' }],
currentModel: 'qwen',
};
const container = renderChatEditor(props);
act(() =>
container
.querySelector<HTMLButtonElement>('[data-web-shell-model-button]')!
.click(),
);
const popover = () =>
document.querySelector('[data-web-shell-toolbar-popover]');
expect(popover()).not.toBeNull();
const option = popover()!.querySelector<HTMLElement>('button[title]')!;
act(() => option.focus());
composerCoreState.focus.mockClear();
rerenderChatEditor(container, { ...props, availableModes: [] });
expect(composerCoreState.focus).not.toHaveBeenCalled();
expect(popover()).not.toBeNull();
});
it.each([
{ flip: { disabled: true }, label: 'the input is disabled' },
{ flip: { modeControlsDisabled: true }, label: 'the mode controls lock' },
])(
'leaves an open model dropdown alone with an empty list when $label',
({ flip }) => {
const props = {
visibleToolbarActions: ['approvalMode', 'model'] as const,
availableModes: [] as string[],
availableModels: [{ id: 'qwen' }, { id: 'other' }],
currentModel: 'qwen',
};
const container = renderChatEditor(props);
act(() =>
container
.querySelector<HTMLButtonElement>('[data-web-shell-model-button]')!
.click(),
);
const popover = () =>
document.querySelector('[data-web-shell-toolbar-popover]');
expect(popover()).not.toBeNull();
const option = popover()!.querySelector<HTMLElement>('button[title]')!;
act(() => option.focus());
composerCoreState.focus.mockClear();
rerenderChatEditor(container, { ...props, ...flip });
expect(composerCoreState.focus).not.toHaveBeenCalled();
expect(document.activeElement).toBe(option);
expect(popover()).not.toBeNull();
},
);
it('does not pull focus from another editor instance when its own list empties', () => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default', 'auto'],
};
const first = renderChatEditor(props);
act(() =>
first
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')!
.click(),
);
const row = document.querySelector<HTMLElement>(
'[data-web-shell-mode-popover] button[title]',
)!;
act(() => row.focus());
const second = renderChatEditor(props);
composerCoreState.focus.mockClear();
rerenderChatEditor(second, { ...props, availableModes: [] });
expect(composerCoreState.focus).not.toHaveBeenCalled();
expect(document.activeElement).toBe(row);
expect(
document.querySelector('[data-web-shell-mode-popover]'),
).not.toBeNull();
});
it('does not pull focus from another instance when this one holds a stale ownership record', () => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default', 'auto'],
};
const first = renderChatEditor(props);
const firstButton = first.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
// This instance's control held focus and lost it to nowhere, so its
// ownership record outlives the focus...
act(() => firstButton.focus());
act(() => firstButton.blur());
// ...while the user moves on to another instance's open picker.
const second = renderChatEditor(props);
act(() =>
second
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')!
.click(),
);
const row = document.querySelector<HTMLElement>(
'[data-web-shell-mode-popover] button[title]',
)!;
act(() => row.focus());
composerCoreState.focus.mockClear();
rerenderChatEditor(first, { ...props, availableModes: [] });
expect(composerCoreState.focus).not.toHaveBeenCalled();
expect(document.activeElement).toBe(row);
});
it('hands focus past a disabled model control when the list empties', () => {
const props = {
visibleToolbarActions: ['approvalMode', 'model'] as const,
availableModes: ['default', 'auto'],
availableModels: [{ id: 'qwen' }],
currentModel: 'qwen',
modelControlsDisabled: true,
disabled: true,
};
const container = renderChatEditor(props);
const modeButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
const modelButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-model-button]',
)!;
expect(modelButton.disabled).toBe(true);
act(() => modeButton.focus());
const modelFocus = vi.spyOn(modelButton, 'focus');
composerCoreState.focus.mockClear();
rerenderChatEditor(container, { ...props, availableModes: [] });
// The disabled model control cannot take focus, and with the input
// disabled too the handoff goes nowhere rather than to a dead target.
expect(modelFocus).not.toHaveBeenCalled();
expect(composerCoreState.focus).not.toHaveBeenCalled();
});
it('hands focus to an enabled model control when the list empties with the input disabled', () => {
const props = {
visibleToolbarActions: ['approvalMode', 'model'] as const,
availableModes: ['default', 'auto'],
availableModels: [{ id: 'qwen' }],
currentModel: 'qwen',
disabled: true,
};
const container = renderChatEditor(props);
const modeButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
const modelButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-model-button]',
)!;
expect(modelButton.disabled).toBe(false);
act(() => modeButton.focus());
const modelFocus = vi.spyOn(modelButton, 'focus');
rerenderChatEditor(container, { ...props, availableModes: [] });
expect(modelFocus).toHaveBeenCalled();
expect(composerCoreState.focus).not.toHaveBeenCalled();
});
it('falls back to the composer on a coarse pointer when no toolbar control can take focus', () => {
const props = {
visibleToolbarActions: ['approvalMode'] as const,
availableModes: ['default', 'auto'],
};
const container = renderChatEditor(props);
const modeButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
act(() => modeButton.focus());
composerCoreState.focus.mockClear();
const media = vi.mocked(window.matchMedia).mockImplementation(
(query: string) =>
({
matches: query === '(hover: none) and (pointer: coarse)',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
try {
rerenderChatEditor(container, { ...props, availableModes: [] });
expect(composerCoreState.focus).toHaveBeenCalled();
} finally {
media.mockImplementation(
() =>
({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
}
});
it('hands focus to an enabled toolbar control rather than the composer on a coarse pointer', () => {
const props = {
visibleToolbarActions: ['approvalMode', 'model'] as const,
availableModes: ['default', 'auto'],
availableModels: [{ id: 'qwen' }],
currentModel: 'qwen',
};
const container = renderChatEditor(props);
const modeButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
const modelButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-model-button]',
)!;
expect(modelButton.disabled).toBe(false);
act(() => modeButton.focus());
const modelFocus = vi.spyOn(modelButton, 'focus');
composerCoreState.focus.mockClear();
const media = vi.mocked(window.matchMedia).mockImplementation(
(query: string) =>
({
matches: query === '(hover: none) and (pointer: coarse)',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
try {
rerenderChatEditor(container, { ...props, availableModes: [] });
// A programmatic composer focus outside a gesture claims the active
// element on touch devices without raising the keyboard; an enabled
// toolbar control takes the handoff instead.
expect(modelFocus).toHaveBeenCalled();
expect(composerCoreState.focus).not.toHaveBeenCalled();
} finally {
media.mockImplementation(
() =>
({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
}
});
it('does not hand focus to a busy plan chip when the list empties on a coarse pointer', () => {
const onTogglePlan = vi.fn();
const props = {
visibleToolbarActions: ['addMenu', 'approvalMode', 'plan'] as const,
availableModes: ['default', 'auto'],
planMode: true,
onTogglePlan,
};
const container = renderChatEditor(props);
const modeButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
)!;
const chip = container.querySelector<HTMLButtonElement>(
'[data-web-shell-plan-chip]',
)!;
expect(chip.getAttribute('aria-disabled')).toBeNull();
act(() => modeButton.focus());
const chipFocus = vi.spyOn(chip, 'focus');
composerCoreState.focus.mockClear();
const media = vi.mocked(window.matchMedia).mockImplementation(
(query: string) =>
({
matches: query === '(hover: none) and (pointer: coarse)',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
try {
// The mode change's round trip is still in flight when the list
// empties: the busy chip is inert and must not take the handoff.
rerenderChatEditor(container, {
...props,
availableModes: [],
modeControlsDisabled: true,
});
expect(chip.getAttribute('aria-disabled')).toBe('true');
expect(chipFocus).not.toHaveBeenCalled();
expect(document.activeElement).not.toBe(chip);
expect(composerCoreState.focus).toHaveBeenCalled();
} finally {
media.mockImplementation(
() =>
({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
}
});
it('keeps permission and Plan controls operable while approval disables input', () => {
const onSelectMode = vi.fn();
const onTogglePlan = vi.fn();
@@ -18,11 +18,9 @@ import type {
ChangeEvent as ReactChangeEvent,
} from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import {
DAEMON_APPROVAL_MODES,
useOptionalWorkspace,
} from '@qwen-code/web-shell/daemon-react-sdk';
import { useOptionalWorkspace } from '@qwen-code/web-shell/daemon-react-sdk';
import type { CommandInfo } from '../adapters/types';
import { EXECUTION_APPROVAL_MODES } from '../utils/planMode';
import type { AttachmentPreviewRequest } from '../adapters/messageTypes';
import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/web-shell/daemon-react-sdk';
import type {
@@ -756,6 +754,7 @@ function ToolbarPopover({
header,
submenu,
compact = false,
modePopover = false,
}: {
open: boolean;
items: DropdownItem[];
@@ -775,6 +774,7 @@ function ToolbarPopover({
sectionLabel: string;
};
compact?: boolean;
modePopover?: boolean;
}) {
const [searchQuery, setSearchQuery] = useState('');
const [submenuOpen, setSubmenuOpen] = useState(false);
@@ -925,6 +925,7 @@ function ToolbarPopover({
data-web-shell-toolbar-popover
data-web-shell-compact-overlay={compact ? '' : undefined}
data-web-shell-reasoning-popover={submenu ? '' : undefined}
data-web-shell-mode-popover={modePopover ? '' : undefined}
onClick={(event) => event.stopPropagation()}
onOpenAutoFocus={(event) => {
if (!submenu) return;
@@ -1417,7 +1418,7 @@ export const ChatEditor = memo(
queuedMessages = [],
onPopQueuedMessages,
currentMode = 'default',
availableModes,
availableModes = EXECUTION_APPROVAL_MODES,
planMode = false,
modeControlsDisabled = false,
onTogglePlan,
@@ -1994,10 +1995,99 @@ export const ChatEditor = memo(
}, [core.hasAttachments, onAttachmentsChange]);
const planDescriptionId = useId();
const modeItems = useMemo<DropdownItem[]>(
() =>
EXECUTION_APPROVAL_MODES.filter((id) =>
availableModes.includes(id),
).map((id) => ({
id,
label: getModeListLabel(id, t),
description: t(`mode.desc.${id}`),
icon: <ModeIcon mode={id} />,
})),
[availableModes, t],
);
const modeItemIds = modeItems.map((item) => item.id).join(',');
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
// Focus held by the mode control is recorded rather than read back: the
// trigger is natively disabled the moment the list empties or the
// controls lock, and a focused element blurs to the body there and then,
// as does a popover row whose row unmounts. Any focus inside the control
// sets this; a blur that lands on another element ends the ownership, one
// that lands nowhere (the body) does not.
const modeControlHadFocusRef = useRef(false);
// The control's last focused element — the trigger or a popover row. A
// changed row set is not itself a loss: a widening, or a narrowing that
// kept this element, takes nothing the user had, and handing off there
// would steal focus back to the composer from wherever it landed.
const modeControlFocusedElementRef = useRef<HTMLElement | null>(null);
const previousModeItemIdsRef = useRef(modeItemIds);
useEffect(() => {
if (modeControlsDisabled) setModeDropdownOpen(false);
}, [modeControlsDisabled]);
const rowsChanged = previousModeItemIdsRef.current !== modeItemIds;
previousModeItemIdsRef.current = modeItemIds;
const gone = modeControlsDisabled || availableModes.length === 0;
const focusedElement = modeControlFocusedElementRef.current;
const focusedElementLost =
focusedElement !== null && !focusedElement.isConnected;
if (!gone && (!rowsChanged || !focusedElementLost)) return;
// A narrowing keeps the picker open; only an empty or locked control
// closes it.
if (gone) setModeDropdownOpen(false);
const hadFocus = modeControlHadFocusRef.current;
const toolbar = toolbarLeadingRef.current;
const doc = toolbar?.ownerDocument;
const active = getShadowAwareActiveElement(toolbar);
if (active !== null && active !== doc?.body && active.isConnected) {
// Focus that is anywhere but lost is somewhere the user put it. The
// handoff takes it only from this instance's own mode control — the
// picker's rows are portaled, so toolbar containment can never vouch
// for them, but the recorded element can: only focus inside this
// control sets it, and another instance's row can never match it —
// and only once the control is gone: a row that survived a narrowing
// and an enabled trigger both keep their focus.
if (!(active instanceof HTMLElement)) return;
const inModeControl = active.closest(
'[data-web-shell-mode-popover], [data-web-shell-mode-button]',
);
if (
!gone ||
!inModeControl ||
(active !== modeControlFocusedElementRef.current &&
toolbar?.contains(active) !== true)
)
return;
} else if (!hadFocus) {
// Nothing ties a body-level focus to this control without the record.
return;
}
if (!disabled && !isCoarsePointerDevice()) {
modeControlHadFocusRef.current = false;
modeControlFocusedElementRef.current = null;
focusComposer();
return;
}
// The mode control itself is disabled, so it cannot be the target — and
// nor can a disabled control beside it. With none focusable, the
// enabled composer is still a better resting place than the body.
const target = toolbar?.querySelector<HTMLElement>(
'[data-web-shell-toolbar-popover-trigger]:not(:disabled), [data-web-shell-plan-button]:not(:disabled):not([aria-disabled])',
);
if (target) {
modeControlHadFocusRef.current = false;
modeControlFocusedElementRef.current = null;
target.focus();
} else if (!disabled) {
modeControlHadFocusRef.current = false;
modeControlFocusedElementRef.current = null;
focusComposer();
}
}, [
modeItemIds,
availableModes.length,
modeControlsDisabled,
disabled,
focusComposer,
]);
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
useEffect(() => {
if (modelControlsDisabled) setModelDropdownOpen(false);
@@ -2106,19 +2196,6 @@ export const ChatEditor = memo(
return () => resizeObserver.disconnect();
}, [chatWidthToggleMin]);
const modeItems = useMemo<DropdownItem[]>(
() =>
DAEMON_APPROVAL_MODES.filter(
(id) =>
id !== 'plan' && (!availableModes || availableModes.includes(id)),
).map((id) => ({
id,
label: getModeListLabel(id, t),
description: t(`mode.desc.${id}`),
icon: <ModeIcon mode={id} />,
})),
[t, availableModes],
);
const visibleActionSet = useMemo(() => {
if (!visibleToolbarActions) return null;
const activeActions = visibleToolbarActions.filter((action) =>
@@ -2195,16 +2272,12 @@ export const ChatEditor = memo(
const handleModeSelect = useCallback(
(modeId: string) => {
if (
modeControlsDisabled ||
(availableModes && !availableModes.includes(modeId))
)
return;
if (modeControlsDisabled || !availableModes.includes(modeId)) return;
onSelectMode?.(modeId);
setModeDropdownOpen(false);
core.focus();
},
[onSelectMode, core, modeControlsDisabled, availableModes],
[availableModes, onSelectMode, core, modeControlsDisabled],
);
// The add menu closes before this runs and leaves focus to the caller. The
@@ -3424,9 +3497,25 @@ export const ChatEditor = memo(
className={`${styles.dropdownWrapper} ${
showModeLabel ? '' : styles.dropdownWrapperCompact
}`}
onFocus={(event) => {
modeControlHadFocusRef.current = true;
if (event.target instanceof HTMLElement) {
modeControlFocusedElementRef.current = event.target;
}
}}
onBlur={(event) => {
// A blur that lands nowhere is the browser dropping a
// disabled or unmounted control's focus to the body —
// the loss the handoff above exists to catch.
if (event.relatedTarget instanceof HTMLElement) {
modeControlHadFocusRef.current = false;
modeControlFocusedElementRef.current = null;
}
}}
>
<ToolbarPopover
compact={compactOverlays}
modePopover
open={modeDropdownOpen}
items={modeItems}
activeId={currentMode}
@@ -3442,7 +3531,10 @@ export const ChatEditor = memo(
showModeLabel ? '' : styles.toolBtnCompact
}`}
data-web-shell-mode-button
disabled={modeControlsDisabled}
disabled={
modeControlsDisabled ||
availableModes.length === 0
}
data-web-shell-toolbar-popover-trigger
onClick={(e) => {
e.stopPropagation();
@@ -116,6 +116,10 @@ const latestComposerCoreOptions = vi.hoisted(() => ({
current: null as Record<string, unknown> | null,
}));
const paneSettings = vi.fn(async () => ({ v: 1, settings: [] }));
const workspaceByCwd = vi.fn(() => ({ workspaceSettings: paneSettings }));
const paneWorkspaceClient = { workspaceByCwd };
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useActions: () => daemonActions,
@@ -148,7 +152,8 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useWorkspaceActions: () => ({}),
useWorkspace: () => ({
capabilities: connectionState.capabilities,
client: {},
client: paneWorkspaceClient,
status: connectionState.status,
workspaceCwd: '/primary',
}),
useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }),
@@ -427,6 +432,9 @@ let container: HTMLDivElement | null = null;
const EMPTY_CUSTOMIZATION: WebShellCustomization = {};
beforeEach(() => {
paneSettings.mockReset();
paneSettings.mockResolvedValue({ v: 1, settings: [] });
workspaceByCwd.mockClear();
connectionState = {
status: 'connected',
sessionId: 'sess-1',
@@ -3977,3 +3985,138 @@ it.each(['builtin-command', 'project', 'missing'])(
expect(sendPrompt).toHaveBeenCalledTimes(source === 'project' ? 1 : 0);
},
);
describe('pane approval mode allowlist', () => {
beforeEach(() => {
connectionState.capabilities = {
features: ['workspace_qualified_rest_core', 'workspace_settings'],
};
});
it('applies host choices to split and embedded side-task composers', async () => {
connectionState.currentMode = 'yolo';
render({ embedded: true }, { allowedApprovalModes: ['default', 'auto'] });
expect(latestChatEditorProps.availableModes).toEqual(['default', 'auto']);
act(() => latestChatEditorProps.onSelectMode('yolo'));
expect(setApprovalMode).not.toHaveBeenCalled();
act(() => latestChatEditorProps.onCycleMode());
await act(async () => {
await Promise.resolve();
});
expect(setApprovalMode).toHaveBeenCalledWith('default', {
planMode: false,
});
expect(workspaceByCwd).not.toHaveBeenCalled();
rerender({ embedded: true }, { allowedApprovalModes: [] });
setApprovalMode.mockClear();
act(() => latestChatEditorProps.onCycleMode());
expect(latestChatEditorProps.availableModes).toEqual([]);
expect(setApprovalMode).not.toHaveBeenCalled();
});
it('reports when a directly selected mode is not offered', () => {
const onError = vi.fn();
render({ onError }, { allowedApprovalModes: ['default', 'auto'] });
act(() => latestChatEditorProps.onSelectMode('yolo'));
expect(setApprovalMode).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('not offered here: yolo'),
}),
expect.any(String),
);
});
it('keeps reporting plan as unsupported when an allowlist is set', async () => {
const onError = vi.fn();
render({ onError }, { allowedApprovalModes: ['auto'] });
await act(async () => {
latestChatEditorProps.onSelectMode('plan');
await Promise.resolve();
});
expect(setApprovalMode).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining(
'Unsupported execution approval mode: plan',
),
}),
expect.any(String),
);
});
it('reads its own workspace settings and drops old choices on workspace change', async () => {
paneSettings.mockResolvedValueOnce({
v: 1,
settings: [
{ key: 'ui.allowedApprovalModes', values: { effective: ['auto'] } },
],
} as never);
render({ workspaceCwd: '/secondary' });
await act(async () => {
await Promise.resolve();
});
expect(workspaceByCwd).toHaveBeenCalledWith('/secondary');
expect(latestChatEditorProps.availableModes).toEqual(['auto']);
paneSettings.mockResolvedValueOnce({
v: 1,
settings: [
{ key: 'ui.allowedApprovalModes', values: { effective: ['default'] } },
],
} as never);
rerender({ workspaceCwd: '/third' });
expect(latestChatEditorProps.availableModes).toEqual([
'default',
'auto-edit',
'auto',
'yolo',
]);
await act(async () => {
await Promise.resolve();
});
expect(workspaceByCwd).toHaveBeenLastCalledWith('/third');
expect(latestChatEditorProps.availableModes).toEqual(['default']);
});
it('ignores an older workspace response after the pane has moved', async () => {
const old = deferred<Awaited<ReturnType<typeof paneSettings>>>();
paneSettings.mockReturnValueOnce(old.promise);
render({ workspaceCwd: '/old' });
paneSettings.mockResolvedValueOnce({
v: 1,
settings: [
{ key: 'ui.allowedApprovalModes', values: { effective: ['auto'] } },
],
} as never);
rerender({ workspaceCwd: '/new' });
await act(async () => {
await Promise.resolve();
});
expect(latestChatEditorProps.availableModes).toEqual(['auto']);
await act(async () => {
old.resolve({
v: 1,
settings: [
{ key: 'ui.allowedApprovalModes', values: { effective: ['yolo'] } },
],
} as never);
await old.promise;
});
expect(latestChatEditorProps.availableModes).toEqual(['auto']);
});
it('does not read primary settings for standalone panes or failed qualified reads', async () => {
connectionState.sessionContext = { kind: 'standalone' };
render();
expect(workspaceByCwd).not.toHaveBeenCalled();
connectionState.sessionContext = undefined;
paneSettings.mockRejectedValueOnce(new Error('workspace unavailable'));
rerender({ workspaceCwd: '/missing' });
await act(async () => {
await Promise.resolve();
});
expect(workspaceByCwd).toHaveBeenCalledOnce();
// A failed scoped read fails closed rather than offering every mode.
expect(latestChatEditorProps.availableModes).toEqual([]);
});
});
@@ -116,7 +116,8 @@ import { QueuedPromptDisplay } from './QueuedPromptDisplay';
import { GoalStatusStrip } from './GoalStatusStrip';
import composerStatusStyles from './ComposerStatusStack.module.css';
import { GoalEditDialog } from './dialogs/GoalEditDialog';
import { parsePlanCommand } from '../utils/planMode';
import { nextApprovalMode, parsePlanCommand } from '../utils/planMode';
import { useApprovalModes } from '../hooks/useApprovalModes';
import { ToolApproval } from './messages/ToolApproval';
import { AskUserQuestion } from './messages/AskUserQuestion';
import { createContextUsageMessageData } from './messages/ContextUsageMessage';
@@ -302,12 +303,23 @@ export function ChatPane({
planControlVisible = false,
}: ChatPaneProps) {
const { t } = useI18n();
const { renderComposerFooter: CustomComposerFooter, askUserFreeTextLabel } =
useWebShellCustomization();
const {
renderComposerFooter: CustomComposerFooter,
askUserFreeTextLabel,
allowedApprovalModes,
} = useWebShellCustomization();
const connection = useConnection();
const actions = useActions();
const sessionOwnerGuard = useDaemonSessionOwnerGuard();
const workspace = useWorkspace();
const paneSettingsCwd =
connection.sessionContext?.kind === 'standalone'
? undefined
: (workspaceCwd ?? connection.workspaceCwd);
const availableApprovalModes = useApprovalModes(
paneSettingsCwd,
allowedApprovalModes,
);
const capacityRecovery = useCapacityRecovery(
workspace.client,
workspace.capabilities?.features,
@@ -1479,10 +1491,25 @@ export function ChatPane({
);
const handleSelectMode = useCallback(
(modeId: string) => {
if (
isDaemonApprovalMode(modeId) &&
modeId !== 'plan' &&
!availableApprovalModes.some((mode) => mode === modeId)
) {
reportError(
new Error(`Approval mode not offered here: ${modeId}`),
'Failed to set approval mode',
);
return;
}
void setComposerMode(modeId, planMode);
},
[setComposerMode, planMode],
[availableApprovalModes, setComposerMode, planMode, reportError],
);
const handleCycleMode = useCallback(() => {
const next = nextApprovalMode(availableApprovalModes, executionMode);
if (next) handleSelectMode(next);
}, [availableApprovalModes, executionMode, handleSelectMode]);
const handleTogglePlan = useCallback(() => {
void setComposerMode(executionMode, !planMode);
}, [setComposerMode, executionMode, planMode]);
@@ -1956,6 +1983,8 @@ export function ChatPane({
workspaceTitle={paneWorkspaceCwd}
workspaceColor={workspaceAccent}
currentMode={executionMode}
availableModes={availableApprovalModes}
onCycleMode={handleCycleMode}
modeControlsDisabled={modeControlsBusy}
planMode={planMode}
onTogglePlan={handleTogglePlan}
@@ -108,4 +108,165 @@ describe('ApprovalModeDialog', () => {
rerender(<ApprovalModeDialog currentMode="yolo" onSelect={vi.fn()} />);
expect(activeDescendant()).toBe('mode-opt-1');
});
it('uses the supplied choices for rendering and keyboard confirmation', () => {
const onSelect = vi.fn();
mount(
<ApprovalModeDialog
currentMode="yolo"
availableModes={['default', 'auto']}
onSelect={onSelect}
/>,
);
expect(
Array.from(container!.querySelectorAll('[data-mode-id]')).map((el) =>
el.getAttribute('data-mode-id'),
),
).toEqual(['default', 'auto']);
press('Enter');
expect(onSelect).toHaveBeenCalledWith('default');
press('ArrowDown');
press('Enter');
expect(onSelect).toHaveBeenLastCalledWith('auto');
});
it('re-anchors the highlight when a same-length swap replaces the highlighted row', () => {
const onSelect = vi.fn();
mount(
<ApprovalModeDialog
currentMode="default"
availableModes={['default', 'auto']}
onSelect={onSelect}
/>,
);
press('ArrowDown');
expect(activeDescendant()).toBe('mode-opt-1');
rerender(
<ApprovalModeDialog
currentMode="default"
availableModes={['default', 'yolo']}
onSelect={onSelect}
/>,
);
// The highlighted row left the list: the highlight re-anchors to the
// current mode instead of sitting on a row the user never chose.
expect(activeDescendant()).toBe('mode-opt-0');
press('Enter');
expect(onSelect).toHaveBeenCalledWith('default');
expect(onSelect).not.toHaveBeenCalledWith('yolo');
});
it('follows the highlighted row when a swap keeps it', () => {
const onSelect = vi.fn();
mount(
<ApprovalModeDialog
currentMode="default"
availableModes={['default', 'auto']}
onSelect={onSelect}
/>,
);
press('ArrowDown');
expect(activeDescendant()).toBe('mode-opt-1');
rerender(
<ApprovalModeDialog
currentMode="default"
availableModes={['auto', 'default']}
onSelect={onSelect}
/>,
);
expect(activeDescendant()).toBe('mode-opt-0');
press('Enter');
expect(onSelect).toHaveBeenCalledWith('auto');
});
it('keeps keyboard confirmation valid when an open list shrinks', () => {
const onSelect = vi.fn();
mount(<ApprovalModeDialog currentMode="yolo" onSelect={onSelect} />);
press('ArrowUp');
rerender(
<ApprovalModeDialog
currentMode="yolo"
availableModes={['default']}
onSelect={onSelect}
/>,
);
expect(activeDescendant()).toBe('mode-opt-0');
press('Enter');
expect(onSelect).toHaveBeenCalledWith('default');
rerender(
<ApprovalModeDialog
currentMode="yolo"
availableModes={[]}
onSelect={onSelect}
/>,
);
onSelect.mockClear();
press('Enter');
expect(onSelect).not.toHaveBeenCalled();
expect(activeDescendant()).toBeNull();
// A later grow must not resurrect the pre-shrink index: the highlight stays
// on the clamped row and Enter commits what is highlighted.
rerender(
<ApprovalModeDialog
currentMode="yolo"
availableModes={['default', 'auto', 'yolo']}
onSelect={onSelect}
/>,
);
expect(activeDescendant()).toBe('mode-opt-0');
press('Enter');
expect(onSelect).toHaveBeenLastCalledWith('default');
});
it('keeps the highlight on the current mode when the list widens while open', () => {
const onSelect = vi.fn();
mount(
<ApprovalModeDialog
currentMode="yolo"
availableModes={['default', 'yolo']}
onSelect={onSelect}
/>,
);
expect(activeDescendant()).toBe('mode-opt-1');
// The operator widens the allowlist while the dialog is open. Both
// effects fire in one commit, and Enter — normally a no-op re-commit of
// the current mode — must not land on a row the user never chose.
rerender(
<ApprovalModeDialog
currentMode="yolo"
availableModes={['default', 'auto', 'yolo']}
onSelect={onSelect}
/>,
);
expect(activeDescendant()).toBe('mode-opt-2');
press('Enter');
expect(onSelect).toHaveBeenCalledWith('yolo');
expect(onSelect).not.toHaveBeenCalledWith('default');
});
it('follows the current mode onto a row that arrives with a later list', () => {
const onSelect = vi.fn();
mount(
<ApprovalModeDialog
currentMode="auto"
availableModes={['default', 'auto']}
onSelect={onSelect}
/>,
);
expect(activeDescendant()).toBe('mode-opt-1');
// The session's mode changed to one the old list lacked; the arriving
// list carries it at the index the old highlight held, and the highlight
// must land on the current mode — not on the row inheriting the index.
rerender(
<ApprovalModeDialog
currentMode="auto-edit"
availableModes={['default', 'auto-edit', 'auto', 'yolo']}
onSelect={onSelect}
/>,
);
expect(activeDescendant()).toBe('mode-opt-1');
press('Enter');
expect(onSelect).toHaveBeenCalledWith('auto-edit');
expect(onSelect).not.toHaveBeenCalledWith('auto');
});
});
@@ -1,5 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { DAEMON_APPROVAL_MODES } from '@qwen-code/web-shell/daemon-react-sdk';
import {
EXECUTION_APPROVAL_MODES,
type WebShellApprovalMode,
} from '../../utils/planMode';
import { useI18n } from '../../i18n';
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
import { dp } from './dialogStyles';
@@ -8,6 +11,7 @@ import styles from './ApprovalModeDialog.module.css';
interface ApprovalModeDialogProps {
currentMode: string;
availableModes?: readonly WebShellApprovalMode[];
sessionWorkflowEnabled?: boolean;
onSelect: (modeId: string) => void;
}
@@ -20,13 +24,12 @@ interface ModeItem {
export function ApprovalModeDialog({
currentMode,
availableModes = EXECUTION_APPROVAL_MODES,
onSelect,
}: ApprovalModeDialogProps) {
const { t } = useI18n();
const listRef = useRef<HTMLDivElement>(null);
const approvalModes: ModeItem[] = DAEMON_APPROVAL_MODES.filter(
(id) => id !== 'plan',
).map((id) => ({
const approvalModes: ModeItem[] = availableModes.map((id) => ({
id,
name: t(`mode.listLabel.${id}`),
description: t(`mode.desc.${id}`),
@@ -51,6 +54,44 @@ export function ApprovalModeDialog({
setActiveIndex(index);
};
const highlightedIndex = Math.min(
activeIndex,
Math.max(0, approvalModes.length - 1),
);
// Re-anchor the highlight by row identity, not position: a same-length
// swap otherwise leaves the index pointing at a row the user never
// highlighted, and Enter would commit that row. A highlighted row that
// still exists keeps the highlight (following it if it moved); one that
// disappeared re-derives from the current mode. Keyed on the ids alone so
// a current-mode change does not steal a highlight the user moved.
const approvalModesKey = approvalModes.map((mode) => mode.id).join('\n');
const approvalModesRef = useRef(approvalModes);
const highlightedIdsRef = useRef(approvalModes.map((mode) => mode.id));
useEffect(() => {
approvalModesRef.current = approvalModes;
});
useEffect(() => {
const previousIds = highlightedIdsRef.current;
highlightedIdsRef.current = approvalModes.map((mode) => mode.id);
setActiveIndex((prev) => {
// The follow-current effect may have queued its own update in this
// commit, leaving prev in new-list coordinates; until the user has
// navigated, the current mode is the anchor and needs no lookup.
if (!userNavigatedRef.current) return currentIdx >= 0 ? currentIdx : 0;
const highlightedId = previousIds[prev];
if (highlightedId === undefined) return 0;
const index = approvalModesRef.current.findIndex(
(mode) => mode.id === highlightedId,
);
if (index >= 0) return index;
return currentIdx >= 0 ? currentIdx : 0;
});
// currentIdx is read fresh inside the update; keying on it would fire on
// every mode change and steal a user-moved highlight.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [approvalModesKey]);
const confirm = (index: number) => {
const mode = approvalModes[index];
if (mode) onSelect(mode.id);
@@ -58,17 +99,17 @@ export function ApprovalModeDialog({
const { keyboardMode } = useListboxKeyboard({
itemCount: approvalModes.length,
activeIndex,
activeIndex: highlightedIndex,
onActiveIndexChange: moveHighlight,
onConfirm: confirm,
});
useEffect(() => {
const el = listRef.current?.children[activeIndex] as
const el = listRef.current?.children[highlightedIndex] as
| HTMLElement
| undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [activeIndex]);
}, [highlightedIndex]);
return (
<div
@@ -77,13 +118,13 @@ export function ApprovalModeDialog({
role="listbox"
tabIndex={0}
aria-activedescendant={
approvalModes.length > 0 ? `mode-opt-${activeIndex}` : undefined
approvalModes.length > 0 ? `mode-opt-${highlightedIndex}` : undefined
}
aria-label={t('mode.select')}
data-web-shell-approval-mode-dialog
>
{approvalModes.map((mode, index) => {
const selected = index === activeIndex;
const selected = index === highlightedIndex;
const isCurrent = mode.id === currentMode;
return (
<div
@@ -285,6 +285,23 @@ describe('ManagedSessionsPage', () => {
});
}
it('connects the Session Skill catalog to the composer without enabling other Workspace features', async () => {
const skills = [{ name: 'review', description: 'Pinned review.' }];
const list = vi.fn().mockResolvedValue({ skills });
provider = { ...provider, skills: { list } };
await render('s1');
expect(mocks.editorProps?.visibleToolbarActions).toContain('addMenu');
expect(mocks.editorProps?.workspaceFeaturesEnabled).toBe(false);
expect(list).not.toHaveBeenCalled();
await act(async () => mocks.editorProps?.onSkillsOpenChange?.(true));
expect(list).toHaveBeenCalledWith(
's1',
expect.objectContaining({ clientId: expect.any(String) }),
);
expect(mocks.editorProps?.skills).toEqual(skills);
expect(mocks.editorProps?.skillsLoaded).toBe(true);
});
it.each(['default', 'auto-edit', 'yolo'] as const)(
'shows persisted policy as fixed even on an unbound Session (%s)',
async (approvalMode) => {
@@ -34,6 +34,7 @@ import { useManagedActions } from './use-managed-actions';
import { useManagedCwdChange } from './use-managed-cwd-change';
import { useManagedModelChange } from './use-managed-model-change';
import { useManagedApprovalChange } from './use-managed-approval-change';
import { useManagedSkills } from './use-managed-skills';
import { ManagedSessionCwdControl } from './ManagedSessionCwdControl';
import { toManagedPermissionRequest } from './managed-approval';
import { isNonRetryableClientError } from './managed-request-error';
@@ -165,6 +166,7 @@ function ManagedSessionsContent({
[provider.storageKey],
);
const detail = useManagedSession(provider, clientId, sessionId);
const skills = useManagedSkills(provider, clientId, sessionId);
const pageRef = useRef<HTMLDivElement>(null);
const taskTriggerRef = useRef<HTMLButtonElement>(null);
const [wideTasks, setWideTasks] = useState(false);
@@ -1164,6 +1166,13 @@ function ManagedSessionsContent({
return false;
}}
commands={[]}
skills={skills.skills}
onSkillsOpenChange={
skills.supported ? skills.onOpenChange : undefined
}
skillsLoading={skills.loading}
skillsLoadError={skills.error}
skillsLoaded={skills.loaded}
workspaceFeaturesEnabled={false}
attachmentsEnabled={
summary?.capabilities.inputAttachments === true
@@ -1172,7 +1181,8 @@ function ManagedSessionsContent({
interactive
? [
...(summary?.capabilities.inputAttachments ||
summary?.planModeSupported
summary?.planModeSupported ||
skills.supported
? ['addMenu' as const]
: []),
...(summary?.planModeSupported || summary?.planMode
@@ -601,6 +601,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/agent/web-shell/v1/session-skills/query": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** @description Read only names and descriptions from the Session's pinned static Skill catalog. Requires a trusted actor and Workspace read access. Never resolves the latest AgentDefinition or discovers host Skills. */
post: operations["queryWebShellSessionSkills"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1526,6 +1543,13 @@ export interface components {
mime_type: "image/png" | "image/jpeg" | "image/webp";
data: string;
};
WebShellSkill: {
name: string;
description: string;
};
WebShellSessionSkills: {
skills: components["schemas"]["WebShellSkill"][];
};
};
responses: {
/** @description The authenticated actor belongs to another tenant than X-Qwen-Tenant-Id or has an invalid ID (actor_scope_mismatch, which the tenant filter answers on every /v1/agents/ and WebShell route), or the resource is readable but the actor lacks this operation or the original Action responder permission. Artifact content reads may also return artifact_content_forbidden when the actor cannot read the original bytes. */
@@ -2803,4 +2827,34 @@ export interface operations {
409: components["responses"]["Conflict"];
};
};
queryWebShellSessionSkills: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["WebShellSessionRequest"];
};
};
responses: {
/** @description Session static Skill metadata. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WebShellSessionSkills"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
409: components["responses"]["Conflict"];
413: components["responses"]["PayloadTooLarge"];
};
};
}
@@ -317,6 +317,14 @@ export class JavaManagedAgentClient {
);
}
getSessionSkills(sessionId: string, signal?: AbortSignal) {
return this.post<Schemas['WebShellSessionSkills']>(
'/session-skills/query',
{ sessionId },
signal,
);
}
changeModel(
request: Schemas['WebShellChangeModelRequest'],
signal?: AbortSignal,
@@ -10,6 +10,37 @@ function jsonResponse(value: unknown): Response {
}
describe('createJavaManagedAgentProvider', () => {
it('reads the selected Session Skill catalog with authenticated cancellable requests', async () => {
const catalog = {
skills: [{ name: 'review', description: 'Published review.' }],
};
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(jsonResponse(catalog));
const provider = createJavaManagedAgentProvider({
baseUrl: 'https://product.example',
fetch: fetchImpl,
getHeaders: () => ({ authorization: 'Bearer scoped' }),
});
const abort = new AbortController();
expect(
await provider.skills!.list('session-a', {
clientId: 'client',
signal: abort.signal,
}),
).toEqual(catalog);
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe(
'https://product.example/api/agent/web-shell/v1/session-skills/query',
);
expect(init?.method).toBe('POST');
expect(init?.signal).toBe(abort.signal);
expect(init?.credentials).toBe('include');
expect(new Headers(init?.headers).get('authorization')).toBe(
'Bearer scoped',
);
expect(JSON.parse(String(init?.body))).toEqual({ sessionId: 'session-a' });
});
it('preserves Workspace registration keys and validates directory and operation identities', async () => {
const operation = {
operationId: 'registration-a',
@@ -147,6 +147,10 @@ export function createJavaManagedAgentProvider(
storageKey: storageKey(options),
canCancel: true,
acceptsWorkspaceCwd: false,
skills: {
list: (sessionId, request) =>
client.getSessionSkills(sessionId, request.signal),
},
tasks: {
list: (sessionId, request) =>
client.listTasks(
@@ -21,6 +21,8 @@ export type ManagedAgentRuntimeState =
export type ManagedApprovalMode = 'default' | 'auto-edit' | 'yolo';
export type ManagedInputBlock = components['schemas']['InputBlock'];
export type ManagedSessionSkills =
components['schemas']['WebShellSessionSkills'];
export function isManagedApprovalMode(
value: unknown,
@@ -228,6 +230,12 @@ export interface ManagedAgentProvider {
readonly storageKey: string;
readonly canCancel: boolean;
readonly acceptsWorkspaceCwd: boolean;
readonly skills?: {
list(
sessionId: string,
options: ManagedAgentRequestOptions,
): Promise<ManagedSessionSkills>;
};
/** Present when the provider can serve Hosted permission Actions. */
readonly actions?: {
listPending(
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type {
ManagedAgentProvider,
ManagedSessionSkills,
} from './managed-agent-provider';
import { useManagedSkills } from './use-managed-skills';
let root: Root;
let provider: ManagedAgentProvider;
let sessionId: string | undefined;
let current: ReturnType<typeof useManagedSkills>;
const catalog = {
skills: [{ name: 'review', description: 'Published review.' }],
};
function Probe() {
current = useManagedSkills(provider, 'client', sessionId);
return null;
}
const render = () => act(async () => root.render(<Probe />));
beforeEach(() => {
root = createRoot(document.createElement('div'));
sessionId = 'session-a';
provider = {
storageKey: 'tenant:actor',
skills: { list: vi.fn().mockResolvedValue(catalog) },
} as unknown as ManagedAgentProvider;
});
afterEach(async () => {
await act(async () => root.unmount());
});
it('lazily loads and caches a Session catalog including an empty catalog', async () => {
await render();
expect(provider.skills!.list).not.toHaveBeenCalled();
await act(async () => current.onOpenChange(true));
expect(current.skills).toEqual(catalog.skills);
expect(current.loaded).toBe(true);
await act(async () => current.onOpenChange(false));
await act(async () => current.onOpenChange(true));
expect(provider.skills!.list).toHaveBeenCalledOnce();
vi.mocked(provider.skills!.list).mockResolvedValue({ skills: [] });
sessionId = 'session-b';
await render();
expect(current.skills).toEqual([]);
expect(current.loaded).toBe(true);
});
it('shows failure and retries when reopened', async () => {
vi.mocked(provider.skills!.list).mockRejectedValueOnce(new Error('offline'));
await render();
await act(async () => current.onOpenChange(true));
expect(current.error).toBe(true);
expect(current.loading).toBe(false);
await act(async () => current.onOpenChange(false));
await act(async () => current.onOpenChange(true));
expect(current.error).toBe(false);
expect(current.skills).toEqual(catalog.skills);
});
it('aborts old requests and hides late results across Session and provider changes', async () => {
let release!: (value: ManagedSessionSkills) => void;
vi.mocked(provider.skills!.list).mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
await render();
await act(async () => current.onOpenChange(true));
expect(current.loading).toBe(true);
const signal = vi.mocked(provider.skills!.list).mock.calls[0][1].signal!;
sessionId = 'session-b';
await render();
expect(signal.aborted).toBe(true);
await act(async () =>
release({ skills: [{ name: 'old', description: 'Old.' }] }),
);
expect(current.skills).toEqual(catalog.skills);
provider = { ...provider, skills: undefined };
await render();
expect(current.supported).toBe(false);
expect(current.skills).toEqual([]);
sessionId = undefined;
await render();
expect(current.supported).toBe(false);
});
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import type {
ManagedAgentProvider,
ManagedSessionSkills,
} from './managed-agent-provider';
export function useManagedSkills(
provider: ManagedAgentProvider,
clientId: string,
sessionId: string | undefined,
) {
const [open, setOpen] = useState(false);
const [state, setState] = useState<{
provider: ManagedAgentProvider;
sessionId: string;
skills?: ManagedSessionSkills['skills'];
error: boolean;
}>();
const current =
state?.provider === provider && state.sessionId === sessionId
? state
: undefined;
const loaded = current?.skills !== undefined;
const supported = Boolean(provider.skills && sessionId);
useEffect(() => {
if (!open || loaded || !sessionId || !provider.skills) return;
const abort = new AbortController();
setState({ provider, sessionId, error: false });
void provider.skills
.list(sessionId, { clientId, signal: abort.signal })
.then(({ skills }) => {
if (!abort.signal.aborted)
setState({ provider, sessionId, skills, error: false });
})
.catch(() => {
if (!abort.signal.aborted)
setState({ provider, sessionId, error: true });
});
return () => abort.abort();
}, [provider, clientId, sessionId, open, loaded]);
return {
supported,
skills: current?.skills ?? [],
loading: supported && open && !loaded && !current?.error,
loaded,
error: current?.error === true,
onOpenChange: setOpen,
};
}
@@ -121,6 +121,7 @@ const SUB_DIALOG_KEYS = new Set([
'modelFallbacks',
]);
const HIDDEN_SETTING_KEYS = new Set([
'ui.allowedApprovalModes',
'ui.hideTips',
'ui.enableUserFeedback',
// Compact behavior is fixed on in the web shell; the daemon schema still
@@ -1,3 +1,4 @@
import type { WebShellApprovalMode } from './utils/planMode';
import type { ArtifactFilter } from './components/artifacts/TurnOutputs';
import {
createContext,
@@ -705,6 +706,7 @@ export type LoadingPhrasesResolver = (
) => readonly string[] | undefined | null;
export interface WebShellCustomization {
allowedApprovalModes?: readonly WebShellApprovalMode[];
filterArtifact?: ArtifactFilter;
artifact?: WebShellArtifactCustomization;
/** Host-specific label for the Ask User Question free-text choice. */
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Approval mode harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/e2e/approval-mode-harness.tsx"></script>
</body>
</html>
@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import type { WebShellProps } from '../App';
import '../styles/standalone.css';
const indexEntry = '../index.tsx';
const { WebShellWithProviders } = await import(/* @vite-ignore */ indexEntry);
const params = new URLSearchParams(window.location.search);
const splitSessionIds = params.get('split')?.split(',');
document.documentElement.classList.add('theme-dark', 'dark');
function ApprovalModeHarness() {
const [allowedApprovalModes, setAllowedApprovalModes] = useState<
WebShellProps['allowedApprovalModes']
>(
params.has('allowed')
? (params.get('allowed')!.split(',').filter(Boolean) as NonNullable<
WebShellProps['allowedApprovalModes']
>)
: undefined,
);
useEffect(() => {
const update = (event: Event) => {
setAllowedApprovalModes(
(event as CustomEvent<WebShellProps['allowedApprovalModes']>).detail,
);
};
window.addEventListener('approval-modes-change', update);
return () => window.removeEventListener('approval-modes-change', update);
}, []);
return (
<WebShellWithProviders
baseUrl={window.location.origin}
sessionId={params.get('sessionId') ?? 'approval-mode-harness-e2e'}
workspaceCwd={params.get('workspaceCwd') ?? '/tmp/qwen-web-shell-e2e'}
theme="dark"
allowedApprovalModes={allowedApprovalModes}
splitSessionIds={splitSessionIds}
composerToolbarActions={
params.has('plan') ? ['approvalMode', 'plan', 'addMenu'] : undefined
}
/>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ApprovalModeHarness />
</React.StrictMode>,
);
@@ -8,6 +8,8 @@ for (const width of [1280, 390]) {
const requests: Record<string, unknown>[] = [];
let summary: JavaAgentSession | undefined;
let laterInputs = 0;
let skillQueries = 0;
let skillInput: unknown;
let loseResponse = width === 390;
const workspaces = ['a', 'b'].map((id) => ({
workspaceId: `ws-${id}`,
@@ -86,7 +88,33 @@ for (const width of [1280, 390]) {
});
if (path.endsWith('/events/stream'))
return route.fulfill({ contentType: 'text/event-stream', body: '' });
if (path.endsWith('/turns/submit')) laterInputs++;
if (path.endsWith('/session-skills/query')) {
expect(body).toEqual({ sessionId: 'managed-ui-session' });
skillQueries++;
return route.fulfill({
json: {
skills: [
{
name: 'repository-review',
description: 'Review using the pinned guide.',
},
],
},
});
}
if (path.endsWith('/turns/submit')) {
laterInputs++;
skillInput = body.input;
return route.fulfill({
status: 202,
json: {
sessionId: summary!.sessionId,
turnId: 'skill-turn',
status: 'accepted',
replayed: false,
},
});
}
return route.fulfill({
status: 500,
json: { error: `Unexpected request: ${path}` },
@@ -142,6 +170,19 @@ for (const width of [1280, 390]) {
expect(second).toEqual(first);
}
expect(laterInputs).toBe(0);
expect(skillQueries).toBe(0);
await editor.fill('inspect the patch');
await page.getByTestId('composer-add-menu-trigger').click();
await page.getByTestId('composer-add-menu-skills').click();
await page.getByTestId('composer-add-menu-skills-item').click();
await expect(editor).toHaveText('/repository-review inspect the patch');
expect(skillQueries).toBe(1);
await editor.press('Enter');
await expect
.poll(() => skillInput)
.toEqual([
{ type: 'input_text', text: '/repository-review inspect the patch' },
]);
expect(errors).toEqual([]);
expect(
await page.evaluate(
@@ -0,0 +1,362 @@
import { mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import {
createWebShellDaemonScenario,
installMockDaemon,
type MockDaemonController,
} from './utils/mockDaemon';
import { completeReplay, submitLocalCommand } from './visuals/harness';
const modeButton = '[data-web-shell-mode-button]';
const menuOptions = '[data-web-shell-toolbar-popover] button[title]';
const dialogOptions = '[data-web-shell-approval-mode-option]';
async function openShell(
page: Page,
info: TestInfo,
options: {
host?: string[];
setting?: unknown;
currentMode?: string;
sideTask?: boolean;
} = {},
) {
const scenario = createWebShellDaemonScenario({
currentMode: options.currentMode ?? 'default',
});
scenario.capabilities.features.push('workspace_qualified_rest_core');
if (options.setting !== undefined) {
scenario.settings.settings.push({
key: 'ui.allowedApprovalModes',
type: 'array',
label: 'Allowed Approval Modes',
category: 'UI',
requiresRestart: false,
default: undefined,
values: { effective: options.setting },
});
}
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(info.project.use.baseURL),
});
if (options.sideTask) {
scenario.capabilities.features.push('session_side_task');
await page.route(`**/session/${scenario.sessionId}/side-task`, (route) =>
route.fulfill({
json: {
sessionId: 'previous-session',
workspaceCwd: scenario.workspaceCwd,
attached: false,
clientId: 'side-task-fixture-client',
createdAt: '2026-07-03T00:00:00.000Z',
hasActivePrompt: false,
displayName: 'Side task',
parentSessionId: scenario.sessionId,
state: scenario.state,
},
}),
);
}
const params = new URLSearchParams({
sessionId: scenario.sessionId,
workspaceCwd: scenario.workspaceCwd,
});
if (options.host !== undefined) params.set('allowed', options.host.join(','));
if (options.currentMode === 'plan') params.set('plan', 'true');
await page.goto(`/e2e/approval-mode-harness.html?${params}`);
await page.getByRole('button', { name: /^E2E Harness Session/ }).click();
await completeReplay(page, daemon, scenario.sessionId);
await expect(page.locator(modeButton)).toBeVisible();
return daemon;
}
async function expectChoices(page: Page, modes: string[]) {
await page.locator(modeButton).click();
await expect(page.locator(menuOptions)).toHaveCount(modes.length);
for (const [index, mode] of modes.entries()) {
await expect(page.locator(menuOptions).nth(index)).toContainText(
`(${mode})`,
);
}
await page.keyboard.press('Escape');
await submitLocalCommand(page, '/approval-mode');
await expect(page.locator(dialogOptions)).toHaveCount(modes.length);
expect(
await page
.locator(dialogOptions)
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute('data-mode-id')),
),
).toEqual(modes);
await page.keyboard.press('Escape');
}
function modeRequests(daemon: MockDaemonController) {
return daemon.requests.filter((request) =>
request.path.endsWith('/approval-mode'),
);
}
async function expectCycle(
page: Page,
daemon: MockDaemonController,
modes: string[],
) {
for (const mode of modes) {
const previousCount = modeRequests(daemon).length;
await page.locator('[data-web-shell-composer-editor] .cm-content').focus();
await page.keyboard.press('Shift+Tab');
await expect
.poll(() => modeRequests(daemon).length)
.toBe(previousCount + 1);
expect(modeRequests(daemon).at(-1)?.body).toMatchObject({ mode });
await expect(page.locator(modeButton)).toBeEnabled();
}
}
test('default keeps all four choices and existing cycle @smoke', async ({
page,
}, info) => {
const daemon = await openShell(page, info);
await expectChoices(page, ['default', 'auto-edit', 'auto', 'yolo']);
await expectCycle(page, daemon, ['auto-edit', 'auto', 'yolo', 'default']);
});
test('workspace settings filter the dropdown, dialog, and cycle @smoke', async ({
page,
}, info) => {
const daemon = await openShell(page, info, { setting: ['auto', 'default'] });
await expectChoices(page, ['default', 'auto']);
await expectCycle(page, daemon, ['auto', 'default']);
});
test('host overrides settings and preserves an excluded current mode @smoke', async ({
page,
}, info) => {
const daemon = await openShell(page, info, {
setting: ['auto'],
host: ['yolo', 'default', 'yolo'],
currentMode: 'auto-edit',
});
await expect(page.locator(modeButton)).toContainText('Auto Edit');
await expectChoices(page, ['default', 'yolo']);
expect(modeRequests(daemon)).toHaveLength(0);
await expectCycle(page, daemon, ['default', 'yolo', 'default']);
});
test('empty host override disables selection and cycling @smoke', async ({
page,
}, info) => {
const daemon = await openShell(page, info, {
setting: ['default', 'auto'],
host: [],
});
await expect(page.locator(modeButton)).toBeDisabled();
await page.locator('[data-web-shell-composer-editor] .cm-content').focus();
await page.keyboard.press('Shift+Tab');
await submitLocalCommand(page, '/approval-mode');
await expect(
page.locator('[data-web-shell-approval-mode-dialog]'),
).toBeVisible();
await expect(page.locator(dialogOptions)).toHaveCount(0);
expect(modeRequests(daemon)).toHaveLength(0);
});
test('singleton current mode does not submit a redundant request @smoke', async ({
page,
}, info) => {
const daemon = await openShell(page, info, { host: ['default'] });
await expectChoices(page, ['default']);
await page.locator('[data-web-shell-composer-editor] .cm-content').focus();
await page.keyboard.press('Shift+Tab');
await expect(page.locator(modeButton)).toBeEnabled();
expect(modeRequests(daemon)).toHaveLength(0);
});
test('empty allowlist keeps Plan independently dismissible @smoke', async ({
page,
}, info) => {
const daemon = await openShell(page, info, { host: [], currentMode: 'plan' });
await expect(page.locator(modeButton)).toBeDisabled();
await page.locator('[data-web-shell-plan-chip]').click();
await expect.poll(() => modeRequests(daemon).length).toBe(1);
expect(modeRequests(daemon)[0].body).toMatchObject({
mode: 'default',
planMode: false,
});
await expect(page.locator('[data-web-shell-plan-chip]')).toHaveCount(0);
});
test('dynamic host changes update an open picker @smoke', async ({
page,
}, info) => {
const daemon = await openShell(page, info);
await page.locator(modeButton).click();
await expect(page.locator(menuOptions)).toHaveCount(4);
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent('approval-modes-change', { detail: ['default', 'auto'] }),
),
);
await expect(page.locator(menuOptions)).toHaveCount(2);
const directory = resolve('../../.qwen/e2e-tests/approval-mode-evidence');
await mkdir(directory, { recursive: true });
await page.screenshot({
path: resolve(directory, 'allowed-default-auto-menu.png'),
fullPage: true,
animations: 'disabled',
});
await page.keyboard.press('Escape');
await submitLocalCommand(page, '/approval-mode');
await expect(page.locator(dialogOptions)).toHaveCount(2);
await page.screenshot({
path: resolve(directory, 'allowed-default-auto-dialog.png'),
fullPage: true,
animations: 'disabled',
});
await page.keyboard.press('ArrowDown');
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent('approval-modes-change', { detail: ['yolo'] }),
),
);
await expect(page.locator(dialogOptions)).toHaveCount(1);
await page.keyboard.press('Enter');
await expect.poll(() => modeRequests(daemon).length).toBe(1);
expect(modeRequests(daemon)[0].body).toMatchObject({ mode: 'yolo' });
});
test('emptying the allowlist under an open picker hands focus to the composer @smoke', async ({
page,
}, info) => {
await openShell(page, info);
await page.locator(modeButton).click();
await expect(page.locator(menuOptions)).toHaveCount(4);
// The trigger becomes disabled and the open menu closes in the same commit;
// the browser drops their focus to the body and the handoff must catch it.
await page.evaluate(() =>
window.dispatchEvent(
new CustomEvent('approval-modes-change', { detail: [] }),
),
);
await expect(page.locator(menuOptions)).toHaveCount(0);
await expect(page.locator(modeButton)).toBeDisabled();
await expect(
page.locator('[data-web-shell-composer-editor] .cm-content'),
).toBeFocused();
});
test('host allowlist applies to both split pane dropdowns @smoke', async ({
page,
}, info) => {
await page.setViewportSize({ width: 1440, height: 900 });
const scenario = createWebShellDaemonScenario();
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(info.project.use.baseURL),
});
const params = new URLSearchParams({
sessionId: scenario.sessionId,
workspaceCwd: scenario.workspaceCwd,
allowed: 'default,auto',
split: `${scenario.sessionId},previous-session`,
});
await page.goto(`/e2e/approval-mode-harness.html?${params}`);
await completeReplay(page, daemon, scenario.sessionId);
await completeReplay(page, daemon, 'previous-session');
const panes = page.getByTestId('chat-pane');
await expect(panes).toHaveCount(2);
for (const pane of await panes.all()) {
await pane.locator(modeButton).click();
await expect(page.locator(menuOptions)).toHaveCount(2);
await expect(page.locator(menuOptions).nth(0)).toContainText('(default)');
await expect(page.locator(menuOptions).nth(1)).toContainText('(auto)');
await page.keyboard.press('Escape');
}
});
test('host allowlist applies to an actual side-task panel @smoke', async ({
page,
}, info) => {
const daemon = await openShell(page, info, {
host: ['default', 'auto'],
sideTask: true,
});
await submitLocalCommand(page, '/btw side Check fixture behavior');
await completeReplay(page, daemon, 'previous-session');
const sideTask = page.getByTestId('chat-pane');
await expect(sideTask).toHaveCount(1);
await sideTask.locator(modeButton).click();
await expect(page.locator(menuOptions)).toHaveCount(2);
await expect(page.locator(menuOptions).nth(0)).toContainText('(default)');
await expect(page.locator(menuOptions).nth(1)).toContainText('(auto)');
});
for (const split of [false, true]) {
test(`secondary workspace reads its scoped settings route in ${split ? 'split' : 'main'} view @smoke`, async ({
page,
}, info) => {
const primary = '/tmp/approval-primary';
const secondary = '/tmp/approval-secondary';
const scenario = createWebShellDaemonScenario({
workspaceCwd: secondary,
capabilities: {
workspaceCwd: primary,
workspaces: [
{ id: 'primary', cwd: primary, primary: true, trusted: true },
{ id: 'secondary', cwd: secondary, primary: false, trusted: true },
],
},
});
scenario.capabilities.features.push('workspace_qualified_rest_core');
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(info.project.use.baseURL),
});
const scopedPath = `/workspaces/${encodeURIComponent(secondary)}/settings`;
let scopedReads = 0;
await page.route('**/*', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/workspace/settings' || path === scopedPath) {
if (path === scopedPath) scopedReads++;
// The routes answer differently on purpose: any code path applying
// the wrong workspace's read renders (default)/(yolo) and fails.
const effective = path === scopedPath ? ['auto'] : ['default', 'yolo'];
await route.fulfill({
json: {
v: 1,
settings: [
{
key: 'ui.allowedApprovalModes',
type: 'array',
label: 'Modes',
category: 'UI',
requiresRestart: false,
values: { effective },
},
],
},
});
} else await route.fallback();
});
const params = new URLSearchParams({
sessionId: scenario.sessionId,
workspaceCwd: secondary,
});
if (split) params.set('split', `${scenario.sessionId},previous-session`);
await page.goto(`/e2e/approval-mode-harness.html?${params}`);
if (!split)
await page.getByRole('button', { name: /^E2E Harness Session/ }).click();
await completeReplay(page, daemon, scenario.sessionId);
if (split) await completeReplay(page, daemon, 'previous-session');
await expect.poll(() => scopedReads).toBeGreaterThan(0);
if (split) {
const pane = page.getByTestId('chat-pane').first();
await pane.locator(modeButton).click();
await expect(page.locator(menuOptions)).toHaveCount(1);
await expect(page.locator(menuOptions)).toContainText('(auto)');
} else {
await expectChoices(page, ['auto']);
await expectCycle(page, daemon, ['auto']);
}
});
}
@@ -0,0 +1,210 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { DaemonWorkspaceSettingsStatus } from '@qwen-code/sdk/daemon';
import type { WebShellApprovalMode } from '../utils/planMode';
const mocks = vi.hoisted(() => {
const settings = vi.fn<() => Promise<DaemonWorkspaceSettingsStatus>>();
const workspaceByCwd = vi.fn(() => ({ workspaceSettings: settings }));
return {
settings,
workspaceByCwd,
client: { workspaceByCwd },
capabilities: { features: [] as string[] },
status: 'connected',
version: 0,
};
});
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],
useWorkspace: () => ({
client: mocks.client,
capabilities: mocks.capabilities,
status: mocks.status,
}),
useWorkspaceEventSignals: () => ({ settingsVersion: mocks.version }),
}));
const { useApprovalModes } = await import('./useApprovalModes');
let root: Root;
let container: HTMLDivElement;
function status(modes: string[]): DaemonWorkspaceSettingsStatus {
return {
v: 1,
settings: [
{
key: 'ui.allowedApprovalModes',
type: 'array',
label: 'Modes',
category: 'UI',
requiresRestart: false,
default: [],
values: { effective: modes },
},
],
};
}
function Probe({
cwd,
allowed,
}: {
cwd?: string;
allowed?: readonly WebShellApprovalMode[];
}) {
return <span>{useApprovalModes(cwd, allowed).join(',')}</span>;
}
function render(cwd?: string, allowed?: readonly WebShellApprovalMode[]) {
act(() => root.render(<Probe cwd={cwd} allowed={allowed} />));
}
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
mocks.settings.mockReset().mockResolvedValue(status(['default']));
mocks.workspaceByCwd.mockClear();
mocks.client = { workspaceByCwd: mocks.workspaceByCwd };
mocks.capabilities = {
features: ['workspace_qualified_rest_core', 'workspace_settings'],
};
mocks.status = 'connected';
mocks.version = 0;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('reads only the requested workspace and clears choices when its target changes', async () => {
mocks.settings.mockResolvedValueOnce(status(['auto']));
render('/secondary');
await flush();
expect(mocks.workspaceByCwd).toHaveBeenCalledWith('/secondary');
expect(container.textContent).toBe('auto');
render('/third');
expect(container.textContent).toBe('default,auto-edit,auto,yolo');
await flush();
expect(mocks.workspaceByCwd).toHaveBeenLastCalledWith('/third');
expect(container.textContent).toBe('default');
});
it('honors host overrides including empty lists without reading settings', async () => {
render('/secondary', ['auto']);
expect(container.textContent).toBe('auto');
render('/secondary', []);
expect(container.textContent).toBe('');
expect(mocks.workspaceByCwd).not.toHaveBeenCalled();
render('/secondary');
await flush();
expect(container.textContent).toBe('default');
});
it('reloads after a settings event', async () => {
render('/secondary');
await flush();
mocks.settings.mockResolvedValueOnce(status(['auto']));
mocks.version++;
render('/secondary');
await flush();
expect(container.textContent).toBe('auto');
expect(mocks.settings).toHaveBeenCalledTimes(2);
});
it('ignores a late response from the previous workspace', async () => {
let resolveOld!: (value: DaemonWorkspaceSettingsStatus) => void;
mocks.settings.mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
);
render('/old');
mocks.settings.mockResolvedValueOnce(status(['auto']));
render('/secondary');
await flush();
await act(async () => {
resolveOld(status(['yolo']));
});
expect(container.textContent).toBe('auto');
});
it('does not reuse a prior daemon setting for the same path', async () => {
render('/secondary');
await flush();
expect(container.textContent).toBe('default');
mocks.client = { workspaceByCwd: mocks.workspaceByCwd };
mocks.settings.mockRejectedValueOnce(new Error('unavailable'));
render('/secondary');
expect(container.textContent).toBe('default,auto-edit,auto,yolo');
await flush();
// The new client has no successful read to fall back on: fail closed
// rather than revive the old client's list or offer every mode.
expect(container.textContent).toBe('');
});
it('never reads primary settings for an absent workspace or failed scoped request', async () => {
render();
expect(mocks.workspaceByCwd).not.toHaveBeenCalled();
mocks.settings.mockRejectedValueOnce(new Error('workspace unavailable'));
render('/missing');
await flush();
expect(mocks.workspaceByCwd).toHaveBeenCalledOnce();
expect(mocks.workspaceByCwd).toHaveBeenCalledWith('/missing');
expect(container.textContent).toBe('');
});
it('retries a failed read on the next settings event', async () => {
mocks.settings.mockRejectedValueOnce(new Error('unavailable'));
render('/secondary');
await flush();
expect(container.textContent).toBe('');
mocks.version++;
render('/secondary');
await flush();
expect(mocks.settings).toHaveBeenCalledTimes(2);
expect(container.textContent).toBe('default');
});
it('keeps the default list and skips the read when the daemon lacks the qualified settings route', async () => {
mocks.capabilities = { features: [] };
render('/secondary');
await flush();
expect(mocks.workspaceByCwd).not.toHaveBeenCalled();
expect(container.textContent).toBe('default,auto-edit,auto,yolo');
});
it('retries a failed read when the workspace reconnects', async () => {
mocks.settings.mockRejectedValueOnce(new Error('unavailable'));
render('/secondary');
await flush();
expect(container.textContent).toBe('');
mocks.status = 'connecting';
render('/secondary');
await flush();
// No doomed request while the workspace is still reconnecting.
expect(mocks.settings).toHaveBeenCalledTimes(1);
mocks.status = 'connected';
render('/secondary');
await flush();
expect(mocks.settings).toHaveBeenCalledTimes(2);
expect(container.textContent).toBe('default');
});
it('keeps the last-known-good list when a reload fails', async () => {
mocks.settings.mockResolvedValueOnce(status(['auto']));
render('/secondary');
await flush();
expect(container.textContent).toBe('auto');
mocks.settings.mockRejectedValueOnce(new Error('unavailable'));
mocks.version++;
render('/secondary');
await flush();
expect(mocks.settings).toHaveBeenCalledTimes(2);
expect(container.textContent).toBe('auto');
});
@@ -0,0 +1,99 @@
import { useEffect, useMemo, useState } from 'react';
import type { DaemonClient } from '@qwen-code/sdk/daemon';
import {
useWorkspace,
useWorkspaceEventSignals,
} from '@qwen-code/web-shell/daemon-react-sdk';
import {
QUALIFIED_CORE_FEATURE,
WORKSPACE_SETTINGS_FEATURE,
} from '../voice/voice-workspace-target';
import {
resolveApprovalModes,
type WebShellApprovalMode,
} from '../utils/planMode';
const NO_APPROVAL_MODES: readonly WebShellApprovalMode[] = [];
interface ScopedSetting {
client: DaemonClient;
cwd: string;
failed: boolean;
// Present once a read for this client+cwd has succeeded, so a rejected
// reload can keep the last-known-good value.
value?: { effective: unknown };
}
export function useApprovalModes(
cwd: string | undefined,
allowedApprovalModes: readonly WebShellApprovalMode[] | undefined,
): readonly WebShellApprovalMode[] {
const { client, capabilities, status } = useWorkspace();
const settingsVersion = useWorkspaceEventSignals()?.settingsVersion;
const [setting, setSetting] = useState<ScopedSetting>();
// The qualified settings route is registered only by daemons advertising
// these features; against an older daemon keep the default list rather
// than failing closed on a route that will never appear.
const scopedSettingsSupported = [
QUALIFIED_CORE_FEATURE,
WORKSPACE_SETTINGS_FEATURE,
].every((feature) => capabilities?.features?.includes(feature));
useEffect(() => {
if (allowedApprovalModes !== undefined || !cwd || !scopedSettingsSupported)
return;
// A reconnect re-runs this effect and so retries a failed read; skip the
// doomed request while the workspace is still reconnecting.
if (status !== 'connected') return;
let cancelled = false;
void client
.workspaceByCwd(cwd)
.workspaceSettings()
.then((status) => {
if (!cancelled)
setSetting({
client,
cwd,
failed: false,
value: {
effective: status.settings.find(
(entry) => entry.key === 'ui.allowedApprovalModes',
)?.values.effective,
},
});
})
.catch(() => {
if (cancelled) return;
setSetting((previous) => ({
client,
cwd,
failed: true,
value:
previous?.client === client && previous.cwd === cwd
? previous.value
: undefined,
}));
});
return () => {
cancelled = true;
};
}, [
allowedApprovalModes,
client,
cwd,
scopedSettingsSupported,
settingsVersion,
status,
]);
return useMemo(() => {
if (allowedApprovalModes !== undefined)
return resolveApprovalModes(allowedApprovalModes);
const scoped =
setting?.client === client && setting.cwd === cwd ? setting : undefined;
// A rejection is not "no allowlist": resolving it to the default list
// would silently drop the operator's restriction, so a failed first read
// offers nothing and a failed reload keeps the last-known-good list. The
// next settings event re-runs the effect above and retries the read.
if (scoped?.failed && !scoped.value) return NO_APPROVAL_MODES;
return resolveApprovalModes(scoped?.value?.effective);
}, [allowedApprovalModes, client, cwd, setting]);
}
+1
View File
@@ -37,6 +37,7 @@ const refreshCapabilities = vi.fn();
vi.mock('@qwen-code/web-shell/daemon-react-sdk', async () => {
const React = await import('react');
return {
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
DaemonWorkspaceProvider: ({ children }: { children: React.ReactNode }) => {
if (workspaceShouldThrow) throw new Error('provider boom');
return React.createElement(React.Fragment, null, children);
+1
View File
@@ -17,6 +17,7 @@ import { normalizeLanguage, type WebShellLanguage } from './i18n';
export { WebShellTranscript } from './components/WebShellTranscript';
export type { WebShellTranscriptProps } from './components/WebShellTranscript';
export * from './daemon-react-sdk';
export type { WebShellApprovalMode } from './utils/planMode';
export type { WebShellBrowserNotificationsOptions } from './browser-turn-notifications';
export {
JavaManagedAgentClient,
@@ -261,6 +261,9 @@ export const SETTINGS_MESSAGES_ZH: Record<string, SettingsMessage> = {
'一个 PR 要达到多少轮评审之后,评审才可以追加一段提示性文字,说明看起来成问题的是改动的方向而不是当前补丁本身。它只在 diff 自评审首次测量以来也膨胀了数倍时出现,且从不出现在 Approve 上。它仅是披露:不新增发现、不改变结论、不阻塞任何事——它存在的原因是每条发现都锚定在当前 diff 的某一行上,所以评审能报告某个方向在哪里漏,却永远说不出换个方向能一次性消除所有这些漏洞。保持 0 表示用内置的 5 轮阈值;调大让这段文字更晚出现;设得非常大则彻底关闭。非正整数的值会被忽略。仅在 User、System、SystemDefaults 作用域生效;Workspace 作用域的值会被忽略,仓库无法为其评审者设定评审策略。',
'settings.label.ui.theme': '主题',
'settings.description.ui.theme': '界面的颜色主题。',
'settings.label.ui.allowedApprovalModes': 'Web Shell 审批模式',
'settings.description.ui.allowedApprovalModes':
'Web Shell 提供的执行审批模式。未配置时显示 default、auto-edit、auto 和 yolo;空列表不提供任何选项。宿主 allowedApprovalModes 优先于此设置。仅控制展示,不改变当前模式或强制约束后端权限。仅在 User、System、SystemDefaults 作用域生效;Workspace 作用域的值会被忽略并产生警告。请直接编辑 settings.json。',
'settings.label.ui.hideTips': '隐藏提示',
'settings.description.ui.hideTips': '隐藏界面中的帮助提示。',
'settings.label.ui.enableWelcomeBack': '显示欢迎回来对话框',
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { EXECUTION_APPROVAL_MODES, parsePlanCommand } from './planMode';
import {
EXECUTION_APPROVAL_MODES,
parsePlanCommand,
resolveApprovalModes,
} from './planMode';
describe('Plan commands', () => {
it('keeps workflow out of execution permissions', () => {
@@ -26,3 +30,30 @@ describe('Plan commands', () => {
expect(parsePlanCommand(args, enabled)).toEqual(expected);
});
});
describe('approval mode choices', () => {
it.each([undefined, null, 'auto', {}])(
'defaults malformed or absent %j to all modes',
(value) => {
expect(resolveApprovalModes(value)).toEqual([
'default',
'auto-edit',
'auto',
'yolo',
]);
},
);
it('keeps canonical order and ignores duplicates, unknown values and Plan', () => {
expect(
resolveApprovalModes(['auto', 'plan', 'default', 'auto', 'bogus']),
).toEqual(['default', 'auto']);
});
it.each([{ values: [] }, { values: ['plan', 'bogus'] }])(
'offers no modes for $values',
({ values }) => {
expect(resolveApprovalModes(values)).toEqual([]);
},
);
});
@@ -1,9 +1,33 @@
import { DAEMON_APPROVAL_MODES } from '@qwen-code/web-shell/daemon-react-sdk';
export type WebShellApprovalMode = Exclude<
(typeof DAEMON_APPROVAL_MODES)[number],
'plan'
>;
export const EXECUTION_APPROVAL_MODES = DAEMON_APPROVAL_MODES.filter(
(mode) => mode !== 'plan',
);
export function resolveApprovalModes(
value: unknown,
): readonly WebShellApprovalMode[] {
if (!Array.isArray(value)) return EXECUTION_APPROVAL_MODES;
return EXECUTION_APPROVAL_MODES.filter((mode) => value.includes(mode));
}
export function nextApprovalMode(
available: readonly WebShellApprovalMode[],
current: string,
): WebShellApprovalMode | undefined {
// findIndex, not indexOf: narrowing `current` to the tuple member type
// silently degrades when the SDK's declaration bundle leaves its
// permission-mode import dangling, and the build must survive both states.
const idx = available.findIndex((mode) => mode === current);
const next = available[(idx + 1) % available.length];
return next && next !== current ? next : undefined;
}
export function parsePlanCommand(
args: string,
enabled: boolean,
@@ -15,8 +15,8 @@ import type {
const PRIMARY_VOICE_FEATURE = 'voice_transcribe';
const QUALIFIED_VOICE_FEATURE = 'workspace_qualified_voice';
const QUALIFIED_CORE_FEATURE = 'workspace_qualified_rest_core';
const WORKSPACE_SETTINGS_FEATURE = 'workspace_settings';
export const QUALIFIED_CORE_FEATURE = 'workspace_qualified_rest_core';
export const WORKSPACE_SETTINGS_FEATURE = 'workspace_settings';
const MODERN_WORKSPACE_FEATURES = new Set([
'dynamic_workspace_registration',
+16 -7
View File
@@ -415,9 +415,9 @@ describe('Hosted real-process gates', () => {
});
it.each([
// Step ceilings today (120 + 10 + 20 + 50): Verify, the Broker fault
// Step ceilings today (134 + 10 + 20 + 50): Verify, the Broker fault
// gates, admission and O4. The failover arms moved to their own job.
['hosted-harness-mysql', 200, 213],
['hosted-harness-mysql', 214, 227],
// Fifteen failover arms at 10 each.
['hosted-failover-e2e', 150, 165],
])(
@@ -609,13 +609,17 @@ describe('Hosted real-process gates', () => {
// 6233; the FG12b store-failure class adds ~120 s (49.6 s measured
// locally on JDK 21 + MySQL 8.0.46), so the fork moves to 6353 and the
// Verify step to 116 minutes; the FG12f stream-gap class adds ~242 s
// (121.1 s measured on the merged base), so the fork moves to 6585 and
// the Verify step to 120 minutes):
// (121.1 s measured on the merged base), so the fork moves to 6585;
// the FG13a execute lost-reply cell adds 150 s, so the fork moves to
// 6735; the FG13a status lost-reply cell adds 150 s, so the fork moves
// to 6885 and the Verify step to 125 minutes; the FG11c Harness-crash
// method (144.8 s measured, 550 s budgeted) moves the fork to 7435 and
// the Verify step to 134 minutes):
// the relational check above tolerates
// anything up to step*60, so the value is pinned literally here —
// an unnoticed raise has no other red to land on.
if (profile === 'hosted-harness-mysql') {
expect(forkSeconds).toBe(6585);
expect(forkSeconds).toBe(7435);
}
},
);
@@ -669,11 +673,15 @@ describe('Hosted real-process gates', () => {
// class adds ~120s (49.6s measured on JDK 21 + MySQL 8.0.46), so it
// becomes 5743s; the FG12f stream-gap class (HostedMcpStreamGapIT, three
// cases, three cold boots) measures 121.1s locally on the merged base
// (~242s budgeted), so it becomes 5985s. The largest ceiling (600s) plus
// (~242s budgeted), so it becomes 5985s; the FG13a execute lost-reply
// cell adds 150s, so it becomes 6135s; the FG13a status lost-reply cell
// adds 150s, so it becomes 6285s; the FG11c Harness-crash method (144.8s
// measured, ~550s budgeted at 2x) becomes 6835s. The largest ceiling
// (600s) plus
// that healthy sum still fits the fork. A larger ceiling lets failsafe
// kill the fork mid-run, and the classes left unrun never report, failing
// the gate undiagnosably (#13780).
const hostedForkHealthySeconds = 5985;
const hostedForkHealthySeconds = 6835;
const hostedForkSeconds = () =>
Number(
read('packages/managed-agent-server/pom.xml')
@@ -742,6 +750,7 @@ describe('Hosted real-process gates', () => {
'HostedHarnessMySqlIT',
'HostedHookCancelFaultGatesIT',
'HostedHookFaultGatesIT',
'HostedHookReplyLossFaultGatesIT',
'HostedMcpReplyLossIT',
'HostedMcpRestartFaultGatesIT',
'HostedMcpStoreFaultGatesIT',