fix(cli): reject malformed hosted shell Unicode before admission (#13678)

* fix(cli): reject malformed hosted shell Unicode before admission

Prevent lone UTF-16 surrogates from reaching Workspace acquisition
or execution while preserving valid Unicode and durable history.

- Refuse whole batches before Hooks and native tool admission
- Stop malformed sequential Hook rewrites and restore safe refusals
- Suppress post-tool callbacks for live and replayed Unicode refusals
- Cover local/O2 persistence, same-turn correction, and Session reuse
- Document the admission design and local acceptance evidence

* fix(cli): preserve durable hosted shell Unicode refusals

Keep malformed Shell input refusals bounded and recoverable after
Hook rewrites without losing policy denials or the original store error.

- Normalize optional arguments and validate reduced refusal evidence
- Fold nested permission rewrites and bound assembled Hook receipts
- Read recovery resources concurrently while preserving event order
- Cover MCP and background guards, sibling denials and failed writes
- Stabilize Hook admission waits and verify the second Session alone

* fix(cli): keep hosted Unicode refusals replayable and batch-wide

Keep durable Unicode refusals correctable across terminal Hook
recovery and bounded receipts, without admitting sibling tools.

- Accept proven-unstarted Hooks while rejecting blocked evidence
- Preserve a fixed Unicode reason in byte-bounded Hook refusals
- Retain each sibling's own argument validation error
- Pin MCP admission, Hook handoff and continuation regression cases

* fix(cli): recover cancelled size-refused hook plans

Treat cancelled Hook plans as terminal when their durable evidence
proves a result or no dispatch, so Unicode batch refusals can replay.

- Preserve failure-closed checks for unresolved or contradictory evidence
- Cover bounded permission refusals and independent recovery guard failures
- Assert Java capture lanes and single successful dispatch in both sessions

* test(sdk-java): scope Unicode publication checks per session

Shared publication rows made the local gate depend on invocation order.
A global positive count also hid a missing secondary-session capture.

- Filter publication counts by the current tenant and each session
- Preserve local zero and o2 positive checks for both sessions

* test(sdk-java): verify hosted Unicode capture completion

Make local publication checks run against an available service,
and distinguish completed o2 captures from reservation rows.

- Register publication fixtures for both capture modes
- Require referenced and complete results in each o2 session

* test(sdk-java): align Unicode gate with managed schema

Keep the hosted Unicode oracle runnable after schema consolidation.
Use canonical tables without changing the per-session completion
and single-dispatch assertions.

- Query captures from ma_output_capture
- Query dispatch evidence from ma_tool_execution

* test(managed-agent): align Unicode gate with main contracts

Keep the hosted Unicode gate compatible with the server's updated
Checkstyle rules and object-store contract after the module move.

- Add braces around the missing-object fixture refusal
- Add braces around the local-mode empty-capture assertion
- Align the fixture with the current object-store validation method

* test(managed-agent): register native macOS workspace fixtures

The Hosted tool-turn fixture registered guarded workspaces only on
Linux, leaving corrected Shell calls unavailable on supported macOS
hosts after malformed input had been refused.

- Match fixture registration to the native storage guard platforms
- Exercise the existing physical checks for both supported host systems

Native macOS local/O2 tests and the non-Unicode workspace control pass
with the original assertions, physical guards and execution deadlines.

---------

Co-authored-by: wangheyang <[email protected]>
This commit is contained in:
Heyang Wangandwangheyang authored and GitHub committed 2026-10-23 02:16:46 +00:00
1 parent f0a47611ab
commit 8666ab091f
13 files changed
+3174 -114

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
# Hosted Shell Unicode E2E
This guide covers the real-process gate for the fix described in the
[English design](docs/design/managed-agents/2026-10-08-hosted-shell-unicode-admission.md)
and the [Chinese design](docs/design/managed-agents/2026-10-08-hosted-shell-unicode-admission.zh-CN.md).
It covers one behavior: a Hosted Shell call that carries an unpaired UTF-16
surrogate is refused before Workspace admission, the refusal is durable, and the
model can correct the call in the same turn.
## What the test proves
The gate runs one JUnit parameterized test with two capture paths, `local` and
`o2`. For each capture path it proves these points:
- Eight standalone malformed batches produce ordered, paired tool errors. The
matrix is `command` and `description`, each with `\ud800`, `\udc00`,
`😀\ud800`, and `\udc00\ud800`.
- The only Broker requests these batches make end in `/runtimes:warm`, which
means no `acquire`, `prepare`, or `start` request. Runtime warming starts
before the model output, so the test allows it.
- The driver observes no new entry in the publication reservation list.
- Neither the malformed shell command nor its valid `write_file` sibling creates
a file.
- Session detach and load keep the original arguments and the paired errors.
- A corrected command runs once in the same prompt turn. It preserves Chinese
text, emoji, combining characters, and the literal six-character `\ud800`.
- A second Session on the same Workspace runs a valid call. Neither path becomes
`recoveryBlocked`.
The test uses the `hosted-workspace-shell/1` profile. The `/2` profile has unit
coverage only.
## Prerequisites
- Node.js 22 or later.
- JDK 21. Set `JAVA_HOME` to it.
- Maven 3.8.9 or later.
- Installed workspace dependencies.
From the repository root:
```bash
corepack pnpm install --frozen-lockfile
```
## Build the CLI
The Java gate runs the bundled CLI as the Runtime worker, so build it first:
```bash
npm run build
npm run bundle
```
The gate reads `dist/cli.js`. Set `qwen.cli.entry` to that file when you run the
test.
## Install the Java prerequisite
The Runtime Broker module no longer has its own POM; the server module uses the
`qwencode` module:
```bash
mvn -q -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install
```
## Run the gate
Compile the server and its tests first:
```bash
mvn -f packages/managed-agent-server/pom.xml -DskipTests test-compile
```
Then run the single test method through the failsafe plugin:
```bash
mvn -f packages/managed-agent-server/pom.xml \
-Phosted-workspace-tools \
-Dnode.executable="$(command -v node)" \
-Dqwen.cli.entry="$PWD/dist/cli.js" \
-Dit.test='HostedWorkspaceToolTurnIT#malformedShellUnicodeIsCorrectableBeforeAcquisition' \
failsafe:integration-test failsafe:verify
```
Do not add `-Dtest=none` or `-Dsurefire.failIfNoSpecifiedTests=false`. The
module sets `failIfNoTests` to `true`, and that combination stops the build at
surefire with `No tests were executed!`.
## Success conditions
The run passes when all of these lines appear:
```text
HOSTED_SHELL_UNICODE_OK: local, 9 refusals per capture, durable reload, Unicode correction, second Session
HOSTED_SHELL_UNICODE_OK: o2, 9 refusals per capture, durable reload, Unicode correction, second Session
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The Druid `testWhileIdle is true, validationQuery not set` line is expected and
does not indicate a failure.
## Recorded result
Measured on macOS arm64 with Node.js 22.23.3, JDK 21.0.13, and Maven 3.9.9, on
the merge of `main` `ce8a94706b` into the PR branch:
```text
HOSTED_SHELL_UNICODE_OK: local, 9 refusals per capture, durable reload, Unicode correction, second Session
HOSTED_SHELL_UNICODE_OK: o2, 9 refusals per capture, durable reload, Unicode correction, second Session
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.79 s -- in com.alibaba.qwen.code.managedagent.HostedWorkspaceToolTurnIT
[INFO] BUILD SUCCESS
```
## Test environment
The gate runs in one JVM and starts these real components:
- The managed-agent server with a temporary H2 database in MySQL mode.
- An `EmbeddedRuntimeBroker`.
- A real Node worker process that runs `dist/cli.js`.
- A deterministic fake OpenAI server that sends the malformed JSON escapes.
O2 uses the real Java publication controller and SQL grant and data services. The
object store is an in-memory test adapter. No model API key and no OSS account
are needed.
This is not a MySQL, OSS, Linux, or Windows acceptance run. It also does not
cover a real `hosted-workspace-shell/2` process.
## Troubleshooting
- `POM ... does not exist`: use `packages/sdk-java/qwencode/pom.xml`. The
`runtime-broker` POM was consolidated and no longer exists.
- `No tests were executed!`: you passed `-Dtest=none`. The module sets
`failIfNoTests` to `true`, so surefire stops the build. Use the command above.
- `Pass -Dnode.executable with an absolute Node.js 22+ path`: add
`-Dnode.executable="$(command -v node)"`.
- `AssertionError` about `dist/cli.js` not being a regular file: run
`npm run bundle` and check `qwen.cli.entry`.
- `recoveryBlocked` in the failure dump: the refusal was not recognized on
reload. Check that the assistant message and both tool errors were persisted,
and that the batch has no native tool intent.
@@ -0,0 +1,339 @@
# Hosted Shell Unicode admission
[English](2026-10-08-hosted-shell-unicode-admission.md) | [简体中文](2026-10-08-hosted-shell-unicode-admission.zh-CN.md)
## 1. Status and evidence
This is the fix design for [#13010](https://github.com/QwenLM/qwen-code/issues/13010),
dated 2026-10-08 and implemented in the local workspace. Section 9 records the
local verification. It does not represent production release authorization, and
the issue is not closed.
Two rounds of design audit are complete: round 1 found two problems, both
revised; a fresh round 2 found no new substantive problem. This does not mean
runtime acceptance passed.
The issue and its full comment history were read through `gh`. The historical
evidence is the
[O2 round 9 report](https://github.com/QwenLM/qwen-code/pull/12894#issuecomment-5888143498)
on `3da25b7c`: an unpaired surrogate in Shell arguments blocked both capture
paths, and a second Session on the same Workspace still received
`workspace_busy` after eight minutes. That report and its log links should be
kept. The report did not run an independent main build, so it is not a
reproduction on current main.
The source review is based on local `040f06c7b141235153d69c3c795da9f8550eb900`.
Upstream main was `fe4d4e345c3f52fd6d8455646cdd3c782ca8b227` when queried; the
GitHub comparison shows that `hosted-workspace-tool-turn.ts`,
`hosted-workspace-broker.ts` and `managed-tool-protocol.ts` did not change
between the two. Hook lifecycle code and tool-turn tests did change; the work
was fast-forwarded to that upstream main before implementation and those changes
were read. Runtime reproduction and implementation evidence are in section 9.
Baseline facts before implementation:
- `HostedWorkspaceToolTurn.executeNative` builds both the local capture and the
O2 publication request in one place. It checks Shell argument shape, but not
UTF-16 well-formedness.
- When a batch has a `validationError`, the existing path already persists the
assistant message and paired tool errors without acquiring a lease or
executing. O2 now uses this path as well; the historical O2 behaviour of
throwing a plain validation error is obsolete.
- `PermissionRequest` currently runs before `prepareRequests`. `PreToolUse` runs
after the lease is acquired and can rewrite arguments, after which
`prepareRequests` runs again. `execute` also calls `completeHookResults`,
which includes `PostToolBatch`.
- `/runtimes:warm` already starts at construction. An earlier prompt Hook or an
earlier valid batch may already hold the Workspace lease.
- Java `BrokerValues` and the Broker/transport already reject incomplete text,
but a late rejection does not replace the early, durable, model-correctable
refusal this issue requires.
- `packages/core/src/tools/managed-shell.ts` from the comment no longer exists;
the suggested `hasUnpairedSurrogate` is a private function of the
external-context integration and cannot be imported across packages.
## 2. Problem, goals, and scope
JSON arguments can carry an escaped, unpaired UTF-16 high or low surrogate. After
parsing succeeds, a later encoder or the Java boundary may reject or replace the
value. If an execution owner was already acquired, the uncertain outcome keeps
the Workspace fenced. A malformed Shell call must therefore be rejected before
it enters that execution chain.
Goals:
1. On both Hosted Shell capture paths, reject an incomplete `command` and a
string `description` before acquisition and reservation of the refused batch.
2. Give the model a durable, ordered, correctable function error; a corrected
call later in the same turn can execute.
3. Preserve valid Unicode and the algorithm and bytes of the canonical digest.
4. Cover Hook rewrites so that incomplete Shell input cannot reach a later
command Hook or the Runtime.
5. Keep blocking on uncertain persistence and on side effects that were already
admitted.
Scope is Hosted `run_shell_command` `/1`, `/2`, local capture, and O2. When the
existing H3 branch is selected, the same field check must not disappear, but this
fix does not enable background Shell. Ordinary CLI Shell, Monitor, MCP
arguments, other file-tool text, general JSON validation, recovery of already
stuck Sessions, public Shell admission, and a new protocol version are out of
scope. Valid unrelated tools keep their current behaviour.
## 3. Chosen approach
Add one small pure Shell-text validator in
`packages/cli/src/serve/hosted-shell-input.ts`, consumed by the two real callers:
the tool turn and the Hosted Hook session. Do not export it from core and do not
build a general sanitizer. Keep the existing type, required-field, and profile
checks. Detect unpaired surrogates in string fields with
`/[\uD800-\uDFFF]/u`: the Unicode flag makes a valid surrogate pair one
supplementary-plane code point, so it does not match the surrogate range. The
`u` flag must not be omitted.
Use fixed error messages that name only the field and never contain its value:
- `Hosted Shell command contains an unpaired UTF-16 surrogate. Provide valid Unicode and retry.`
- `Hosted Shell description contains an unpaired UTF-16 surrogate. Provide valid Unicode and retry.`
Do not trim, normalize, delete, or replace code units. The printable six-character
`\ud800` sequence is valid text; a parsed unpaired `\uD800` code unit is not. An
omitted or empty description remains valid. A missing or non-string command and a
non-string description keep their existing errors; unknown keys and other shape
problems keep being rejected by the existing validation.
Reject instead of replace: changing command bytes can change side effects and
breaks the correspondence among input, approval, and execution. Keep
`managedToolDigest`, the payload JSON, Tool v3, Java validation, and the resource
schema unchanged. Do not migrate the integration's private function into core
just to reuse this one check.
## 4. Admission and Hook order
Use the following order and keep the existing profile/identity, truncated
argument, and inline-size refusal semantics:
1. Check the model's Shell text before `PermissionRequest` and before any Hook
that carries arguments. If any call fails, refuse the whole batch through the
persisted argument-refusal path, including valid siblings.
2. An initially Unicode-refused batch runs no command Hook and no approval
Action, and skips the post-tool callbacks in `completeHookResults`, including
`PostToolBatch`. The shared completion entry still rebuilds the refusal
evidence, and its callbacks do not receive the original incomplete command.
3. Inside `HostedHookSession.fireOccurrence`, after each successfully applied
sequential Hook result, validate the effective Shell input before admitting
the next Hook. Cover the `tool_input` merge performed for `updatedInput` and
`sequentialInput`, not only the final aggregate. Stop at the first invalid
effective input even if a later Hook could repair it. Keep the results of the
children that already ran and the original plan, aggregate the executed
prefix through the existing result resource, and return its invalid effective
input so the tool-turn validator produces the fixed, model-correctable
error. Do not add a general interrupt, do not end the whole model turn, and
do not admit a later child.
4. `settleSavedMarker` must rebuild the same ordered prefix and stop condition
from the saved child results, and evaluate the stop condition before looking
for a later child. Then, if the process crashes after an invalid child result
is saved but before the plan marker result is committed, the marker can be
finished from that prefix instead of waiting forever for a child that was
deliberately not created. When an earlier child result is uncertain, keep
blocking under the existing rule. Parallel Hooks keep receiving the original
input and are validated on the final aggregate before native execution; do not
cancel admitted sibling Hooks and do not change their ordering.
5. When `PermissionRequest` returns `updatedInput`, validate the effective Shell
text again before publishing the approval input or native acquisition. A
malformed rewrite refuses the whole native tool batch. The Hook itself may
already have acquired the Runtime; keep the existing owner and let normal
settlement handle it.
6. Check again in `prepareRequests`, including the call after `PreToolUse`. If a
Hook introduced incomplete text, ask for no approval, reserve no publication,
and start no native tool in that batch; refuse every call. Acquisition,
publisher registration, or the first approval may already have happened, so
do not claim the batch was completely inert, and do not force-clear the owner.
7. For a Unicode refusal caused by a Hook rewrite, do not forward the incomplete
effective arguments to a post-tool callback; skip that batch's post-tool
callbacks, keep the committed Hook decisions and results, and persist the
tool refusal normally. Do not fabricate an executed tool intent for a refused
call.
8. Only effective input that passes validation enters the existing
reservation/start path. The rule that a valid rewrite is re-approved stays
unchanged.
Reuse the existing batch-refusal persistence logic, extracting it only where
these entries need it. A refusal before the assistant message is committed writes
the assistant once and one ordered response per call; a refusal after the
assistant message is committed writes only the responses and must not append a
second assistant message.
Do not rely on a transient flag to skip post-tool callbacks. At the
`completeHookResults` entry shared by live `execute` and cold resume
`resumeHookResults`, rebuild the original assistant group and that batch's saved
`PermissionRequest`/`PreToolUse` results from the original occurrence/call
identity, and restore the effective input by the same rules used at admission,
including a prefix terminated by the Unicode check. Return the saved responses
and run no callback and re-commit no assistant/result only when invalid Shell
text, a complete paired refusal group, and the absence of native tool intents for
the batch together prove a Unicode refusal. Do not decide from the error string
alone and do not skip every batch that has no intent; an ordinary
argument/approval refusal keeps its current post-batch behaviour. Contradictory
native intent or an incomplete refusal record is a recovery error, not evidence
of a safe refusal. No new persisted flag or schema field is needed; the next
valid batch decides again from its own records.
## 5. Persistence, ownership, and compatibility
Error responses contain only the fixed reason, the original function-call
identity, and the existing response shape; valid siblings receive the existing
explicit not-executed error. Error messages and diagnostics do not echo the
invalid field value. Persist the original assistant function-call group through
the existing JSON-escaped record without rewriting the original command and
without claiming it executed. Prove that the group persists byte-for-byte and
reloads using the real Java Store, not only an in-memory TS mock.
For a fresh refusal batch without Hooks, require zero acquire, zero publisher
registration, zero publication reservation, zero prepare, zero start/execute,
and zero worker side effects. Runtime warming happens before the model output;
allow it and record it separately. The approach therefore does not promise
completely zero Broker HTTP traffic or stop independent provisioning.
A later invalid batch must not acquire, reserve, or start again, but it may
inherit the lease held by earlier valid work. Normal final settlement releases
the owner; the validator neither releases the owner nor rewrites an existing
execution to `not_started`. When the refusal commit succeeds it is a
model-correctable result, not recovery-blocked; when the assistant or result
commit fails and the outcome is uncertain, keep the existing recovery blocking.
A Shell that did not execute cannot prove journal consistency.
An incomplete command is rejected before its approval, and no approval is
created for that command. No configuration, migration, public DTO, protocol
version, new error-code enum, or Shell capability enablement is needed.
Subsequent source implementation must follow the maintainer review rules for
core and cross-package changes; this design is not PR approval and does not
authorize rollout.
## 6. Implementation and verification plan
Before implementation, a read-only `test-engineer` reproduces the issue with an
independent real Java Store/Broker/Harness/worker environment on a build of main
at that time. Try the installed global `qwen` first; when a private profile is
unreachable, record it as unavailable and do not count it as a baseline pass.
Use a deterministic model fixture to cover local capture and O2 separately;
input-admission tests do not need a real OSS bucket. Save the round 9 logs and
the new exact-head before/after results, and put the E2E plan and report in
`.qwen/e2e-tests/`.
Main implementation/test files:
- `packages/cli/src/serve/hosted-workspace-tool-turn.ts`.
- `packages/cli/src/serve/hosted-shell-input.ts` and its collocated tests.
- `packages/cli/src/serve/hosted-hook-session.ts` and tests, changing only the
Shell effective-input boundary and saved-marker reconstruction.
- `packages/cli/src/serve/hosted-workspace-tool-turn.test.ts`, plus Hook tests as
needed to prove callback order and suppression.
- A native Hosted integration fixture that covers real Java persistence, file
side effects, and a second Session on the same Workspace. After rebasing,
choose an existing driver that really drives both capture paths; do not add a
second production admission entry just to test this fix.
| Test | Evidence that must be provided |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unicode negatives | Both fields, both paths: unpaired high/low surrogate, high surrogate followed by ASCII, reversed pair, high-high, low-low, valid pair followed by an unpaired surrogate. Cover any position, including after a valid emoji. |
| Unicode compatibility | ASCII, Chinese, supplementary-plane emoji, valid surrogate pair, combining characters, ESC, U+0001, DEL, U+2028/U+2029, and the literal `\ud800` in command/description keep byte-identical bytes; command semantics stay unchanged. |
| Batch refusal | Invalid call first/last, mixed with valid Shell and Write; all errors persisted in order, every sibling explicitly not executed, no tool intent and no publisher reservation. |
| Correction | A call corrected in the same turn executes exactly once; the result reaches the next model request; final settlement succeeds and reload and later turns work. |
| Ownership | A new refusal holds no Workspace lease and a valid call in a second Session completes; a later refusal with an existing lease keeps the owner until normal settlement. |
| Persistence failure | Fail the assistant/result write separately and lose the response of a committed operation; no execution, no false success, existing recovery blocking kept, no duplicated history. |
| Hooks | The original invalid input reaches no permission/pre/post/batch Hook; an invalid `PermissionRequest` or `PreToolUse` rewrite cannot native start or reach a later callback that carries invalid arguments. A real two-Hook ordered plan covers `updatedInput` and the `tool_input` merge: H1 introduces invalid text, H2 is never admitted even if H2 could repair it. A crash after the H1 result and before the marker result resumes the marker without the H2 child; a valid rewrite is still re-approved and reaches H2. |
| Hook cold completion | Reload the original invalid and Hook-rewritten refusal groups and Hook records into a new tool turn, call the real `resumeHookResults` caller, and prove no post-batch callback, no duplicated history, and no native operation. Ordinary refusal replay and the next valid batch keep their current behaviour. |
| Store and model transport | The real Java Store persists and reloads the original escaped assistant group and paired errors; the deterministic model receives them and corrects the call. Acceptance fails if encoding changes or transport rejects the group. |
| Limits and identity | Existing inline-size, unsupported-tool, duplicate-call, and incomplete-argument guards still apply; correctness is not promised when the existing limits are exceeded. |
| Regression and mutation | Existing local/O2 refusal, approval, capture, and Hook tests pass; removing each admission check and each callback-suppression check must fail the corresponding negative case; removing the `u` flag must fail the valid-emoji witness. |
Run these in the respective package directories:
```bash
# Repository root
npm run build && npm run typecheck
# packages/cli
npx vitest run src/serve/hosted-workspace-tool-turn.test.ts
npx vitest run src/serve/hosted-shell-input.test.ts src/serve/hosted-hook-session.test.ts
# Add the actually changed Hook/native driver suites, then the real-stack gate.
```
Section 9 records the checks that were completed. A catalog-only or worker-only
result cannot prove pre-acquisition admission; unrelated full test suites are not
required.
## 7. Acceptance and risk
The fix is complete only when every verification above passes on the same final
source version, both capture paths refuse invalid input before the first native
side effect, valid text keeps its bytes, the corrected call and the second
Session work, and a failed refusal persistence still blocks safely. Public Shell
enablement and recovery of historically stuck Sessions remain separate work.
The main risks are a wrong validator position relative to Hooks, post-batch
completion forwarding invalid input again, duplicated assistant commits after a
rewrite, and relying only on TS persistence evidence. Inline limits and side
effects already in flight can legitimately prevent a correctable continuation;
this must be recorded explicitly.
Writing this design needs no additional product decision. Before source
delivery there must be a current-main reproduction and Java byte-for-byte
round-trip evidence; any failure must change the implementation/design, not
silently replace the invalid command or claim the issue is fixed.
## 8. Independent audit record
The user asked for a fresh Sub Agent audit and reverse audit each round, updated
between rounds, for at most seven rounds. Each round checks both whether the
approach can close the issue and whether the new refusal/ordering breaks valid
behaviour or makes a verification claim unprovable.
| Round | Problem and resolution | Result |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| 1 | The new auditor audit_round_1 found sequential Hook propagation before the outer check (P1) and lost callback suppression on cold recovery (P2). Added the shared Hosted validator, sequential-prefix terminal reconstruction, persisted completion decision, and the corresponding negative/compatibility witnesses. | Both accepted and revised; round 2 independently re-checked them. |
| 2 | The new auditor audit_round_2 independently checked the issue/source, auditing forward behaviour and reverse compatibility/recovery negative cases. No new substantive problem was found; the round 1 revisions covered the risks. The report keeps the implementation constraints for the event return shape and nested `PermissionRequest` without widening valid Hook semantics. | Met the user's "no new problem" stop condition and ended the audit loop (max 7, actual 2). |
Local audit reports: `.qwen/pr-reviews/issue-13010-design-round-1.md` and
`.qwen/pr-reviews/issue-13010-design-round-2.md` (git-ignored working artifacts).
## 9. Implementation and local verification
The implementation uses the shared pure validator before the permission Hook and
in request preparation, terminates an incomplete sequential Hook prefix on both
the live and saved-marker paths, and rebuilds the persisted Unicode refusal
evidence before callbacks. It shares the existing ordered refusal path; it adds
no persisted schema, core validator, digest algorithm, or production Java
protocol.
Before the fix, a build of main admitted a U+D800 command to
acquire/prepare/start on the local path and still had an active prompt after 30
seconds. Eight controlled source fallbacks independently observed acquisition.
The early O2 fixture lacked a publication endpoint, so its recovery block alone
cannot prove Unicode caused the lockup. These observations did not reproduce the
historical eight-minute incident.
The final real-process Maven verify gate passed both the local and O2 parameters.
Each path covers eight incomplete command/description batches and one more batch
that is refused and then corrected in the same turn. The model checks the
original arguments and ordered paired errors before and after detach/load. The
Broker and Store proxy observe no native admission or publication request for a
fresh invalid batch; the primary Session's SQL execution ledger has exactly one
execution, the corrected one. A second Session on the same Workspace completes a
valid call on both paths.
This stack uses temporary H2, an Embedded Broker, and a real Node worker
process. O2 uses the real Java SQL grant/data services and publication
controller, with an in-memory test adapter for the object store; this is not
MySQL, OSS, or Linux isolation acceptance. Controlled CLI regression separately
covers `/1` and `/2`, both capture paths, invalid first/last calls mixed with
valid Shell/Write siblings, persistence failure and lost replies after commit,
retention of an existing owner, real Hook rewrites, and cold completion. Seven
mutations (original admission, permission rewrite preparation, `PreToolUse`
rewrite, live/cold sequential stop, callback suppression, Unicode flag) each fail
their witness. Source was restored verbatim before the final regression.
Exact commands, environment, success conditions, results, and troubleshooting are
in the [local E2E guide](../../../HOSTED_SHELL_UNICODE_E2E.md). The local
implementation code review has no Critical and two E2E Suggestions, both accepted
and fixed.
@@ -0,0 +1,265 @@
# Hosted Shell Unicode 准入
[English](2026-10-08-hosted-shell-unicode-admission.md) | [简体中文](2026-10-08-hosted-shell-unicode-admission.zh-CN.md)
## 1. 状态与证据
这是 [#13010](https://github.com/QwenLM/qwen-code/issues/13010) 的修复方案,
日期为 2026-10-08,已在本地工作区实施。第 9 节记录本机验证,
不代表生产发布授权或 issue 已关闭。
设计审计已完成两轮:第 1 轮发现两项问题并已修订;全新第 2 轮没有发现新的实质
问题。这不代表运行时验收通过。
已通过 `gh` 阅读 issue 及完整评论。历史证据是 `3da25b7c` 上的
[O2 第 9 轮报告](https://github.com/QwenLM/qwen-code/pull/12894#issuecomment-5888143498):
Shell 参数中的孤立代理项使两条捕获路径都被阻塞,同 Workspace 的第二个 Session
在八分钟后仍收到 `workspace_busy`。应保留该报告及其日志链接。报告没有运行独立
main 构建,因此不算当前 main 的复现。
源码检查基于本地 `040f06c7b141235153d69c3c795da9f8550eb900`。查询时上游 main
为 `fe4d4e345c3f52fd6d8455646cdd3c782ca8b227`;GitHub 对比显示两者之间
`hosted-workspace-tool-turn.ts`、`hosted-workspace-broker.ts` 和
`managed-tool-protocol.ts` 未变。Hook 生命周期代码及 tool-turn 测试有所变化,
实施前已 fast-forward 到该上游 main,并阅读这些变化。
运行时复现与实施证据见第 9 节。
实施前的基线事实:
- `HostedWorkspaceToolTurn.executeNative` 同时构造 local capture 与 O2
publication 请求。它检查 Shell 参数形状,但不检查 UTF-16 是否完整。
- 批次存在 `validationError` 时,已有路径会持久化 assistant 和配对的工具错误,
无须获取租约或执行。O2 现在也走这条路径;历史上 O2 直接抛出普通校验错误的
行为已经过时。
- `PermissionRequest` 当前在 `prepareRequests` 前运行。`PreToolUse` 在获取租约
后运行,可以改写参数,随后再次调用 `prepareRequests`。`execute` 还会调用
`completeHookResults`,其中包含 `PostToolBatch`。
- 构造时已经启动 `/runtimes:warm`。更早的 prompt Hook 或此前的合法批次可能已
持有 Workspace 租约。
- Java 的 `BrokerValues` 以及 Broker/transport 已拒绝不完整文本,但晚期拒绝
不能代替本 issue 要求的、提前且持久的模型可纠正拒绝。
- 评论中的 `packages/core/src/tools/managed-shell.ts` 已不存在;建议复用的
`hasUnpairedSurrogate` 是 external-context integration 的私有函数,不能直接
跨包导入。
## 2. 问题、目标与范围
JSON 参数可以携带转义的孤立 UTF-16 高代理项或低代理项。解析成功后,后续编码器
或 Java 边界可能拒绝或替换该值。若此前已经获取执行 owner,不确定结果就会保留
Workspace fence。因此应在进入该执行链路前拒绝错误的 Shell 调用。
目标:
1. 在两条 Hosted Shell 捕获路径中,拒绝不完整的 `command` 和字符串
`description`,先于被拒批次的执行获取与预留。
2. 给模型持久、有序、可纠正的 function error;同一 turn 后续纠正的调用可以执行。
3. 保留合法 Unicode,以及 canonical digest 的算法和字节。
4. 覆盖 Hook 改写,避免将不完整的 Shell 输入转发给后续 command Hook 或 Runtime。
5. 对不确定的持久化和此前已准入的副作用继续保持阻塞。
范围为 Hosted `run_shell_command` 的 `/1`、`/2`、local capture 和 O2。
现有 H3 分支被选择时,同样的字段检查不能消失,但本修复不启用后台 Shell。
普通 CLI Shell、Monitor、MCP 参数、其他文件工具文本、通用 JSON 校验、已有阻塞
Session 的恢复、公开 Shell 准入和新协议版本不在范围内。合法的无关工具保持原行为。
## 3. 选定方案
在 `packages/cli/src/serve/hosted-shell-input.ts` 添加一个小型纯 Shell 文本校验
函数,由 tool turn 和 Hosted Hook session 两个真实消费者使用。不从 core 导出,
不建立通用 sanitizer。保留既有类型、必填字段和 profile 校验。对字符串字段使用
`/[\uD800-\uDFFF]/u` 检测孤立代理项:Unicode 标志使合法代理对成为一个补充
平面码点,不匹配代理项范围。不能遗漏 `u`。
使用固定错误消息,只指出字段,不包含其值:
- `Hosted Shell command contains an unpaired UTF-16 surrogate. Provide valid Unicode and retry.`
- `Hosted Shell description contains an unpaired UTF-16 surrogate. Provide valid Unicode and retry.`
不 trim、不规范化、不删除或替换码元。可打印的六字符 `\ud800` 序列是合法文本;
解析得到的孤立 `\uD800` 码元不是。省略或空的 description 仍合法。缺失或非字符串
command、非字符串 description 继续由已有错误处理;未知 key 和其他形状问题继续
由已有校验拒绝。
选择拒绝而不是替换:修改命令字节可能改变副作用,也会破坏输入、审批、执行之间
的对应关系。保持 `managedToolDigest`、payload JSON、Tool v3、Java 校验和资源
schema 不变。不为复用这一个检查而将 integration 的私有函数迁入 core。
## 4. 准入与 Hook 顺序
采用以下顺序,并保留既有 profile/identity、截断参数和 inline-size 拒绝语义:
1. 在 `PermissionRequest` 和任何携带参数的 Hook 前检查模型的 Shell 文本。
任一调用失败时,通过持久化参数拒绝路径拒绝整个批次,包含合法 sibling。
2. 初始 Unicode 被拒批次不运行 command Hook、审批 Action,跳过
`completeHookResults` 中的 post-tool callback,包含 `PostToolBatch`。
共用 completion 入口仍重建拒绝证据,其 callback 不接收原始不完整命令。
3. 在 `HostedHookSession.fireOccurrence` 内,每次应用成功的顺序 Hook 结果后,
先校验生效的 Shell 输入,再准入下一个 Hook。覆盖 `updatedInput` 和
`sequentialInput` 执行的 `tool_input` 合并,不能仅检查最终 aggregate。
首次出现非法生效输入即停止,即使后续 Hook 可以修正也不继续。保留已执行 child
的结果及原 plan,通过既有 result resource 聚合已执行前缀;返回其非法生效输入,
让 tool-turn 校验器生成固定、模型可纠正的错误。不引入通用 interrupt,不结束
整个模型 turn,不准入后继 child。
4. `settleSavedMarker` 必须从保存的 child 结果重建相同顺序前缀及停止条件,先判断
停止条件,再查找后继 child。这样在非法 child 结果已保存、plan marker 结果未
提交时崩溃,可以从该前缀结束 marker,不会永远等待故意没有创建的 child。
更早的 child 结果不确定时仍按既有规则阻塞。并行 Hook 继续接收原输入,在 native
执行前校验最终 aggregate,不取消已准入的 sibling Hook,也不改变其排序。
5. `PermissionRequest` 返回 `updatedInput` 时,在发布审批输入或 native acquisition
前再次校验生效的 Shell 文本。错误的改写拒绝整个 native 工具批次。该 Hook 本身
可能已经获取 Runtime,保留原 owner,交给正常 settlement 处理。
6. 在 `prepareRequests` 中再次检查,包含 `PreToolUse` 后的调用。如果 Hook 引入
不完整文本,不再询问审批、不预留 publication、不启动该批次的任何 native 工具,
拒绝全部调用。acquisition、publisher registration 或第一次审批可能已经发生,
不能宣称此前完全无活动,也不能强行清除 owner。
7. 对 Hook 改写导致的 Unicode 拒绝,不向 post-tool callback 转发不完整的生效
参数;跳过该批次的 post-tool callback,保留已提交的 Hook 决定与结果,正常
持久化工具拒绝。不能为被拒调用伪造已执行的 tool intent。
8. 只有校验通过的生效输入进入既有 reservation/start 路径。合法输入改写后重新
审批的规则保持不变。
复用已有批次拒绝的持久化逻辑,仅在这些入口需要时提取。assistant 提交前的拒绝
写一次 assistant,并为每个调用写有序响应;assistant 提交后的拒绝只写响应,不能
再追加一份 assistant。
不能依赖临时 flag 跳过 post-tool callback。在实时 `execute` 和冷恢复
`resumeHookResults` 共用的 `completeHookResults` 入口,通过原始 occurrence/call
identity 重建原 assistant group 和该批次保存的 `PermissionRequest`/`PreToolUse`
结果,按准入的相同规则恢复生效输入,包含被 Unicode 检查终止的前缀。只有非法
Shell 文本、完整配对的拒绝 group,以及该批次没有 native tool intent 共同证明
Unicode 拒绝时,才返回保存的响应,不运行 callback,不重复提交 assistant/result。
不能仅用错误字符串判断,也不能跳过所有没有 intent 的批次;普通参数/审批拒绝
保持原 post-batch 行为。矛盾的 native intent 或不完整拒绝记录属于恢复错误,不能
当作安全拒绝的证据。不需要新增持久 flag 或 schema 字段,下一合法批次从自己的
记录重新判断。
## 5. 持久化、所有权与兼容性
错误响应仅包含固定原因、原 function-call identity 和既有响应形状;合法 sibling
收到既有的明确未执行错误。错误消息和诊断不回显非法字段值。通过既有 JSON 转义
持久记录保存原 assistant function-call group,不改写原命令,不声称已经执行。
必须使用真实 Java Store 证明该 group 原样持久化及 reload,不能仅使用内存 TS mock。
对于全新、无 Hook 的被拒批次,要求 acquire、publisher registration、publication
reservation、prepare、start/execute 和 worker 副作用均为零。Runtime warming 在
模型输出前已经发生,允许它并单独记录。因此方案不承诺完全没有 Broker HTTP 流量,
也不停止独立 provisioning。
后续非法批次不能再次 acquire/reserve/start,但可能继承先前合法工作持有的租约。
正常最终 settlement 释放 owner;校验器不释放 owner,也不将既有 execution 改为
`not_started`。拒绝提交成功时是模型可纠正结果,而非 recovery-blocked;assistant
或 result 提交失败、结果不确定时保留既有 recovery blocking。Shell 未执行不能
证明 journal 一致。
不完整命令先于它的审批被拒绝,不为该命令创建审批。不需要配置、迁移、公开 DTO、
协议版本、新错误码枚举或 Shell capability 启用。后续源码实施须遵循 core/跨包
改动的维护者审核规则;本设计不代表 PR 审批,也不授权 rollout。
## 6. 实施与验证计划
实施前由只读 `test-engineer` 使用独立的真实 Java Store/Broker/Harness/worker
环境,在当时 main 构建上复现。先尝试已安装的全局 `qwen`,private profile 不可达
时记录 unavailable,不能记为基线通过。用确定性模型 fixture 分别覆盖 local capture
与 O2;输入准入测试不要求真实 OSS bucket。保存第 9 轮日志和新 exact-head
before/after 结果,将 E2E 计划与报告放入 `.qwen/e2e-tests/`。
主要实施/测试文件:
- `packages/cli/src/serve/hosted-workspace-tool-turn.ts`。
- `packages/cli/src/serve/hosted-shell-input.ts` 及 collocated tests。
- `packages/cli/src/serve/hosted-hook-session.ts` 及测试,仅修改 Shell 生效输入边界
和 saved-marker 重建。
- `packages/cli/src/serve/hosted-workspace-tool-turn.test.ts`,并按需要增加 Hook 测试,
证明 callback 顺序与抑制。
- 覆盖真实 Java 持久化、文件副作用和同 Workspace 第二个 Session 的 native Hosted
集成 fixture。rebase 后选择确实能驱动两条捕获路径的既有 driver,不为测试该修复
增设第二个生产准入入口。
| 测试 | 必须提供的证据 |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Unicode 反例 | 两个字段、两条路径:孤立高/低代理项、高代理项后 ASCII、逆序对、高高、低低、合法代理对后孤立代理项。覆盖任意位置,包含合法 emoji 后。 |
| Unicode 兼容性 | ASCII、中文、补充平面 emoji、合法代理对、组合字符、ESC、U+0001、DEL、U+2028/U+2029 和字面 `\ud800` 的 command/description 字节完全保留;命令语义保持。 |
| 批次拒绝 | 非法调用位于首/尾,与合法 Shell、Write 混合;所有错误有序持久化,每个 sibling 明确未执行,无 tool intent 或 publisher reservation。 |
| 纠正 | 同 turn 纠正的调用恰好执行一次;结果到达下一次模型请求;最终 settlement 成功,reload 和后续 turn 正常。 |
| 所有权 | 新拒绝不持有 Workspace 租约,第二个 Session 的合法调用完成;已有租约时的后续拒绝保留 owner,直到正常 settlement。 |
| 持久化失败 | 分别失败 assistant/result 写入,并丢失已提交操作的响应;无执行、无假成功、保留既有恢复阻塞、不重复历史。 |
| Hooks | 原始非法输入不进入 permission/pre/post/batch Hook;PermissionRequest 与 PreToolUse 的非法改写不能 native start 或进入后续携带非法参数的 callback。真实双 Hook 顺序 plan 覆盖 updatedInput 与 tool_input 合并:H1 引入非法文本,H2 从未准入,即使 H2 能修复也不运行。H1 result 后、marker result 前崩溃,恢复无需 H2 child 即可结束 marker;合法改写仍重新审批并到达 H2。 |
| Hook 冷恢复完成 | 将原始非法和 Hook 改写拒绝 group、Hook 记录 reload 到新 tool-turn,走真实 resumeHookResults 调用方,证明无 post-batch callback、无重复历史、无 native 操作。普通拒绝 replay 和下一合法批次行为不变。 |
| 存储和模型传输 | 真实 Java Store 持久化/reload 原始转义 assistant group 和配对错误;确定性模型收到它们并纠正调用。编码改变或传输拒绝该 group 时验收失败。 |
| 限额与 identity | 既有 inline-size、unsupported-tool、duplicate-call 和 incomplete-argument 守卫仍适用;超出现有限额时不承诺可纠正。 |
| 回归与变异 | 既有 local/O2 拒绝、审批、捕获和 Hook 测试通过;分别移除每个准入检查与 callback 抑制检查,对应反例必须失败;去掉 `u` 时合法 emoji 见证必须失败。 |
在相应包目录运行:
```bash
# Repository root
npm run build && npm run typecheck
# packages/cli
npx vitest run src/serve/hosted-workspace-tool-turn.test.ts
npx vitest run src/serve/hosted-shell-input.test.ts src/serve/hosted-hook-session.test.ts
# Add the actually changed Hook/native driver suites, then the real-stack gate.
```
第 9 节记录已完成的检查。catalog-only 或 worker-only
结果不能证明 pre-acquisition admission;不要求运行无关完整测试集。
## 7. 验收与风险
只有上述全部验证在同一最终源码版本通过,两条捕获路径先于首次 native 副作用拒绝
非法输入,合法文本字节不变,纠正后的调用和第二个 Session 正常,拒绝持久化失败仍
安全阻塞,才算修复完成。公开 Shell 启用和历史阻塞 Session 恢复仍属独立工作。
主要风险是校验相对 Hook 的位置错误、post-batch completion 再次转发非法输入、
改写后重复提交 assistant,以及仅依赖 TS 持久化证据。inline 限额和此前仍在进行的
副作用可能合理地阻止可纠正 continuation,必须明确记录。
编写本设计不需要额外产品决策。源码交付前必须有当前 main 复现和 Java 历史原样
往返证据;任一失败都应修改实施/设计,不能静默替换非法命令或宣称 issue 已修复。
## 8. 独立审计记录
用户要求每轮由全新 Sub Agent 进行审计和反向审计,轮间更新,最多七轮。
每轮同时检查方案能否关闭 issue,以及新增拒绝/顺序是否破坏合法行为,或使验证
声明无法证明。
| 轮次 | 问题与处理 | 结果 |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| 1 | 新审计者 audit_round_1 发现外层检查前的顺序 Hook 传播(P1)和冷恢复丢失 callback 抑制(P2)。已补 Hosted 共用校验器、顺序前缀终态重建、持久 completion 判定,以及对应反例/兼容性见证。 | 两项均接受并修订;第 2 轮已独立复核。 |
| 2 | 新审计者 audit_round_2 独立核对 issue/源码,审计正向行为与反向兼容性/恢复反例。未发现新实质问题,第 1 轮修正已覆盖。报告保留事件返回形状和嵌套 PermissionRequest 的实施约束,不扩大合法 Hook 语义。 | 满足用户“无新问题”停止条件,结束审计循环(最多 7 轮,实际 2 轮)。 |
本地审计报告:`.qwen/pr-reviews/issue-13010-design-round-1.md` 和
`.qwen/pr-reviews/issue-13010-design-round-2.md`(git-ignored 工作产物)。
## 9. 实施与本机验证
实施在 permission Hook 前和 request preparation 中使用共享纯校验器,
在 live 与 saved-marker 路径终止不完整的顺序 Hook 前缀,并在 callback 前
重建持久化 Unicode 拒绝证据。共享既有有序拒绝路径;未增加持久化 schema、
core 校验器、digest 算法或生产 Java 协议。
修复前 main 的打包产物在 local 路径将 U+D800 command 准入
acquire/prepare/start,30 秒后仍有活跃 prompt。8 项受控源码 fallback
独立观察到 acquisition。早期 O2 fixture 缺少 publication endpoint,
其 recovery block 不能单独证明 Unicode 导致锁死。这些观察未复现历史
八分钟事故。
最终真实进程 Maven verify gate 的 local 与 O2 两个参数均通过。每条路径
包括 8 个不完整 command/description 批次,以及同 turn 拒绝后纠正的另一个
批次。模型在 detach/load 前后检查原始参数和有序配对错误。Broker 与 Store
proxy 观察新鲜非法批次没有 native admission/publication 请求;主 Session
的 SQL 执行账本仅有一次纠正后的执行。同 Workspace 的第二个 Session
在两条路径均完成合法调用。
此栈使用临时 H2、Embedded Broker 和真实 Node worker 进程。O2 使用真实
Java SQL grant/data 服务和 publication controller,object store 为内存
测试适配器;这不是 MySQL、OSS 或 Linux 隔离验收。受控 CLI 回归另行覆盖
/1 与 /2、两条 capture 路径、非法首尾与合法 Shell/Write siblings、持久化
失败和提交后丢回复、已有 owner 保留、真实 Hook 改写和 cold completion。
7 项 mutation(原始准入、permission 改写 preparation、PreToolUse 改写、
live/cold 顺序停止、callback 抑制、Unicode 标志)分别使对应 witness 失败。
最终回归前,源码已逐字恢复。
精确命令、环境、成功条件、结果与排查见
[本地 E2E 文档](../../../HOSTED_SHELL_UNICODE_E2E.md)。本地实施代码审查
没有 Critical,有两项 E2E Suggestion,均接受并修复。
@@ -117,7 +117,7 @@ describe('Hosted proxy header relay', () => {
}
});
it('keeps every store relay driver calling the shared helper', () => {
it('keeps the Hosted relays calling the shared helper', () => {
// The sdk-java workflow step runs this file after `cd integration-tests`
// while the repo-root lanes use `--root ./integration-tests`, so resolve
// the drivers relative to this file, never process.cwd(). Read source
@@ -140,6 +140,7 @@ describe('Hosted proxy header relay', () => {
'hosted-process-crash-driver.ts',
'hosted-recovery-blocked-wedge-driver.ts',
'hosted-shell-output-driver.ts',
'hosted-shell-unicode-driver.ts',
'hosted-store-failure-driver.ts',
'hosted-workflow-fault-driver.ts',
'hosted-workspace-tool-turn-driver.ts',
@@ -42,7 +42,7 @@ export function relayedHeaders(headers: Headers): Record<string, string> {
}
// The wholesale upstream relays — hosted-{latency,process-crash,
// shell-output,store-failure,workspace-tool-turn,channel-fault}-driver.ts —
// shell-output,shell-unicode,store-failure,workspace-tool-turn,channel-fault}-driver.ts —
// pass through this one site, pinned by the caller list in
// hosted-relay-headers.test.ts. The drivers' other upstream replies write
// fixed header literals and must stay that way: a driver-local copy of the
@@ -0,0 +1,345 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { access, readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import {
fakeToolCall,
startFakeOpenAIServer,
type FakeOpenAIToolCall,
} from '../fake-openai-server.js';
import { HostedHarnessProcess, waitUntil } from './hosted-harness-process.js';
import { relayUpstream } from './hosted-relay-headers.js';
const config = JSON.parse(await readFile(process.argv[2], 'utf8')) as {
tenantId: string;
storeUrl: string;
brokerUrl: string;
secondarySessionId: string;
sessions: Array<{
sessionId: string;
workspaceId: string;
directory: string;
toolProfile: string;
fault: string;
}>;
};
const controls: string[] = [];
const publications: string[] = [];
const proxy = createServer(async (req, res) => {
try {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const payload = Buffer.concat(chunks);
const brokerRequest = req.url!.startsWith('/internal/runtime-broker/');
if (brokerRequest) controls.push(req.url!);
if (req.url!.startsWith('/internal/managed-tool-publications/'))
publications.push(req.url!);
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries(req.headers)) {
if (
value !== undefined &&
!['host', 'connection', 'content-length', 'transfer-encoding'].includes(
name,
)
) {
headers[name] = Array.isArray(value) ? value.join(',') : value;
}
}
const response = await fetch(
new URL(req.url!, brokerRequest ? config.brokerUrl : config.storeUrl),
{
method: req.method,
headers,
...(payload.length ? { body: payload } : {}),
signal: AbortSignal.timeout(30_000),
},
);
relayUpstream(res, response, Buffer.from(await response.arrayBuffer()));
} catch (error) {
res.writeHead(503);
res.end(String(error));
}
});
await new Promise<void>((resolve) => proxy.listen(0, '127.0.0.1', resolve));
const address = proxy.address();
assert(address && typeof address !== 'string');
const validText = '中文😀é\\ud800';
let scenario: { field: string; value: string } | undefined;
let corrected = false;
let repairInTurn = false;
type Message = {
role: string;
content?: unknown;
tool_call_id?: string;
tool_calls?: FakeOpenAIToolCall[];
};
const refused = new Map<
string,
{ call: FakeOpenAIToolCall; result?: Message }
>();
let correctedCallId = '';
const correction = () => {
const file =
sessionId === config.secondarySessionId
? 'unicode-secondary-ok.txt'
: 'unicode-ok.txt';
const call = fakeToolCall('run_shell_command', {
command: "printf '%s' '中文😀é\\ud800' > " + file,
description: validText,
});
correctedCallId = call.id;
return { toolCalls: [call] };
};
const model = await startFakeOpenAIServer(({ body }) => {
const messages = (body.messages ?? []) as Message[];
const start = messages.findLastIndex((entry) => entry.role === 'user');
const results = messages
.slice(start + 1)
.filter((entry) => entry.role === 'tool');
if (results.length) {
if (scenario) {
assert.equal(
results.length,
repairInTurn && results.length === 3 ? 3 : 2,
);
for (const result of results.slice(0, 2)) {
const snapshot = refused.get(result.tool_call_id!);
assert(snapshot, 'Unexpected tool response');
const call = messages
.flatMap((entry) => entry.tool_calls ?? [])
.find((entry) => entry.id === snapshot.call.id);
assert.deepEqual(call, snapshot.call);
snapshot.result = result;
}
assert.equal(results[0].tool_call_id, [...refused.keys()].at(-2));
assert.equal(results[1].tool_call_id, [...refused.keys()].at(-1));
assert.match(
JSON.stringify(results[0].content),
/unpaired UTF-16 surrogate/,
);
assert.match(JSON.stringify(results[1].content), /not executed/);
if (repairInTurn && results.length === 2) return correction();
}
if (corrected || repairInTurn) {
assert.equal(results.at(-1)!.tool_call_id, correctedCallId);
assert.doesNotMatch(JSON.stringify(results.at(-1)!.content), /"error"/);
}
return { content: 'UNICODE_DONE' };
}
if (scenario) {
const calls = [
fakeToolCall('run_shell_command', {
command: 'printf invalid > invalid.txt',
[scenario.field]: scenario.value,
}),
fakeToolCall('write_file', {
file_path: 'sibling.txt',
content: 'must not execute',
}),
];
for (const call of calls) refused.set(call.id, { call });
return { toolCalls: calls };
}
if (corrected) return correction();
for (const snapshot of refused.values()) {
assert(snapshot.result, 'Missing paired refusal');
const call = messages
.flatMap((entry) => entry.tool_calls ?? [])
.find((entry) => entry.id === snapshot.call.id);
assert.deepEqual(
call,
snapshot.call,
'Original arguments changed after reload',
);
const result = messages.find(
(entry) => entry.tool_call_id === snapshot.call.id,
);
assert.deepEqual(
result,
snapshot.result,
'Paired refusal changed after reload',
);
}
return { content: 'HISTORY_RELOADED' };
});
const cli = new HostedHarnessProcess();
let sessionId = '';
let clientId = '';
async function json(route: string, body?: unknown, expected = 200) {
const response = await cli.request(route, {
method: body === undefined ? 'GET' : 'POST',
headers: { ...cli.headers(clientId), 'Content-Type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await response.text();
assert.equal(response.status, expected, text);
return text ? JSON.parse(text) : undefined;
}
async function prompt() {
const promptId = randomUUID();
const prompt = [{ type: 'text', text: 'UNICODE_' + promptId }];
await json(
'/session/' + sessionId + '/prompt',
{
promptId,
prompt,
payloadDigest:
'sha256:' +
createHash('sha256').update(JSON.stringify(prompt)).digest('hex'),
},
202,
);
let status;
try {
await waitUntil(async () => {
status = await json('/session/' + sessionId + '/status');
assert.equal(
status.recoveryBlocked,
false,
JSON.stringify({ status, controls, output: cli.output }),
);
return !status.hasActivePrompt;
}, 30_000);
} catch (error) {
console.error(
JSON.stringify({
scenario,
status,
controls,
modelRequests: model.requests.length,
output: cli.output,
}),
);
throw error;
}
const transcript = await json(
'/session/' + sessionId + '/transcript?cursor=0&limit=256',
);
assert(
transcript.events.some(
(event: { type: string; promptId?: string }) =>
event.type === 'turn_complete' && event.promptId === promptId,
),
JSON.stringify(transcript) + cli.output,
);
}
try {
const proxyUrl = 'http://127.0.0.1:' + address.port;
await cli.start(model.baseUrl, {
extraArgs: [
'--managed-runtime-broker-url',
proxyUrl,
'--managed-runtime-broker-token',
'hosted-tools-broker-token',
],
});
for (const session of config.sessions) {
refused.clear();
sessionId = session.sessionId;
clientId = '';
const connection = {
baseUrl: proxyUrl,
tenantId: config.tenantId,
workspaceId: session.workspaceId,
writerId: cli.bootId,
leaseDurationMs: 60_000,
};
const profile = {
managedSessionStore: connection,
toolProfile: session.toolProfile,
...(session.fault === 'o2' ? { captureBytes: 1024 * 1024 } : {}),
};
clientId = (
await json('/session', { sessionId, sessionScope: 'thread', ...profile })
).clientId;
for (const field of ['command', 'description']) {
for (const value of ['\ud800', '\udc00', '😀\ud800', '\udc00\ud800']) {
scenario = { field, value };
const before = controls.length;
const publicationBefore = publications.length;
await prompt();
const newControls = controls.slice(before);
assert(
newControls.every((route) => route.endsWith('/runtimes:warm')),
JSON.stringify(newControls),
);
assert.equal(
publications.length,
publicationBefore,
JSON.stringify(publications),
);
await assert.rejects(
access(path.join(session.directory, 'invalid.txt')),
);
await assert.rejects(
access(path.join(session.directory, 'sibling.txt')),
);
}
}
scenario = undefined;
await json('/session/' + sessionId + '/detach', {}, 204);
clientId = (await json('/session/' + sessionId + '/load', profile))
.clientId;
await prompt();
scenario = { field: 'command', value: '\ud800' };
repairInTurn = true;
await prompt();
scenario = undefined;
repairInTurn = false;
assert.equal(
await readFile(path.join(session.directory, 'unicode-ok.txt'), 'utf8'),
validText,
);
await json('/session/' + sessionId + '/detach', {}, 204);
clientId = (await json('/session/' + sessionId + '/load', profile))
.clientId;
await prompt();
await json('/session/' + sessionId + '/detach', {}, 204);
refused.clear();
{
sessionId = config.secondarySessionId;
clientId = '';
clientId = (
await json('/session', {
sessionId,
sessionScope: 'thread',
...profile,
})
).clientId;
await assert.rejects(
access(path.join(session.directory, 'unicode-secondary-ok.txt')),
);
corrected = true;
await prompt();
corrected = false;
assert.equal(
await readFile(
path.join(session.directory, 'unicode-secondary-ok.txt'),
'utf8',
),
validText,
);
await json('/session/' + sessionId + '/detach', {}, 204);
}
}
console.log(
'HOSTED_SHELL_UNICODE_OK: ' +
config.sessions.map((session) => session.fault).join(',') +
', 9 refusals per capture, durable reload, Unicode correction, second Session',
);
} finally {
await cli.close();
await model.close();
proxy.closeAllConnections();
await new Promise<void>((resolve, reject) =>
proxy.close((error) => (error ? reject(error) : resolve())),
);
}
@@ -1342,13 +1342,16 @@ it('applies failClosed when a lost reply is settled through status after reconst
});
});
function heldHookExecution() {
return new Promise<(result: ManagedHookResult) => void>((started) => {
execute = () => new Promise((finish) => started(finish));
});
}
it('waits for Runtime admission before acknowledging an async Hook', async () => {
catalog = { ...catalog, hooks: [{ ...catalog.hooks[0], async: true }] };
const admitted = heldHookExecution();
let finish: ((result: ManagedHookResult) => void) | undefined;
execute = () =>
new Promise((resolve) => {
finish = resolve;
});
let completed = false;
const firing = hooks
.fire(HookEventName.PreToolUse, 'call-1', {}, signal())
@@ -1356,13 +1359,21 @@ it('waits for Runtime admission before acknowledging an async Hook', async () =>
completed = true;
return result;
});
await vi.waitFor(() => expect(finish).toBeDefined(), {
timeout: isSlowTestHost() ? 30_000 : 1_000,
});
expect(completed).toBe(false);
finish!({ success: true, outcome: 'success', duration: 0 });
await firing;
expect(completed).toBe(true);
try {
finish = await Promise.race([
admitted,
firing.then(() => {
throw new Error('Hook completed before Runtime admission.');
}),
]);
expect(completed).toBe(false);
finish!({ success: true, outcome: 'success', duration: 0 });
await firing;
expect(completed).toBe(true);
} finally {
finish?.({ success: false, outcome: 'cancelled', duration: 0 });
await Promise.allSettled([firing]);
}
});
it('rejects async prompt Hooks instead of leaving a detached model request', async () => {
@@ -1383,32 +1394,37 @@ it('rejects async prompt Hooks instead of leaving a detached model request', asy
});
it('rejects a changed input while the same occurrence is still running', async () => {
const admitted = heldHookExecution();
let finish: ((result: ManagedHookResult) => void) | undefined;
execute = () =>
new Promise((resolve) => {
finish = resolve;
});
const firing = hooks.fire(
HookEventName.PreToolUse,
'call-1',
{ tool_name: 'read_file' },
signal(),
);
await vi.waitFor(() => expect(finish).toBeDefined(), {
timeout: isSlowTestHost() ? 30_000 : 1_000,
});
await expect(
hooks.fire(
HookEventName.PreToolUse,
'call-1',
{ tool_name: 'write_file' },
signal(),
),
).rejects.toThrow(HostedHookInputConflictError);
expect(hooks.hasPendingOperations).toBe(true);
finish!({ success: true, outcome: 'success', duration: 0 });
await firing;
expect(hooks.hasPendingOperations).toBe(false);
try {
finish = await Promise.race([
admitted,
firing.then(() => {
throw new Error('Hook completed before Runtime admission.');
}),
]);
await expect(
hooks.fire(
HookEventName.PreToolUse,
'call-1',
{ tool_name: 'write_file' },
signal(),
),
).rejects.toThrow(HostedHookInputConflictError);
expect(hooks.hasPendingOperations).toBe(true);
finish!({ success: true, outcome: 'success', duration: 0 });
await firing;
expect(hooks.hasPendingOperations).toBe(false);
} finally {
finish?.({ success: false, outcome: 'cancelled', duration: 0 });
await Promise.allSettled([firing]);
}
});
it('rejects another tool input order while the same occurrence is still running', async () => {
@@ -1448,24 +1464,29 @@ it('cancels an active occurrence and does not dispatch later sequential hooks',
{ ...catalog.hooks[0], sequential: true, hookId: 'second' },
],
};
const admitted = heldHookExecution();
let finish: ((result: ManagedHookResult) => void) | undefined;
execute = () =>
new Promise((resolve) => {
finish = resolve;
});
const firing = hooks.fire(HookEventName.PreToolUse, 'call-1', {}, signal());
await vi.waitFor(() => expect(finish).toBeDefined(), {
timeout: isSlowTestHost() ? 30_000 : 1_000,
});
const cancelled = await hooks.status('call-1', true);
expect(cancelled.cancelRequested).toBe(true);
finish!({ success: false, outcome: 'cancelled', duration: 0 });
expect((await firing)?.decision).toBe('block');
expect(
requests.filter((request) => request.kind === 'hook-execute'),
).toHaveLength(1);
expect((await hooks.status('call-1')).run.state).toBe('cancelled');
expect(hooks.hasPendingOperations).toBe(false);
try {
finish = await Promise.race([
admitted,
firing.then(() => {
throw new Error('Hook completed before Runtime admission.');
}),
]);
const cancelled = await hooks.status('call-1', true);
expect(cancelled.cancelRequested).toBe(true);
finish!({ success: false, outcome: 'cancelled', duration: 0 });
expect((await firing)?.decision).toBe('block');
expect(
requests.filter((request) => request.kind === 'hook-execute'),
).toHaveLength(1);
expect((await hooks.status('call-1')).run.state).toBe('cancelled');
expect(hooks.hasPendingOperations).toBe(false);
} finally {
finish?.({ success: false, outcome: 'cancelled', duration: 0 });
await Promise.allSettled([firing]);
}
});
it('refuses an ambiguous external occurrence id', async () => {
@@ -3398,6 +3419,113 @@ function loseFinalMarkerWrite() {
});
}
it.each([
...[false, true].flatMap((merge) =>
[false, true].map((loseMarker) => ({
merge,
loseMarker,
event: HookEventName.PreToolUse,
padding: 0,
})),
),
...[false, true].flatMap((loseMarker) =>
[HookEventName.PreToolUse, HookEventName.PermissionRequest].map(
(event) => ({
merge: event === HookEventName.PreToolUse,
loseMarker,
event,
padding: 40 * 1024,
}),
),
),
...[false, true].map((loseMarker) => ({
merge: false,
loseMarker,
event: HookEventName.PermissionRequest,
padding: 0,
})),
])(
'stops malformed sequential Shell input and reconstructs its terminal prefix ($event, $merge, $loseMarker, $padding)',
async ({ merge, loseMarker, event, padding }) => {
enforceHostedResourceLimit();
const malformed = 'x'.repeat(padding) + 'echo 😀\ud800';
catalog = {
...catalog,
hooks: ['first', 'repair'].map((hookId) => ({
...catalog.hooks[0],
hookId,
eventName: event,
sequential: true,
onceKey: hookId === 'repair' ? 'repair-once' : null,
})),
};
execute = async (operation) => ({
success: true,
outcome: 'success',
duration: 0,
output: {
hookSpecificOutput:
event === HookEventName.PermissionRequest
? {
decision: {
behavior: 'allow',
updatedInput: {
command:
operation.hookId === 'first'
? malformed
: 'echo repaired',
},
},
}
: {
[merge ? 'tool_input' : 'updatedInput']: {
command:
operation.hookId === 'first' ? malformed : 'echo repaired',
},
},
},
});
const input = {
tool_name: 'run_shell_command',
tool_input: { command: 'echo valid' },
};
const expected = {
hookSpecificOutput:
event === HookEventName.PermissionRequest
? { decision: { updatedInput: { command: malformed } } }
: { updatedInput: { command: malformed } },
};
const publish = vi.spyOn(session.resources, 'publish');
const lost = loseMarker ? loseFinalMarkerWrite() : undefined;
const firing = hooks.fire(event, 'unicode', input, signal());
if (lost) {
await expect(firing).rejects.toThrow('lost final marker write');
lost.mockRestore();
} else await expect(firing).resolves.toMatchObject(expected);
const restored = new HostedHookSession(options, session, pin);
expect((await restored.status('unicode')).run.state).toBe('settled');
expect(
await restored.fire(event, 'unicode', input, signal()),
).toMatchObject(expected);
expect(restored.hasPendingOperations).toBe(false);
for (const [kind, bytes] of publish.mock.calls)
if (kind === 'managed-hook-result')
expect(bytes.length).toBeLessThanOrEqual(60 * 1024);
expect(
requests
.filter((request) => request.kind === 'hook-execute')
.map((request) => request.hookId),
).toEqual(['first']);
expect(
session.authority
.extensionRecordsInDomain('hook_execution')
.some(
(entry) => parseHookExecution(entry.record).onceKey === 'repair-once',
),
).toBe(false);
},
);
it.each([
[HookEventName.PreToolUse, false],
[HookEventName.PreToolUse, true],
@@ -3595,6 +3723,74 @@ it.each([false, true])(
},
);
it.each([false, true])(
'bounds assembled Unicode refusal metadata (lost marker: %s)',
async (loseMarker) => {
enforceHostedResourceLimit();
catalog = {
...catalog,
hooks: ['first', 'repair'].map((hookId) => ({
...catalog.hooks[0],
hookId,
sequential: true,
})),
};
execute = async () => ({
success: true,
outcome: 'success',
duration: 0,
output: {
hookSpecificOutput: {
tool_input: { description: '\ud800' },
padding: 'x'.repeat(40 * 1024),
},
},
});
const input = {
tool_name: 'run_shell_command',
tool_input: { command: 'x'.repeat(40 * 1024) },
};
const lost = loseMarker ? loseFinalMarkerWrite() : undefined;
const firing = hooks.fire(
HookEventName.PreToolUse,
'assembled-unicode',
input,
signal(),
);
if (lost) {
await expect(firing).rejects.toThrow('lost final marker write');
lost.mockRestore();
} else
await expect(firing).resolves.toMatchObject({
continue: false,
decision: 'block',
});
const restored = new HostedHookSession(options, session, pin);
expect((await restored.status('assembled-unicode')).run.state).toBe(
'settled',
);
expect(
await restored.fire(
HookEventName.PreToolUse,
'assembled-unicode',
input,
signal(),
),
).toMatchObject({
decision: 'block',
reason: expect.stringContaining('60 KiB'),
stopReason:
'Hosted Shell description contains an unpaired UTF-16 surrogate. Provide valid Unicode and retry.',
});
expect(restored.hasPendingOperations).toBe(false);
expect(
requests
.filter((request) => request.kind === 'hook-execute')
.map((request) => request.hookId),
).toEqual(['first']);
},
);
it.each(['none', 'hook', 'borrowed'] as const)(
'releases only acquired Hook owners after actual HTTP warm (%s)',
async (mode) => {
+74 -6
View File
@@ -58,6 +58,7 @@ import {
} from './hosted-workspace-broker.js';
import type { HostedWorkspaceBrokerOptions } from './hosted-workspace-broker.js';
import { waitForTurn } from './hosted-turn-wait.js';
import { hostedShellInputError } from './hosted-shell-input.js';
export class HostedHookRecoveryRequiredError extends Error {
constructor() {
@@ -124,13 +125,18 @@ function sequentialInput(
if (!result.success || !result.output) return input;
const effective = applyHookOutputToInput(input, result.output, event);
const output = result.output.hookSpecificOutput;
const decision =
event === HookEventName.PermissionRequest
? (output?.['decision'] as { updatedInput?: unknown } | undefined)
: undefined;
const updatedInput = decision?.updatedInput ?? output?.['updatedInput'];
return {
...effective,
// PreToolUse replacements are validated by the shared helper above.
...(event !== HookEventName.PreToolUse &&
output?.['updatedInput'] &&
typeof output['updatedInput'] === 'object'
? { tool_input: output['updatedInput'] }
updatedInput &&
typeof updatedInput === 'object'
? { tool_input: updatedInput }
: {}),
...(typeof output?.['updatedPrompt'] === 'string'
? { prompt: output['updatedPrompt'] }
@@ -138,6 +144,57 @@ function sequentialInput(
};
}
function malformedShellInput(input: HookInput, event: HookEventName): boolean {
return (
(event === HookEventName.PreToolUse ||
event === HookEventName.PermissionRequest) &&
'tool_name' in input &&
'tool_input' in input &&
hostedShellInputError(input['tool_name'], input['tool_input']) !== undefined
);
}
function shellRefusalOutput(
output: HookOutput | undefined,
input: HookInput,
event: HookEventName,
): HookOutput | undefined {
if (
!('tool_name' in input) ||
!('tool_input' in input) ||
!malformedShellInput(input, event)
)
return output;
const specific = { ...output?.hookSpecificOutput };
delete specific['tool_input'];
delete specific['updatedInput'];
const refusal = {
...output,
hookSpecificOutput: {
...specific,
...(event === HookEventName.PermissionRequest
? {
decision: {
...(specific?.['decision'] as
| Record<string, unknown>
| undefined),
updatedInput: input['tool_input'],
},
}
: { updatedInput: input['tool_input'] }),
},
};
return exceedsHookResourceLimit({ output: refusal })
? {
...byteLimitOutput(event),
stopReason: hostedShellInputError(
input['tool_name'],
input['tool_input'],
),
}
: refusal;
}
function semanticInput(value: object): Record<string, unknown> {
const fields = { ...value } as Record<string, unknown>;
for (const key of [
@@ -1152,6 +1209,7 @@ export class HostedHookSession {
const result = await execute(hook, index);
results.push(result);
effectiveInput = sequentialInput(effectiveInput, result, event);
if (malformedShellInput(effectiveInput, event)) break;
}
} else {
const settled = await Promise.allSettled(plan.hooks.map(execute));
@@ -1166,7 +1224,11 @@ export class HostedHookSession {
? cancelledResult().output
: inputLimitExceeded
? byteLimitOutput(event)
: this.aggregateOutput(results, event);
: shellRefusalOutput(
this.aggregateOutput(results, event),
effectiveInput,
event,
);
const resultRef = await this.publish('managed-hook-result', {
...(output ? { output: createHookOutput(event, output) } : {}),
});
@@ -1818,8 +1880,10 @@ export class HostedHookSession {
eventName: record.eventName as HookEventName,
...(result.error ? { error: new Error(result.error) } : {}),
} as HookExecutionResult);
if (plan.sequential)
if (plan.sequential) {
effectiveInput = sequentialInput(effectiveInput, result, event);
if (malformedShellInput(effectiveInput, event)) break;
}
}
if (record.run.execution === 'intent') {
await this.commit('hook_execution', record.hookExecutionId, (latest) =>
@@ -1837,7 +1901,11 @@ export class HostedHookSession {
}
const output = inputLimitExceeded
? byteLimitOutput(event)
: this.aggregateOutput(results, event);
: shellRefusalOutput(
this.aggregateOutput(results, event),
effectiveInput,
event,
);
const resultRef = await this.publish('managed-hook-result', {
...(output ? { output } : {}),
});
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, it } from 'vitest';
import { managedToolDigest } from '@qwen-code/qwen-code-core/tools/managed-tool-protocol.js';
import {
hostedShellHookInputError,
hostedShellInputError,
} from './hosted-shell-input.js';
it.each(['command', 'description'])(
'rejects unpaired code units in %s without echoing input',
(field) => {
for (const value of [
'\ud800',
'\udc00',
'\ud800a',
'\udc00\ud800',
'\ud800\ud800',
'\udc00\udc00',
'😀\ud800',
]) {
const error = hostedShellInputError('run_shell_command', {
command: 'pwd',
[field]: value,
});
expect(error).toBe(
'Hosted Shell ' +
field +
' contains an unpaired UTF-16 surrogate. Provide valid Unicode and retry.',
);
}
},
);
it.each([
'ASCII',
'中文',
'😀',
'é',
'\\ud800',
'\x1b\x01\x7f\u2028\u2029',
'',
])('preserves valid text and canonical digest (%j)', (value) => {
const input = { command: 'printf ' + value, description: value };
const encoded = JSON.stringify(input);
const digest = managedToolDigest(input);
expect(hostedShellInputError('run_shell_command', input)).toBeUndefined();
expect(JSON.stringify(input)).toBe(encoded);
expect(managedToolDigest(input)).toBe(digest);
});
it('leaves other tools and argument shapes to their existing validators', () => {
expect(
hostedShellInputError('monitor', { command: '\ud800' }),
).toBeUndefined();
for (const input of [
undefined,
null,
[],
{ command: 7 },
{ command: 'pwd', description: 7 },
{ command: 'pwd' },
])
expect(hostedShellInputError('run_shell_command', input)).toBeUndefined();
});
it('recognizes a bounded Unicode refusal without accepting unrelated stop reasons', () => {
const error = hostedShellInputError('run_shell_command', {
description: '\ud800',
})!;
const output = { continue: false, stopReason: error };
expect(hostedShellHookInputError('run_shell_command', output)).toBe(error);
expect(hostedShellHookInputError('read_file', output)).toBeUndefined();
expect(
hostedShellHookInputError('run_shell_command', {
stopReason: 'policy stop',
}),
).toBeUndefined();
expect(
hostedShellHookInputError('run_shell_command', undefined),
).toBeUndefined();
});
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { HookOutput } from '@qwen-code/qwen-code-core/hooks/types.js';
function unicodeError(field: string): string {
return (
'Hosted Shell ' +
field +
' contains an unpaired UTF-16 surrogate. Provide valid Unicode and retry.'
);
}
export function hostedShellHookInputError(
toolName: unknown,
output: HookOutput | undefined,
): string | undefined {
if (toolName !== 'run_shell_command') return undefined;
return ['command', 'description'].some(
(field) => output?.stopReason === unicodeError(field),
)
? output?.stopReason
: undefined;
}
export function hostedShellInputError(
toolName: unknown,
input: unknown,
): string | undefined {
if (toolName !== 'run_shell_command' || !input || typeof input !== 'object')
return undefined;
const args = input as Record<string, unknown>;
for (const field of ['command', 'description']) {
const value = args[field];
if (typeof value === 'string' && /[\uD800-\uDFFF]/u.test(value))
return unicodeError(field);
}
return undefined;
}
File diff suppressed because it is too large. Load diff
@@ -108,6 +108,10 @@ import {
type HostedWorkspaceBrokerOptions,
} from './hosted-workspace-broker.js';
import { HostedShellPublisher } from './hosted-shell-publisher.js';
import {
hostedShellHookInputError,
hostedShellInputError,
} from './hosted-shell-input.js';
import { MANAGED_WORKSPACE_CONTEXT_FILES } from './managed-runtime-provider-protocol.js';
import type { HostedMcpSession } from './hosted-mcp-session.js';
import type {
@@ -2638,6 +2642,139 @@ export class HostedWorkspaceToolTurn {
entry.payload['batchId'] === assistant?.uuid,
);
const preToolOutputs = new Map<string, PreToolUseHookOutput>();
let unicodeRefused = calls.some((call) =>
hostedShellInputError(call.name, call.args),
);
let completeEvidence = true;
await Promise.all(
calls.map(async (call) => {
let args = call.args;
const records = await Promise.all(
[HookEventName.PermissionRequest, HookEventName.PreToolUse].map(
async (event) => {
const marker = this.session.authority.extensionRecord(
'hook_execution',
hostedHookOccurrenceId(
event,
`${this.promptId}:${call.callId}`,
),
);
if (!marker) return undefined;
const record = parseHookExecution(marker.record);
const [inputBytes, resultBytes] = await Promise.all([
this.session.resources.read(record.inputRef),
record.resultRef
? this.session.resources.read(record.resultRef)
: undefined,
]);
const original = JSON.parse(inputBytes.toString()) as {
tool_input?: Record<string, unknown>;
tool_use_id?: string;
tool_name?: string;
};
const saved = resultBytes
? (JSON.parse(resultBytes.toString()) as {
output?: HookOutput;
})
: undefined;
let matches =
original.tool_use_id === call.callId &&
original.tool_name === call.name;
if (!matches) {
const plan = JSON.parse(
(
await this.session.resources.read(record.planRef)
).toString(),
) as {
refusedInputDigest?: string;
hooks?: unknown[];
input?: unknown;
};
matches =
typeof plan.refusedInputDigest === 'string' &&
/^[a-f0-9]{64}$/.test(plan.refusedInputDigest) &&
plan.hooks?.length === 0 &&
isDeepStrictEqual(plan.input, original) &&
(record.resultRef !== null ||
record.run.execution === 'not_started_proven');
}
if (
(!record.resultRef &&
record.run.execution !== 'not_started_proven') ||
record.run.state === 'recovery_blocked' ||
!matches
)
completeEvidence = false;
return { event, original, saved };
},
),
);
for (const record of records) {
if (!record) continue;
const { event, original, saved } = record;
const specific = saved?.output?.hookSpecificOutput;
const decision = specific?.['decision'] as
| { updatedInput?: Record<string, unknown> }
| undefined;
args =
(event === HookEventName.PermissionRequest
? decision?.updatedInput
: (specific?.['updatedInput'] as
| Record<string, unknown>
| undefined)) ??
original.tool_input ??
args;
if (
hostedShellInputError(call.name, args) ||
hostedShellHookInputError(call.name, saved?.output)
)
unicodeRefused = true;
if (event === HookEventName.PreToolUse && saved?.output)
preToolOutputs.set(
call.callId,
new PreToolUseHookOutput(saved.output),
);
}
}),
);
if (unicodeRefused) {
const originalCalls = assistant?.message?.parts?.flatMap((part) =>
part.functionCall ? [part.functionCall] : [],
);
const persisted = history
.slice(assistant ? history.indexOf(assistant) + 1 : history.length)
.find((entry) => entry.type === 'tool_result')?.message?.parts;
if (
!completeEvidence ||
intents.length ||
!isDeepStrictEqual(
originalCalls?.map((call) => ({
id: call.id,
name: call.name,
args: call.args ?? {},
})),
calls.map((call) => ({
id: call.callId,
name: call.name,
args: call.args,
})),
) ||
responses.length !== calls.length ||
!responses.every(
(part, index) =>
part.functionResponse?.id === calls[index].callId &&
part.functionResponse?.name === calls[index].name &&
typeof part.functionResponse?.response?.['error'] === 'string',
) ||
!isDeepStrictEqual(persisted, JSON.parse(JSON.stringify(responses)))
) {
this.uncertain = true;
throw new HostedToolRecoveryRequiredError(
'Shell Unicode refusal evidence is incomplete or contradictory.',
);
}
return responses;
}
// Each call with the input its Hook decisions left. Records without an
// order are the earlier one's, where PreToolUse ran on what
// PermissionRequest left: there `earlier` is how an earlier release
@@ -3041,6 +3178,43 @@ export class HostedWorkspaceToolTurn {
this.promptHookRunner = runner;
}
private async refuseBatch(
calls: ToolCallRequestInfo[],
errors: Array<string | undefined>,
parts: Part[] | undefined,
model: string,
skillOrigin:
| Pick<ChatRecord, 'uuid' | 'timestamp' | 'clientInitiatedSkills'>
| undefined,
): Promise<Part[]> {
if (parts && !this.messageFitsInline('assistant', parts, model))
throw new Error(
'Hosted assistant record exceeds the inline Session Store limit.',
);
const responses = calls.flatMap((call, index) =>
convertToFunctionErrorResponse(
call.name,
call.callId,
[],
errors[index] ??
'This tool was not executed because another call in the batch has invalid arguments. Retry the batch with corrected arguments.',
),
);
if (!this.messageFitsInline('tool_result', responses, model))
throw new Error(
'Hosted tool refusal exceeds the inline Session Store limit.',
);
this.uncertain = true;
try {
if (parts) await this.commit('assistant', parts, model, skillOrigin);
await this.commit('tool_result', responses, model);
this.uncertain = false;
return responses;
} catch (cause) {
throw new HostedToolRecoveryRequiredError(cause);
}
}
setToolOutputBudget(budget: number): void {
this.toolOutputBudget = budget;
}
@@ -3518,6 +3692,8 @@ export class HostedWorkspaceToolTurn {
) {
validationError = 'Hosted Shell requires one foreground command.';
}
validationError =
hostedShellInputError(call.name, args) ?? validationError;
input = backgroundAdmitted
? { ...args, is_background: true }
: this.publication
@@ -4054,7 +4230,6 @@ export class HostedWorkspaceToolTurn {
};
});
};
let requests = restored?.requests ?? prepareRequests(calls);
const skillOrigin = calls.every(
(call) => call.name === 'skill' && call.isClientInitiated,
)
@@ -4064,6 +4239,23 @@ export class HostedWorkspaceToolTurn {
clientInitiatedSkills: true as const,
}
: undefined;
const unicodeErrors = calls.map((call) =>
hostedShellInputError(call.name, call.args),
);
if (unicodeErrors.some((error) => error !== undefined)) {
// Profile, duplicate-id and truncated-input hard refusals still win.
const requests = prepareRequests(calls);
return this.refuseBatch(
calls,
requests.map(
(request, index) => unicodeErrors[index] ?? request.validationError,
),
parts,
model,
skillOrigin,
);
}
let requests = restored?.requests ?? prepareRequests(calls);
if (!this.messageFitsInline('assistant', parts, model))
throw new Error(
'Hosted assistant record exceeds the inline Session Store limit.',
@@ -4088,28 +4280,13 @@ export class HostedWorkspaceToolTurn {
);
signal.throwIfAborted();
if (requests.some((request) => request.validationError)) {
const responses = requests.flatMap((request) =>
convertToFunctionErrorResponse(
request.call.name,
request.call.callId,
[],
request.validationError ??
'This tool was not executed because another call in the batch has invalid arguments. Retry the batch with corrected arguments.',
),
return this.refuseBatch(
calls,
requests.map((request) => request.validationError),
parts,
model,
skillOrigin,
);
if (!this.messageFitsInline('tool_result', responses, model))
throw new Error(
'Hosted tool refusal exceeds the inline Session Store limit.',
);
this.uncertain = true;
try {
await this.commit('assistant', parts, model, skillOrigin);
await this.commit('tool_result', responses, model);
this.uncertain = false;
return responses;
} catch (cause) {
throw new HostedToolRecoveryRequiredError(cause);
}
}
if (this.hooks || this.mcp || requests.some((request) => !request.skill))
await waitForTurn(this.warmed, signal);
@@ -4246,14 +4423,25 @@ export class HostedWorkspaceToolTurn {
const asks = new Set<number>();
if (this.hooks) {
const decided = this.nativeBatch.refusals;
requests = await this.decideHookInputs(
const decidedInputs = await this.decideHookInputs(
requests,
(inputs) => prepareRequests(inputs, true),
decided,
asks,
signal,
);
requests = decidedInputs.requests;
this.nativeBatch.requests = requests;
if (decidedInputs.unicode.some((error) => error !== undefined))
return this.refuseBatch(
requests.map((request) => request.call),
requests.map(
(_, index) => decidedInputs.unicode[index] ?? decided[index],
),
undefined,
model,
skillOrigin,
);
await startPublisher(
requests.filter((_, index) => decided[index] === undefined),
);
@@ -4278,6 +4466,7 @@ export class HostedWorkspaceToolTurn {
Math.max(0, requests.length - 1),
);
} catch (cause) {
if (cause instanceof HostedToolRecoveryRequiredError) throw cause;
throw new HostedToolRecoveryRequiredError(cause);
}
const refusal = (index: number): Part[] | undefined => {
@@ -7681,8 +7870,12 @@ export class HostedWorkspaceToolTurn {
refusals: Array<string | undefined>,
asks: Set<number>,
signal: AbortSignal,
): Promise<HostedPreparedRequest[]> {
): Promise<{
requests: HostedPreparedRequest[];
unicode: Array<string | undefined>;
}> {
const inputs = requests.map((request) => request.call);
const unicode: Array<string | undefined> = [];
for (const [index, request] of requests.entries()) {
const occurrence = `${this.promptId}:${request.call.callId}`;
const output = await this.hooks!.fire(
@@ -7698,6 +7891,12 @@ export class HostedWorkspaceToolTurn {
undefined,
HOSTED_TOOL_INPUT_ORDER,
);
unicode[index] =
hostedShellInputError(
request.call.name,
output?.hookSpecificOutput?.['updatedInput'],
) ?? hostedShellHookInputError(request.call.name, output);
if (unicode[index] !== undefined) continue;
refusals[index] = preToolUseRefusal(output, request.skill);
if (refusals[index] !== undefined) continue;
const replacement = preToolUseReplacement(output, request.skill);
@@ -7714,6 +7913,7 @@ export class HostedWorkspaceToolTurn {
let permissionRan = false;
for (const [index, request] of requests.entries()) {
if (
unicode[index] !== undefined ||
refusals[index] !== undefined ||
!this.approval ||
request.skill ||
@@ -7725,17 +7925,23 @@ export class HostedWorkspaceToolTurn {
)
continue;
permissionRan = true;
const decision = permissionRequestDecision(
await this.hooks!.fire(
HookEventName.PermissionRequest,
`${this.promptId}:${request.call.callId}`,
this.permissionRequestFields(request.call),
signal,
this.promptHookRunner,
undefined,
HOSTED_TOOL_INPUT_ORDER,
),
const output = await this.hooks!.fire(
HookEventName.PermissionRequest,
`${this.promptId}:${request.call.callId}`,
this.permissionRequestFields(request.call),
signal,
this.promptHookRunner,
undefined,
HOSTED_TOOL_INPUT_ORDER,
);
const decision = permissionRequestDecision(output);
const unicodeError =
hostedShellInputError(request.call.name, decision.input) ??
hostedShellHookInputError(request.call.name, output);
if (unicodeError !== undefined) {
unicode[index] = unicodeError;
continue;
}
refusals[index] = decision.refusal;
if (decision.allowed) this.hookAllowed.add(request.call.callId);
if (decision.input)
@@ -7746,7 +7952,7 @@ export class HostedWorkspaceToolTurn {
for (const index of asks)
refusals[index] ??=
'Hook requires approval, but no approval policy is available.';
return requests;
return { requests, unicode };
}
/**
@@ -1,13 +1,21 @@
package com.alibaba.qwen.code.managedagent;
import com.alibaba.qwen.code.managedagent.api.ToolPublicationController;
import com.alibaba.qwen.code.managedagent.artifact.ToolPublicationObjectStore;
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore;
import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore;
import com.alibaba.qwen.code.managedagent.persistence.ToolPublicationAdmissionStore;
import com.alibaba.qwen.code.managedagent.persistence.ToolPublicationDataStore;
import com.alibaba.qwen.code.managedagent.persistence.ToolPublicationStore;
import com.alibaba.qwen.code.managedagent.persistence.WorkspaceStorageGuard;
import com.alibaba.qwen.code.managedagent.session.HarnessEventProjector;
import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection;
import com.alibaba.qwen.code.managedagent.workspace.runtime.EmbeddedRuntimeBroker;
import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository;
import com.alibaba.qwen.code.runtimebroker.RuntimeSession;
import com.alibaba.qwen.code.runtimebroker.RuntimeTransport;
import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository;
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection;
import com.fasterxml.jackson.databind.JsonNode;
@@ -17,13 +25,19 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.reflect.Proxy;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
@@ -43,6 +57,7 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
@@ -67,6 +82,13 @@ class HostedWorkspaceToolTurnIT {
}
}
@ParameterizedTest
@ValueSource(strings = {"local", "o2"})
@Timeout(360)
void malformedShellUnicodeIsCorrectableBeforeAcquisition(String capture) throws Exception {
runDriver(List.of(capture), "shell-unicode");
}
@Test
@Timeout(360)
void operatorRecoveryReleasesOnlyTheOriginalQuiescentShellOnMySql() throws Exception {
@@ -175,10 +197,53 @@ class HostedWorkspaceToolTurnIT {
runDriver(cases, "provider-control");
}
private static void registerUnicodePublication(GenericApplicationContext context) {
var objects = new ConcurrentHashMap<String, byte[]>();
context.registerBean("unicodeObjects", ToolPublicationObjectStore.class, () -> new ToolPublicationObjectStore() {
@Override
public void putIfAbsent(String key, byte[] bytes) {
byte[] previous = objects.putIfAbsent(key, bytes.clone());
if (previous != null && !java.util.Arrays.equals(previous, bytes)) {
throw new IllegalStateException("Immutable fixture object changed");
}
}
@Override
public InputStream open(String key) {
byte[] bytes = objects.get(key);
if (bytes == null) {
throw new IllegalStateException("Missing fixture object");
}
return new ByteArrayInputStream(bytes);
}
@Override
public void validateConfiguration() {}
});
context.registerBean("unicodeGrants", ToolPublicationStore.class, () -> new ToolPublicationStore(
context.getBean(JdbcTemplate.class), context.getBean(PlatformTransactionManager.class),
context.getBean(ManagedSessionStore.class), context.getBean(ToolExecutionRepository.class),
context.getBean(RuntimeBindingRepository.class),
new ToolPublicationStore.Capacity(1024 * 1024, 16 * 1024 * 1024, 64 * 1024 * 1024, 4), false));
context.registerBean("unicodeData", ToolPublicationDataStore.class, () -> new ToolPublicationDataStore(
context.getBean(JdbcTemplate.class), context.getBean(PlatformTransactionManager.class),
context.getBean(ToolPublicationStore.class), context.getBean(ManagedSessionStore.class),
context.getBean(ToolPublicationObjectStore.class), Duration.ofSeconds(30), Duration.ofSeconds(5),
new ToolPublicationDataStore.VerificationBudget(1024 * 1024, Duration.ofMinutes(25))));
context.registerBean("unicodePublicationController", ToolPublicationController.class, () -> {
var properties = context.getBean(ManagedAgentProperties.class);
properties.getToolPublication().setEntryConcurrency(4);
return new ToolPublicationController(context.getBean(ToolPublicationStore.class),
context.getBean(ToolPublicationDataStore.class),
new ToolPublicationAdmissionStore(context.getBean(JdbcTemplate.class),
context.getBean(PlatformTransactionManager.class), context.getBean(ManagedSessionStore.class),
context.getBean(ToolPublicationDataStore.class)), properties);
});
}
private void runDriver(List<String> cases, String driverName) throws Exception {
boolean latency = driverName.equals("latency");
boolean unicode = driverName.equals("shell-unicode");
boolean operatorRecovery = driverName.equals("operator-recovery");
boolean faults = !driverName.equals("workspace-tool-turn") && !latency && !operatorRecovery;
boolean faults = !driverName.equals("workspace-tool-turn") && !latency && !unicode && !operatorRecovery;
boolean storeFaults = driverName.equals("store-failure");
boolean cancellations = driverName.equals("cancellation");
boolean sseGaps = driverName.equals("sse-gap");
@@ -253,6 +318,9 @@ class HostedWorkspaceToolTurnIT {
boolean verifiedRecovery = List.of("Linux", "Mac OS X").contains(System.getProperty("os.name"));
arguments.add("--qwen.managed-agent.runtime-broker.verified-workspace-recovery-enabled=" + verifiedRecovery);
var application = new SpringApplicationBuilder(ManagedAgentServerApplication.class);
if (unicode) {
application.initializers(context -> registerUnicodePublication((GenericApplicationContext) context));
}
if (sseGaps) {
// Keep SQL polling outside the 10s receive window to require live hub delivery.
arguments.add("--qwen.managed-agent.events.poll-interval=60s");
@@ -262,6 +330,10 @@ class HostedWorkspaceToolTurnIT {
}
try (var spring = (ServletWebServerApplicationContext) application.run(arguments.toArray(String[]::new))) {
JdbcTemplate jdbc = spring.getBean(JdbcTemplate.class);
if (unicode) {
spring.getBean(ManagedAgentProperties.class).getToolPublication()
.setServiceBaseUrl("http://127.0.0.1:" + spring.getWebServer().getPort());
}
if (faults) {
var metadata = jdbc.queryForMap("SELECT VERSION() AS version, @@version_comment AS engine");
System.out.println((shellOutput || providerControl ? "FG6F_DATABASE " : sseGaps ? "FG6E_DATABASE " : cancellations ? "FG6D_DATABASE " : storeFaults ? "FG6B_DATABASE " : "FG6A_DATABASE ") + metadata);
@@ -284,7 +356,7 @@ class HostedWorkspaceToolTurnIT {
"sha256:" + "a".repeat(64), "qwen-code", null, null, List.of(), null,
new WorkspaceSelection(workspaceId, "child"));
String profile = operatorRecovery ? (index < 2 ? "hosted-workspace-shell/1" : "hosted-workspace-files/1")
: !shellOutput && (faults || index < 2) ? "hosted-workspace-files/1" : "hosted-workspace-shell/1";
: !unicode && !shellOutput && (faults || index < 2) ? "hosted-workspace-files/1" : "hosted-workspace-shell/1";
String secondary = operatorRecovery ? store.insertWorkspaceSessionCommand(tenant, "actor", "secondary-" + index,
"sha256:" + "a".repeat(64), "qwen-code", null, null, List.of(), null,
new WorkspaceSelection(workspaceId, "child")).sessionId() : "";
@@ -376,7 +448,8 @@ class HostedWorkspaceToolTurnIT {
assertThat(driver.waitFor(faults || latency ? 130 : 270, TimeUnit.SECONDS)).as("Driver timeout: %s", Files.readString(log)).isTrue();
assertThat(driver.exitValue()).as("Driver output: %s", Files.readString(log)).isZero();
System.out.println(Files.readString(log));
assertThat(Files.readString(log)).contains(operatorRecovery ? "HOSTED_OPERATOR_RECOVERY_OK" : latency ? "HOSTED_LATENCY_OK"
assertThat(Files.readString(log)).contains(unicode ? "HOSTED_SHELL_UNICODE_OK: " + String.join(",", cases)
: operatorRecovery ? "HOSTED_OPERATOR_RECOVERY_OK" : latency ? "HOSTED_LATENCY_OK"
: providerControl ? "HOSTED_PROVIDER_FAULTS_OK"
: shellOutput ? "HOSTED_SHELL_OUTPUT_FAULTS_OK"
: sseGaps ? "HOSTED_SSE_GAP_OK"
@@ -393,7 +466,35 @@ class HostedWorkspaceToolTurnIT {
if (operatorRecovery) {
continue;
}
if (providerControl) {
if (unicode) {
assertThat(workspace.resolve("child/invalid.txt")).doesNotExist();
assertThat(workspace.resolve("child/sibling.txt")).doesNotExist();
assertThat(Files.readString(workspace.resolve("child/unicode-ok.txt")))
.isEqualTo("中文😀é\\ud800");
assertThat(Files.readString(workspace.resolve("child/unicode-secondary-ok.txt")))
.isEqualTo("中文😀é\\ud800");
for (String sessionId : List.of(sessions.get(index).get("sessionId").toString(), secondarySessionId)) {
var publications = jdbc.queryForList("SELECT state, producer_phase,"
+ " CASE WHEN accepted_complete THEN 1 ELSE 0 END AS complete"
+ " FROM ma_output_capture WHERE tenant_id = ? AND session_id = ?", tenant, sessionId);
if (cases.contains("o2")) {
assertThat(publications).hasSize(1);
var publication = publications.getFirst();
assertThat(publication.get("state")).isIn("OPEN", "FENCED");
assertThat(publication.get("producer_phase")).isEqualTo("REFERENCED");
assertThat(((Number) publication.get("complete")).intValue()).isEqualTo(1);
} else {
assertThat(publications).isEmpty();
}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_tool_execution"
+ " WHERE harness_session_id = ?", Integer.class, sessionId)).isEqualTo(1);
var execution = jdbc.queryForMap("SELECT dispatch_generation, execution_state, execution_status"
+ " FROM ma_tool_execution WHERE harness_session_id = ?", sessionId);
assertThat(((Number) execution.get("dispatch_generation")).longValue()).isEqualTo(1);
assertThat(execution.get("execution_state")).isEqualTo("SETTLED");
assertThat(execution.get("execution_status")).isEqualTo("success");
}
} else if (providerControl) {
providerProbe.assertReport(sessions.get(index), reports.get(index));
} else if (latency) {
boolean tool = cases.get(index).equals("tool");
@@ -439,7 +540,7 @@ class HostedWorkspaceToolTurnIT {
driver.destroyForcibly();
}
}
if (!faults && !latency && !operatorRecovery) {
if (!faults && !latency && !unicode && !operatorRecovery) {
Object service = ReflectionTestUtils.getField(broker, "service");
Object provisioner = ReflectionTestUtils.getField(service, "provisioner");
Object local = ReflectionTestUtils.getField(provisioner, "delegate");