mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
feat(managed-agent): admit public foreground Shell with mandatory approval (#13674)
* feat(managed-agent): admit public foreground Shell with mandatory approval * codex: address PR review feedback (#13674) * codex: address PR review feedback (#13674) * codex: address PR review feedback (#13674) * fix(managed-agent): keep Shell action answers with the recorded owner A Workspace operator may still answer other Actions. A Shell answer runs a command, so that arm does not apply. The rollout note says to upgrade the Harness before enabling the flag, because an older Harness ignores suppressChildAgents. * fix(managed-agent): state that only the owner may answer Shell Actions The public contract still said any Workspace operator could respond. Shell answers stay limited to the recorded owner; the route text now says so. * fix(managed-agent): omit the message funnel when Shell suppresses child agents Session messages share the child orchestrator's write chain, so a Shell lane that does not attach that orchestrator has no message funnel. A message-stop load keeps suppressChildAgents. * fix(managed-agent): require Shell approval before lifecycle and message-stop loads These two loads already send the Shell profile, but they skipped the check that a persisted Shell uses default or auto-edit approval. * fix(managed-agent): isolate internal TeamLead admission Preserve public Shell approval, child suppression and lifecycle boundaries while trusted internal TeamLead creation pins a reserved profile and private execution policy. Isolate public/internal creation replay and move the existing MySQL process rig to the trusted admission. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
b437892ffd
commit
a8990042e6
44 changed files with 2164 additions and 193 deletions
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
Status: implemented; under review. Part of #12952 and #12380.
|
||||
|
||||
This document records the original files-only G0 slice. [Public foreground Shell admission](public-hosted-shell-admission.md) supersedes its files-only profile/tool boundary when the separate deployment Shell opt-in is enabled. Public callers still cannot select a profile; the Workspace files opt-in still gates creation input, and existing Sessions keep their persisted profile and approval mode.
|
||||
|
||||
## Problem and scope
|
||||
|
||||
Hosted Read/Write/Edit already runs through the production Broker and worker,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
状态:已实现,审计中。属于 #12952 与 #12380。
|
||||
|
||||
本文记录原始仅文件工具的 G0 切片。另行开启部署 Shell 开关时,[公开前台 Shell 准入](public-hosted-shell-admission.zh-CN.md)取代本文仅文件 profile/工具的边界。公开调用方仍不能选择 profile;创建输入仍由 Workspace 文件开关准入,既有 Session 保留其持久化 profile 和审批模式。
|
||||
|
||||
## 问题与范围
|
||||
|
||||
Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前只有私有集成测试把它接到持久 Workspace 会话。公开创建在 service 和 SQL store 两层拒绝初始输入;coordinator 也拒绝有绑定的会话。Java connector 不传工具 profile,所有 Session Store 连接均使用部署的全局 Workspace。
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@ Status: implemented and enabled. #13824 landed the lead's team funnel, the five
|
|||
|
||||
## Problem and scope
|
||||
|
||||
Admission update in PR #13674: public foreground Shell suppresses child/team
|
||||
tools and excludes public lifecycle. The internal TeamLead fixture now uses
|
||||
the trusted empty-creation entry to pin `hosted-workspace-team-lead/1`, which
|
||||
retains its private approval, lineage and protocol-0 close without a SQL
|
||||
profile rewrite. Public create and stored definitions cannot mint that policy.
|
||||
The historical physical pass below used the old rig; it does not certify this
|
||||
new admission. The original three Linux/MySQL process cases remain required.
|
||||
See the [internal admission design](2026-10-11-internal-team-lead-admission.md).
|
||||
|
||||
H4e-a pinned what a team may commit: a roster and lifecycle (`team_state`), a board (`team_task`), a mailbox (`team_message`) and plan approvals (`team_plan`), all in the lead Session's journal, all disabled. Nothing produces them. On the Hosted path a lead can launch child agents (H4b), but it cannot name them, group them, give them a board, or delete a team; `name` is refused at admission as a legacy-only argument, and no team tool is declared.
|
||||
|
||||
The H4e-a design gave the whole runtime to "H4e-b". That slice is too large to land in one piece, and part of it waits on work that has not started. The mailbox relay, the member-side delivery and the continuation of a member all build on H4d-b, the Session message runtime ([H4d-a design](2026-10-09-managed-session-messages.md)), which had no implementation when this design was written and has since landed ([H4d-b design](2026-10-10-managed-session-message-runtime.md), #13822); plan approval and member shutdown travel as mailbox messages. H4e-b is therefore split into three slices:
|
||||
|
|
@ -102,7 +111,7 @@ A member launched with `isolation: "worktree"` (#13841) also has its child Works
|
|||
- **Tool turn.** The admission matrix for every tool and every refusal of decisions 2, 3, 5 and 7, including the batch rules. Replay of each tool call: a replayed call adds no team, member, task or edge. A crash after `task_create` commits its task and before the tool result: the replay answers with the same number and opens no second task. The crash between launch and join, in both outcomes. Task numbering after deletions. Completing, editing and deleting a task whose owner's run has ended. A member whose run completed set as the owner of a task the call leaves completed, and refused on an open task, including one reopened with that owner kept. Multi-record edge updates that stop part-way and then replay. The roster view in `task_list`, with stop reasons and the task ids of running members. The `<teammate>` label, beside the `<workspace>` outcome of an isolated member, whose settle `team_delete` waits for. A message to a running member, the refused continuation of a finished one, the continuation of a former member once its team is deleted, and the label kept on a result accepted after the deletion. Approval classes and preview.
|
||||
- **Authority.** No new rule, so the H4e-a suites stand. A rebuild of a Session holding team records restores them, and the reopen whitelist admits them.
|
||||
- **Java.** A lead close with an open team and running members runs the H4b cascade over the members and commits no team record, and the lifecycle gate still refuses team records under a claim.
|
||||
- **Hosted IT.** `HostedTeamLeadMySqlIT` runs in the Hosted MySQL lane: a real Spring server on MySQL drives the packaged Harness with a scripted model through the team flow (both labeled reports, `team_delete` refused while the members run, a finished member set as the owner of the task it completed), a lead close that waits for its running member, and the approval cards in `default` mode. It reaches a Shell-laned lead as the physical pass does, by setting the profile before the first Turn.
|
||||
- **Hosted IT.** `HostedTeamLeadMySqlIT` runs in the Hosted MySQL lane: a real Spring server on MySQL drives the packaged Harness with a scripted model through the team flow (both labeled reports, `team_delete` refused while the members run, a finished member set as the owner of the task it completed), a lead close that waits for its running member, and the approval cards in `default` mode. Its lead now comes from trusted internal admission; the same three assertions remain, with no public Shell flag or SQL promotion. New-head Linux/MySQL acceptance is pending.
|
||||
- **Physical acceptance**, before enablement, on a real Hosted stack:
|
||||
- a lead creates a team, spawns two members by name, assigns board tasks, receives both labeled reports, marks the tasks done and deletes the team;
|
||||
- a lead closes with a member still running, and the member is cancelled while the team records stay unchanged;
|
||||
|
|
@ -112,7 +121,7 @@ A member launched with `isolation: "worktree"` (#13841) also has its child Works
|
|||
- A lead close with a member still running cancels the member: its `child_run` ends `cancelled` with its stop request, its Session closes, the team records are unchanged and the lead is never revived. The close waits for the member's Turn to end (decision 8), and that includes an approval the member is waiting on.
|
||||
- Killing the Harness before the join commits leaves one `child_run` and no roster entry; killing it after the join commits leaves one `child_run` and one roster entry. In both arms the member Session runs, but the lead's Turn fails `managed_runtime_recovery_blocked`, so the member's result never reaches the lead (open question 5). The same run without a team, with an unnamed background launch, ends the same way. The channel settlement's answer (decision 11) is not exercised, because the rig has no channel adapter.
|
||||
- `team_delete` with a member running is refused, naming the member, and commits no revision. `task_list` runs without an approval card, and the `team_create`, `task_create`, `task_update` and Agent cards show their input previews. A control arm that removes only the two domains from the enabled list declares no team tool and no `name`, refuses `name` with the unsupported-argument answer, and commits no record.
|
||||
- The pass needs one declared rig step: a fresh Session is moved to `hosted-workspace-shell/1` by SQL before its first Turn. No Java route creates a Shell-laned Session today: a public Workspace Session gets `hosted-workspace-files/1`, the public create refuses a profile override, and a child Session copies its parent's profile. So, like H4b's Agent tool, the team tools reach users only once a public route opens a Shell profile.
|
||||
- That historical pass used one declared rig step: a fresh Session was moved to `hosted-workspace-shell/1` by SQL before its first Turn. PR #13674 replaces this in the process fixture with trusted internal admission. Public foreground Shell is a separate policy and does not expose H4b Agent or team tools; a public team admission contract remains follow-up work. The historical pass is not validation of the new reserved profile.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@
|
|||
|
||||
## 问题与范围
|
||||
|
||||
PR #13674 准入更新:公开前台 Shell 抑制 child/team 工具并排除公开生命周期。
|
||||
内部 TeamLead 夹具现在经可信空创建入口固定 `hosted-workspace-team-lead/1`,
|
||||
无需 SQL 改写 profile,保留内部审批、lineage 和 protocol-0 关闭。
|
||||
公开创建与已存储定义不能生成该策略。下文历史实机验收使用旧环境步骤,
|
||||
不证明新准入;原有三项 Linux/MySQL 进程案例仍为必需验收。
|
||||
详见[内部准入设计](2026-10-11-internal-team-lead-admission.zh-CN.md)。
|
||||
|
||||
H4e-a 固定了团队可以提交什么:名册与生命周期(`team_state`)、任务板(`team_task`)、mailbox(`team_message`)与计划审批(`team_plan`),全部位于 lead Session 的 journal 中,全部禁用。还没有任何东西生产它们。在 Hosted 路径上,lead 可以启动 child agent(H4b),但无法为它们命名、把它们编成组、给它们一块任务板,也无法删除团队;`name` 在准入时作为 legacy 专属参数被拒绝,也没有声明任何团队工具。
|
||||
|
||||
H4e-a 设计把整个运行时交给了 "H4e-b"。这一片太大,无法一次落地,其中一部分还要等尚未开始的工作。mailbox 中继、成员一侧的投递以及成员的续跑,都建立在 H4d-b(Session 消息运行时,见 [H4d-a 设计](2026-10-09-managed-session-messages.zh-CN.md))之上,而 H4d-b 在撰写本设计时尚无实现,此后已经落地([H4d-b 设计](2026-10-10-managed-session-message-runtime.zh-CN.md),#13822);计划审批与成员关停都以 mailbox 消息传递。因此 H4e-b 拆为三片:
|
||||
|
|
@ -102,7 +109,7 @@ H4e-b1 不需要任何尚未落地的东西。它的成员就是 H4b 的 child a
|
|||
- **工具轮。** 每个工具的准入矩阵,以及决策 2、3、5、7 的每种拒绝,包括批次规则。每个工具调用的重放:重放的调用不会增加团队、成员、任务或边。`task_create` 提交任务之后、工具结果之前的崩溃:重放以相同的编号作答,且不开启第二个任务。启动与加入之间的崩溃,两种结果都覆盖。删除之后的任务编号。完成、修改与删除 owner 的 run 已结束的任务。run 已成功完成的成员被设为在该调用之后处于 completed 的任务的 owner,而在未完成的任务上被拒绝,包括保留该 owner 重新打开的任务。中途停止后再重放的多记录依赖更新。`task_list` 中的名册视图,含停止原因与运行中成员的 task id。`<teammate>` 标签,以及隔离成员旁边的 `<workspace>` 结果,`team_delete` 会等待其 settle。给运行中成员的消息、对已结束成员续跑的拒绝、团队删除后前成员的续跑,以及删除后才被接受的结果仍保留标签。审批类别与预览。
|
||||
- **Authority。** 没有新规则,因此 H4e-a 的测试套件照旧成立。重建持有团队记录的 Session 会恢复它们,重开白名单放行它们。
|
||||
- **Java。** 带开启团队且有成员运行的 lead 关闭,会对成员执行 H4b 级联且不提交任何团队记录,生命周期门禁在认领下仍拒绝团队记录。
|
||||
- **Hosted IT。** `HostedTeamLeadMySqlIT` 在 Hosted MySQL 车道中运行:基于 MySQL 的真实 Spring 服务器以脚本化模型驱动打包的 Harness,覆盖团队流程(两份带标签的汇报、成员运行时被拒的 `team_delete`、已完成成员被设为其所完成任务的 owner)、等待运行中成员结束的 lead 关闭,以及 `default` 模式下的审批卡片。它与实机验收一样,在第一个 Turn 之前设置档位,得到 Shell 通道的 lead。
|
||||
- **Hosted IT。** `HostedTeamLeadMySqlIT` 在 Hosted MySQL 车道中运行:基于 MySQL 的真实 Spring 服务器以脚本化模型驱动打包的 Harness,覆盖团队流程(两份带标签的汇报、成员运行时被拒的 `team_delete`、已完成成员被设为其所完成任务的 owner)、等待运行中成员结束的 lead 关闭,以及 `default` 模式下的审批卡片。lead 现在由可信内部准入创建,保留三项原有断言,不打开公开 Shell 开关,也不经 SQL 提升权限。新 head 的 Linux/MySQL 验收待完成。
|
||||
- **实机验收**,在启用之前,于真实 Hosted 环境上:
|
||||
- lead 创建团队,按名字拉起两个成员,分配任务板任务,收到两个带名字的汇报,把任务标记为完成并删除团队;
|
||||
- lead 在仍有成员运行时关闭,该成员被取消,而团队记录保持不变;
|
||||
|
|
@ -112,7 +119,7 @@ H4e-b1 不需要任何尚未落地的东西。它的成员就是 H4b 的 child a
|
|||
- lead 在仍有成员运行时关闭,会取消该成员:它的 `child_run` 带着停止请求以 `cancelled` 结束,它的 Session 关闭,团队记录不变,lead 也从未被复活。关闭会等待成员的 Turn 结束(决策 8),这也包括成员正在等待的审批。
|
||||
- 在加入提交之前杀掉 Harness,留下一个 `child_run`、没有名册条目;在加入提交之后杀掉,留下一个 `child_run`、一个名册条目。两种情况下成员 Session 都会运行,但 lead 的 Turn 以 `managed_runtime_recovery_blocked` 失败,因此成员的结果永远到不了 lead(开放问题 5)。不建团队、改用不带名字的后台启动做同样的运行,结局相同。channel 收尾的回答(决策 11)没有覆盖到,因为该环境没有 channel 适配器。
|
||||
- 有成员运行时 `team_delete` 被拒绝,点名该成员,且不提交任何修订。`task_list` 不弹审批卡,`team_create`、`task_create`、`task_update` 与 Agent 的审批卡都显示输入预览。只从启用列表中去掉这两个 domain 的对照组不声明任何团队工具,也没有 `name`,传入 `name` 时以不支持参数的回答拒绝,且不提交任何记录。
|
||||
- 这次验收需要一个申报过的环境步骤:新建的 Session 在第一个 Turn 之前用 SQL 切到 `hosted-workspace-shell/1`。目前没有任何 Java 路由会创建带 Shell 通道的 Session:公共 Workspace Session 得到的是 `hosted-workspace-files/1`,公共创建接口拒绝覆盖档位,child Session 复制父 Session 的档位。因此与 H4b 的 Agent 工具一样,团队工具要等某条公共路由开放 Shell 档位之后才能触达用户。
|
||||
- 那次历史验收使用一个申报过的环境步骤:新建的 Session 在第一个 Turn 之前用 SQL 切到 `hosted-workspace-shell/1`。PR #13674 在进程夹具中将它替换为可信内部准入。公开前台 Shell 属于独立策略,不开放 H4b Agent 或团队工具;公开团队准入契约仍为后续工作。历史验收不证明新的保留 profile。
|
||||
|
||||
## 验收标准
|
||||
|
||||
|
|
|
|||
88
docs/design/2026-10-11-internal-team-lead-admission.md
Normal file
88
docs/design/2026-10-11-internal-team-lead-admission.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Internal TeamLead admission alongside public foreground Shell
|
||||
|
||||
[English](2026-10-11-internal-team-lead-admission.md) | [简体中文](2026-10-11-internal-team-lead-admission.zh-CN.md)
|
||||
|
||||
Status: implemented in PR #13674, with focused local verification in progress and Linux/MySQL acceptance pending. The author chose to preserve the public Shell boundary and give internal TeamLead a separate admission path. A passing Store probe is not physical TeamLead acceptance.
|
||||
|
||||
## Problem and current state
|
||||
|
||||
The public foreground Shell slice opens `hosted-workspace-shell/1` behind a default-off deployment switch and asking approval. Its public Sessions suppress child agents and therefore team tools, and exclude public lifecycle. The incoming H4e-b1 process fixture creates an empty built-in Session, changes its profile to Shell/1 by SQL, and uses public input, Actions and close. It enables only Workspace files, and two cases inherit `yolo`. There is no persisted distinction between this internal rig and public Shell.
|
||||
|
||||
At `da4679f3e7242bbee91e0acbf4734578716c1600`, all three TeamLead MySQL cases fail before the first Turn: HTTP409 `workspace_unavailable`, expected202. Exact PR/main production-Store probes reproduce the difference with the same pin and zero new Turns on refusal. Enabling the Shell flag alone only addresses that first guard; asking approval, child suppression and public close remain incompatible.
|
||||
|
||||
## Goals and scope
|
||||
|
||||
Keep the public Shell policy and restore an explicit, reviewable internal TeamLead creation path. The identity must survive persistence, replay, child creation, cold attachment and recovery. Public REST/WebShell creation and stored AgentDefinition content must not acquire it. Internal execution retains existing Workspace authorization and Runtime authority; it is not a new authorization bypass.
|
||||
|
||||
This adds no HTTP route, public DTO field, deployment switch or database column. It does not implement public TeamLead, public Shell lifecycle, new background Shell/Monitor admission, Workspace migration beyond files/1, or Linux physical recovery on macOS.
|
||||
|
||||
## Admission identity
|
||||
|
||||
Reserve `hosted-workspace-team-lead/1` in the existing `tool_profile` column. A profile already pins the execution policy and is copied into child rows, so another independently mutable origin bit would be redundant. Keep `hosted-workspace-shell/1` and `/2` as the public Shell family in Java policy predicates. The authenticated private Harness recognizes the internal profile as a Shell execution lane, with its existing child/team capabilities and without `/2` search.
|
||||
|
||||
| Property | Public foreground Shell | Internal TeamLead |
|
||||
| ------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Creation | Existing public built-in/definition admission | Trusted Java Store entry only, built-in `qwen-code` |
|
||||
| Persisted profile | Shell/1; existing Shell/2 defensive guards | `hosted-workspace-team-lead/1` |
|
||||
| Public Shell switch | Required for fresh Shell work | Does not govern this private profile; Workspace files and execution prerequisites still apply |
|
||||
| Approval | `default` or `auto-edit` | Existing private configured mode, including `yolo` |
|
||||
| Child/team tools | Suppressed | Existing lead/child lineage rules apply |
|
||||
| Action response | Recorded owner only | Existing private Workspace Action authorization |
|
||||
| Close | Publicly refused | Existing owner/operator-authorized protocol-0 close and child cascade |
|
||||
| Public minting or definition override | Cannot select internal identity | Not available |
|
||||
|
||||
“Internal” describes trusted creation and the pinned execution policy. It does not hide a Session from an actor already granted access: existing public input, Action and close routes may operate on that internally created Session under their normal tenant, Workspace, owner and lifecycle checks. Those routes cannot change its profile or mint another internal root.
|
||||
|
||||
Deployment validation is unchanged: enabling public Shell still refuses a
|
||||
deployment-wide `yolo` mode. Internal Sessions can pin `yolo` while public Shell
|
||||
is off, and retain that pin if a later deployment enables public Shell with an
|
||||
asking default. Internal admission does not bypass startup validation.
|
||||
|
||||
## Trusted creation and replay
|
||||
|
||||
Add `ManagedAgentStore.insertInternalTeamLeadSessionCommand` for empty creation. It accepts the existing tenant, actor, idempotency key/digest, title and Workspace selection; it fixes the built-in identity, current revision, private configured approval and reserved profile itself. No public request or definition is forwarded into this method. The H4e-b1 fixture becomes its concrete caller and removes its SQL profile rewrite.
|
||||
|
||||
Fresh internal creation requires Workspace files enabled, the creator's creation grant, an ACTIVE resolved registry, the fixed execution config/policy refs, an admitted local mount and open migration state. Keep the existing tenant migration lock and replay-before-new-work ordering. Creation commits the profile, approval, binding, creator/owner and creation command together. Do not convert an existing public Session to internal with an UPDATE.
|
||||
|
||||
Creation command keys are shared by actor and tenant. Public replay must reject an internal record even when the request digest matches; internal replay must reject a public record. A matching internal retry returns its original pin without resolving a new deployment policy. Apply the kind check both to normal replay and the public DuplicateKey fallback, with `409 idempotency_conflict` on a mismatch.
|
||||
|
||||
## Execution, attachment and lifecycle
|
||||
|
||||
Java predicates explicitly identify public Shell. The switch, mandatory asking approval, child suppression, owner-only Shell Action rule and public lifecycle exclusion continue to apply to that family. The internal profile follows the existing private behavior; registry facts, Workspace access, mount verification, selected Runtime, worker fencing and failure semantics remain unchanged.
|
||||
|
||||
The Connector sends the internal profile through all four create/load/cold lifecycle/rename attachment paths and does not set public Shell suppression for it. Child insertion already copies the parent's profile and approval; retain that immutable inheritance. The Harness uses its shared profile vocabulary for live admission and saved-definition restore. Unknown profiles still refuse; older readers must not interpret this new identifier as files. Deploy the compatible Harness before using the trusted creation entry.
|
||||
|
||||
Internal close uses the existing protocol-0 path. Protocol-1 lifecycle and its receipt remain files/1-only. Offline Workspace migration also remains files/1-only; adding this private execution lane must not expand either acceptance gate.
|
||||
|
||||
## Public and definition boundaries
|
||||
|
||||
Built-in public creation still selects files or public Shell according to the existing switch. Public DTOs expose no internal profile selector. Hard-refuse the reserved identifier in `AgentDefinitionCompiler`, even if an operator adds it to the deployment allowlist: that allowlist currently accepts arbitrary profile strings and cannot establish internal provenance.
|
||||
|
||||
No OpenAPI shape or generated public type changes are required. Public `foreground_shell` remains the capability of the public Shell family, while existing authorized operation capabilities describe internally created records. Keep the public Shell negative controls, including initial input, disabled sending, owner-only Actions, child suppression and lifecycle refusal.
|
||||
|
||||
## Affected layers
|
||||
|
||||
| Layer | Change or verified consumer |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Java Store/profile vocabulary | Reserved profile, trusted empty creation, replay-kind checks; existing Session mapper/INSERT and child INSERT SELECT carry the pin |
|
||||
| Java definition compiler | Reserved-profile hard refusal |
|
||||
| Java service, Action Store and Connector | Explicit public Shell predicates; preserve existing private authorization and attachment behavior |
|
||||
| CLI profile vocabulary | Recognize private internal Shell execution in admission/restore; no search profile or public override |
|
||||
| H4e-b1 fixture | Use trusted creation instead of SQL mutation; retain all three original assertions and Linux/MySQL lane |
|
||||
| Design/test documentation | Update both languages and keep current-vs-verified evidence separate |
|
||||
|
||||
## Validation plan and acceptance criteria
|
||||
|
||||
Use the global CLI for a baseline dry-run and record its actual Managed Gateway limitation. Build/typecheck/bundle the local CLI before process verification. Run package-local focused tests, Java compilation/static checks and two clean full-diff audits, followed by independent code review.
|
||||
|
||||
Verify internal creation/replay preserves the reserved pin and approval with the public Shell switch both off and on. A public/internal key collision must refuse in both directions without new rows. Invalid creator, missing mount, disabled files, registry mismatch or closed migration must refuse before minting. Public and definition attempts to choose the internal identifier must not produce an internal row. Children inherit the private pin, and cold Connector requests retain it with suppression false. CLI controls must declare the internal agent/team tools while public Shell suppression remains effective.
|
||||
|
||||
Run the original three `HostedTeamLeadMySqlIT` cases in CI on Linux/MySQL8.4: two named members and task/labeled-result flow, lead close waiting for its member, and asking approval cards. Do not skip or weaken them. Re-run the public Shell Allow/Deny process controls locally. A local non-durable/test-script fallback may prove narrower behavior, but cannot certify the Linux durable close or MySQL receipt-fault gates. The existing local supplemental CLI661/663 failure remains separate unless directly repaired and verified.
|
||||
|
||||
The blocker clears only when the new-head process gate passes. Thread resolution and human approval remain separate merge requirements.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
The principal risk is accidentally letting public input or a definition acquire the reserved internal policy. Fixed Store minting, explicit compiler refusal and replay-kind checks are the boundary; caller intent or a fixture comment is not authority. A second risk is losing the pin during cold load or child creation, covered by the existing persisted-profile flow and targeted controls.
|
||||
|
||||
Existing SQL-mutated Shell rigs are not automatically grandfathered into the new private profile. They retain the public Shell refusals until a trusted caller creates a new internal Session. A general internal administration API, wider private-agent support and migration of such records are outside this slice.
|
||||
87
docs/design/2026-10-11-internal-team-lead-admission.zh-CN.md
Normal file
87
docs/design/2026-10-11-internal-team-lead-admission.zh-CN.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# 内部 TeamLead 准入与公开前台 Shell 并存
|
||||
|
||||
[English](2026-10-11-internal-team-lead-admission.md) | [简体中文](2026-10-11-internal-team-lead-admission.zh-CN.md)
|
||||
|
||||
状态:已在 PR #13674 实现,定向本地验证进行中,Linux/MySQL 验收待完成。作者选择保留公开 Shell 边界,并为内部 TeamLead 提供独立准入。Store 探针通过不代表物理 TeamLead 验收。
|
||||
|
||||
## 问题与现状
|
||||
|
||||
公开前台 Shell 通过默认关闭的部署开关及询问审批开放 `hosted-workspace-shell/1`。其公开 Session 抑制 child agent,因而没有 team 工具,并排除公开生命周期。main 新增的 H4e-b1 进程夹具创建空内置 Session,用 SQL 把 profile 改为 Shell/1,再使用公开输入、Action 和 close。它只开启 Workspace files,两项用例继承 `yolo`。当前持久化数据无法区分这个内部验收环境与公开 Shell。
|
||||
|
||||
在 `da4679f3e7242bbee91e0acbf4734578716c1600`,三项 TeamLead MySQL 用例均在首个 Turn 前失败:实际 HTTP409 `workspace_unavailable`,预期202。精确 PR/main 的生产 Store 探针用同样绑定复现差异,拒绝时无新增 Turn。仅开启 Shell 开关只解决第一道守卫;询问审批、child suppression 和公开 close 仍不兼容。
|
||||
|
||||
## 目标与范围
|
||||
|
||||
保留公开 Shell 策略,恢复明确且可评审的内部 TeamLead 创建路径。身份必须经持久化、重放、child 创建、冷 attachment 与恢复保留。公开 REST/WebShell 创建及存储 AgentDefinition 内容不得取得该身份。内部执行仍受既有 Workspace 授权和 Runtime authority 约束,不新增授权绕过。
|
||||
|
||||
本次不新增 HTTP 路由、公开 DTO 字段、部署开关或数据库列。不实现公开 TeamLead、公开 Shell 生命周期、新的后台 Shell/Monitor 准入、files/1 之外的 Workspace 迁移,或在 macOS 上认证 Linux 物理恢复。
|
||||
|
||||
## 准入身份
|
||||
|
||||
在已有 `tool_profile` 列保留 `hosted-workspace-team-lead/1`。profile 已固定执行策略且被复制到 child 行,再增加独立可变 origin 位会重复表达同一身份。Java 策略谓词继续把 `hosted-workspace-shell/1` 和 `/2` 定义为公开 Shell 家族。认证后的私有 Harness 把内部 profile 识别为 Shell 执行路径,保留既有 child/team 能力,不获得 `/2` search。
|
||||
|
||||
| 属性 | 公开前台 Shell | 内部 TeamLead |
|
||||
| ------------------ | ----------------------------------- | ------------------------------------------------------------ |
|
||||
| 创建 | 既有公开内置/定义准入 | 仅可信 Java Store 入口,固定内置 `qwen-code` |
|
||||
| 持久化 profile | Shell/1;防御性守卫覆盖既有 Shell/2 | `hosted-workspace-team-lead/1` |
|
||||
| 公开 Shell 开关 | 新 Shell 工作必须开启 | 不控制此私有 profile;仍要求 Workspace files 和执行前提 |
|
||||
| 审批 | `default` 或 `auto-edit` | 既有私有配置模式,包含 `yolo` |
|
||||
| child/team 工具 | 抑制 | 遵循既有 lead/child lineage 规则 |
|
||||
| Action 响应 | 仅记录 owner | 既有私有 Workspace Action 授权 |
|
||||
| close | 公开拒绝 | 既有 owner/operator 授权的 protocol-0 close 与 child cascade |
|
||||
| 公开创建或定义覆盖 | 不得选择内部身份 | 不开放 |
|
||||
|
||||
“内部”描述可信创建来源和固定执行策略,并不对已获得访问授权的 actor 隐藏 Session:既有公开输入、Action 与 close 路由可以在正常 tenant、Workspace、owner 和生命周期检查下操作该内部创建的 Session。这些路由不能修改 profile 或创建另一个内部根 Session。
|
||||
|
||||
部署校验不变:开启公开 Shell 时仍拒绝部署级 `yolo` 模式。内部 Session 可以
|
||||
在公开 Shell 关闭时固定 `yolo`,在后续部署以询问模式默认值开启公开 Shell 时
|
||||
保留原绑定。内部准入不会绕过启动校验。
|
||||
|
||||
## 可信创建与重放
|
||||
|
||||
增加 `ManagedAgentStore.insertInternalTeamLeadSessionCommand`,只进行空创建。接收既有 tenant、actor、幂等键/digest、title 和 Workspace selection,由入口自身固定内置身份、当前 revision、私有配置审批及保留 profile。公开请求或定义不会被转发到这个方法。H4e-b1 夹具成为具体调用方,删除 SQL profile 重写。
|
||||
|
||||
新内部创建要求 Workspace files 开启、创建者具备创建授权、解析的 registry 为 ACTIVE、执行 config/policy ref 固定、存在准入本地 mount,且 migration state 开放。保留既有 tenant migration lock 及新工作之前的重放顺序。profile、审批、binding、creator/owner 与创建命令一并提交。不得用 UPDATE 把已有公开 Session 转为内部身份。
|
||||
|
||||
创建命令键按 actor 和 tenant 共享。即使 request digest 相同,公开重放也必须拒绝内部记录,内部重放必须拒绝公开记录。匹配的内部重试返回原绑定,不重新解析部署策略。在正常重放与公开 DuplicateKey fallback 中均检查类型,不匹配返回 `409 idempotency_conflict`。
|
||||
|
||||
## 执行、attachment 与生命周期
|
||||
|
||||
Java 谓词明确识别公开 Shell。开关、强制询问审批、child suppression、Shell Action 的 owner-only 规则与公开生命周期排除继续约束这个家族。内部 profile 遵循既有私有行为;registry facts、Workspace access、mount 验证、选定 Runtime、worker fencing 和失败语义保持一致。
|
||||
|
||||
Connector 在四处 create/load/冷 lifecycle/rename attachment 路径传递内部 profile,不为其设置公开 Shell suppression。child 插入已复制父 Session 的 profile 与审批,保留这一不可变继承。Harness 在实时准入与存储定义恢复中使用共享 profile 词汇表。未知 profile 仍拒绝,旧 reader 不得把新标识解释为 files。使用可信创建入口前先部署兼容 Harness。
|
||||
|
||||
内部 close 使用既有 protocol-0 路径。protocol-1 生命周期及 receipt 仍只支持 files/1。离线 Workspace 迁移也仍只支持 files/1;新增私有执行路径不得扩大这两类验收门禁。
|
||||
|
||||
## 公开与定义边界
|
||||
|
||||
公开内置创建仍根据既有开关选择 files 或公开 Shell。公开 DTO 不增加内部 profile 选择器。`AgentDefinitionCompiler` 必须硬拒绝保留标识,即使 operator 将它加入部署 allowlist:当前 allowlist 能接受任意 profile 字符串,不能建立内部创建来源。
|
||||
|
||||
不需要改变 OpenAPI 结构或生成公开类型。公开 `foreground_shell` 继续描述公开 Shell 家族的能力,既有授权操作 capabilities 描述内部创建记录。保留公开 Shell 负对照,包括初始输入、关闭发送、owner-only Action、child suppression 与生命周期拒绝。
|
||||
|
||||
## 受影响层
|
||||
|
||||
| 层 | 修改或已确认消费方 |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Java Store/profile 词汇表 | 保留 profile、可信空创建、重放类型检查;既有 Session mapper/INSERT 与 child INSERT SELECT 传递绑定 |
|
||||
| Java definition compiler | 硬拒绝保留 profile |
|
||||
| Java service、Action Store、Connector | 明确公开 Shell 谓词;保留既有私有授权及 attachment 行为 |
|
||||
| CLI profile 词汇表 | 在准入/恢复识别私有内部 Shell 执行;不增加 search profile 或公开覆盖 |
|
||||
| H4e-b1 夹具 | 通过可信创建替代 SQL 修改;保留三项原断言及 Linux/MySQL lane |
|
||||
| 设计/测试文档 | 两种语言同步,区分现状与已验证证据 |
|
||||
|
||||
## 验证计划与验收标准
|
||||
|
||||
使用全局 CLI dry-run 基线,记录其实际 Managed Gateway 限制。进程验证前执行本地 CLI build/typecheck/bundle。运行 package-local 定向测试、Java 编译/静态检查与两轮干净完整 diff 自审,再进行独立代码评审。
|
||||
|
||||
验证公开 Shell 开关开启或关闭时,内部创建/重放均保留保留 profile 和审批。公开/内部幂等键碰撞在双向均应拒绝且无新行。无效创建者、缺失 mount、files 关闭、registry 不匹配或 migration 关闭,均在创建前拒绝。公开和定义选择内部标识的尝试不得产生内部行。child 继承私有绑定,冷 Connector 请求保留该绑定且 suppression 为 false。CLI 对照必须声明内部 agent/team 工具,同时公开 Shell suppression 继续有效。
|
||||
|
||||
在 CI 的 Linux/MySQL8.4 上运行原三项 `HostedTeamLeadMySqlIT`:两个命名成员及 task/带标签结果流程、lead close 等待成员,以及询问审批卡片。不得跳过或削弱这些用例。本地重跑公开 Shell Allow/Deny 进程对照。本地非 durable/test-script fallback 可以证明较窄行为,不能认证 Linux durable close 或 MySQL receipt 故障门禁。既有本地补充 CLI661/663 失败独立保留,除非直接修复并验证。
|
||||
|
||||
只有新 head 的进程门禁通过,才消除该阻塞。线程解决与人类 approval 仍是独立合并要求。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
主要风险是公开输入或定义意外取得保留的内部策略。固定 Store 创建、显式 compiler 拒绝与重放类型检查构成边界;调用方意图或夹具注释不是 authority。另一风险是在冷加载或 child 创建中丢失绑定,由既有持久化 profile 链和定向对照覆盖。
|
||||
|
||||
已有 SQL 修改的 Shell 验收记录不会自动取得新私有 profile。在可信调用方创建新的内部 Session 前,它们仍遵循公开 Shell 拒绝。通用内部管理 API、更广的私有 agent 支持和此类记录迁移不属于本次范围。
|
||||
213
docs/design/public-hosted-shell-admission.md
Normal file
213
docs/design/public-hosted-shell-admission.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# Public foreground Shell admission for Hosted Workspace Sessions
|
||||
|
||||
[English](public-hosted-shell-admission.md) | [简体中文](public-hosted-shell-admission.zh-CN.md)
|
||||
|
||||
## Status and problem
|
||||
|
||||
Implementation design for #13271, based on main bb66a52c. The Harness already
|
||||
supports persisted `hosted-workspace-shell/1` and mandatory Shell approval.
|
||||
Public REST and WebShell creation still pin `hosted-workspace-files/1`; callers
|
||||
cannot opt into the foreground Shell path. This change exposes that existing
|
||||
path through deployment admission, without completing G3 Step 3 or Shell L4.
|
||||
|
||||
## Scope and decisions
|
||||
|
||||
The deployment flag `qwen.managed-agent.harness.workspace-shell-enabled`
|
||||
(`QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED`) defaults to false. Enabling it
|
||||
requires Workspace files and all their existing trusted local-process,
|
||||
Session-isolated Broker, mount, Session Store and Harness prerequisites.
|
||||
Approval mode must be `default` or `auto-edit`; `yolo`, missing, blank and
|
||||
unknown modes fail startup. This deployment mode is persisted for built-in
|
||||
`qwen-code` Workspace Sessions.
|
||||
|
||||
New Workspace-bound Sessions of the built-in `qwen-code` agent, including empty
|
||||
creation and creation with input, pin `hosted-workspace-shell/1` when enabled,
|
||||
otherwise files/1. Unbound Sessions
|
||||
and existing files Sessions retain their behavior. No caller profile selector,
|
||||
metadata override, schema migration, public /2 search profile, background Shell,
|
||||
Monitor or new credential mechanism is added.
|
||||
|
||||
Stored AgentDefinition Sessions retain the profile and approval mode compiled
|
||||
from their pinned revision and the deployment allowlist. The Shell flag changes
|
||||
only the built-in agent's default profile selection; it does not override a
|
||||
stored definition. Every Shell Session still requires the Shell flag and valid
|
||||
asking approval for fresh execution. Harness load requests retain both the
|
||||
AgentDefinition identity and `suppressChildAgents` through child-Workspace,
|
||||
lifecycle and stopped-message copies.
|
||||
|
||||
The inherited Shell/1 declaration includes Monitor, but public creation does
|
||||
not configure capture bytes or the H3 publication lane. Existing admission
|
||||
rejects Monitor and background commands before approval or Broker execution.
|
||||
Real public tests exercise both refusals and assert zero side effects; the
|
||||
private H3 tool family is unchanged.
|
||||
|
||||
Public Shell create and load set `suppressChildAgents`. The Harness then does
|
||||
not attach the child-agent orchestrator, so the public tool list stays the
|
||||
file tools plus foreground `run_shell_command` and Monitor. Session messages
|
||||
share that orchestrator's write chain, so the same lane has no message funnel.
|
||||
A Shell lane that omits the flag keeps the child agent tool.
|
||||
|
||||
PR #13674 also separates the existing internal TeamLead rig with the reserved
|
||||
`hosted-workspace-team-lead/1` profile and a trusted Java-only empty-creation
|
||||
entry. Public create and AgentDefinition cannot select this identity, including
|
||||
through idempotency replay. These public Shell rules still apply only to
|
||||
Shell/1 and /2. Authorized input, Actions and protocol-0 close on an internally
|
||||
created Session retain their private policy; opening public foreground Shell
|
||||
does not expose team tools. See the [internal admission design](2026-10-11-internal-team-lead-admission.md)
|
||||
for the persisted boundary and pending Linux/MySQL process acceptance.
|
||||
|
||||
## Admission and persistence
|
||||
|
||||
Profile and approval mode are written in the existing creation transaction.
|
||||
They are server decisions, not inputs to the client request digest. Creation
|
||||
replay returns the original Session across a deployment flag change.
|
||||
|
||||
The V53 actor-role storage merged from main fbde5cf0 initializes the Session's
|
||||
creator and owner from the same authenticated actor key in that transaction,
|
||||
for Shell and files creation. Shell profile selection preserves both identity
|
||||
columns and the upstream role-based grants; this slice adds no handover or
|
||||
role-management operation.
|
||||
|
||||
With Shell disabled, existing Shell Sessions reject fresh Turns with
|
||||
`409 workspace_unavailable`. Authorization still runs first. Both the service
|
||||
replay probe and the locked Store command probe precede the new Shell gate;
|
||||
original idempotency keys replay without accepting new work. The Store gate
|
||||
is authoritative. Disabling only the Shell flag preserves accepted work,
|
||||
approval decisions, cancellation and settlement while Workspace files remain
|
||||
enabled and persisted approval remains `default` or `auto-edit`. Files-off or
|
||||
invalid approval can refuse attachment; the shared Connector does not bypass
|
||||
those guards. Controlled W2 cwd changes do not execute Shell and retain their
|
||||
own files, actor, path and context-revision admission. The Shell flag is not a
|
||||
general Session freeze.
|
||||
|
||||
## Persisted approval and recovery
|
||||
|
||||
Before returning a cached attachment or making a create/load/recovery request,
|
||||
the Connector validates that a persisted Shell profile has `default` or
|
||||
`auto-edit` approval mode. Invalid modes never silently become YOLO. Harness
|
||||
confirmation still must match the stored mode. Existing file YOLO behavior is
|
||||
unchanged. The helper recognizes existing Shell /2 records defensively but
|
||||
public creation emits only /1.
|
||||
|
||||
Recovery reuses current G3 behavior. Requested approvals remain requested on a
|
||||
plain owner reattach. Durable allow/deny remains durable. Recovery may refuse
|
||||
at `model_start` or `shell_in_flight` with the existing recovery-blocked reason;
|
||||
no Shell is automatically replayed and no physical Workspace fence is cleared
|
||||
without verified recovery. This change does not promise full owner takeover.
|
||||
|
||||
## Capabilities and lifecycle
|
||||
|
||||
Add optional public `foreground_shell` and WebShell `foregroundShell`. Files
|
||||
and unbound responses omit them, without a schema default. Stored Shell Sessions return true only when
|
||||
mandatory approval and deployment admission are valid; disabled Shell Sessions
|
||||
return false. This explicit false is necessary to distinguish an existing Shell
|
||||
Session from a files Session without exposing a caller-selectable profile.
|
||||
`workspaceTurns` expresses the OPERATOR submit and rename capability under the
|
||||
Session's creator-keyed execution facts. It can remain true when Shell
|
||||
admission is off and is not sufficient Turn admission. Shell fresh sending
|
||||
also requires `foregroundShell=true`. Disabling only Shell admission retains
|
||||
accepted cancellation under the prerequisites above; false caused by files-off
|
||||
or invalid approval does not guarantee cancellation. The `actions` capability
|
||||
likewise does not override attachment or response guards. Cancellation admits
|
||||
an OPERATOR caller on the Session's executable shape and remains independent
|
||||
of fresh-creation grants. Its dedicated cached attachment path performs the
|
||||
same persisted Shell approval validation. Older clients remain protected by
|
||||
the transactional admission gate.
|
||||
|
||||
Shell close, archive, unarchive and delete capabilities are false. Fresh public
|
||||
lifecycle requests, including protocol-0 close, are rejected before an
|
||||
operation or command is created. The internal child close uses the 8-argument
|
||||
`beginWorkspaceLifecycle` overload and is not subject to that Shell refusal.
|
||||
Public Shell parents stay uncloseable. The refusal includes seeded
|
||||
CLOSED/ARCHIVED Shell records. Existing command replay keeps
|
||||
its original result. Existing non-Shell lifecycle declarations and L2 behavior are unchanged;
|
||||
this does not certify files/2 L3 support. Turn cancellation is distinct from
|
||||
Session lifecycle and retains its authorization, attachment and recovery guards.
|
||||
|
||||
## Changes by component
|
||||
|
||||
| Component | Change |
|
||||
| -------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| ManagedAgentProperties and application.yml | Default-off flag and startup prerequisites |
|
||||
| ManagedAgentStore / AgentStateStore | Creation profile, fresh Turn gate, lifecycle exclusion, flag accessor |
|
||||
| ManagedAgentService | Persisted-profile capability projection and Shell lifecycle exclusion |
|
||||
| QwenHostedHarnessConnector | Mandatory approval checks before cache or RPC use |
|
||||
| ApiModels / OpenAPI / generated WebShell API | Additive optional Shell capability |
|
||||
| Java WebShell provider | Separate fresh send from accepted cancellation |
|
||||
| Unit and Hosted public integration tests | Admission, replay, approval, lifecycle and real-process coverage |
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
Configuration tests cover default-off, missing prerequisites and all approval
|
||||
modes. SQL-backed admission tests cover both surfaces, empty/initial creation,
|
||||
frozen metadata and profile, flag changes, replay, fresh rejection, ACLs and
|
||||
zero lifecycle operations. Empty and initial Shell creation in both approval
|
||||
modes, plus flag-off files creation, must persist the exact actor key in both
|
||||
creator and owner columns. Connector tests exercise create/load/cached/recovery
|
||||
validation with zero Harness calls for invalid modes and file YOLO controls.
|
||||
WebShell adapter tests prove disabled sending retains active cancellation.
|
||||
Both wire surfaces pin explicit false for flag-off Shell and omission for files
|
||||
and unbound Sessions. Lifecycle negatives use close-capable Runtime support and
|
||||
a completed files close receipt before switching the stored profile to Shell,
|
||||
so a missing Shell exclusion cannot pass through an unavailable Runtime fixture.
|
||||
Connector coverage includes cold and cached action responses with Shell disabled
|
||||
and rejects invalid persisted approval before any Harness call. Cancellation
|
||||
asserts its durable CANCELLING state and coordinator dispatch. A requested Action
|
||||
on a bound Shell/default Session with files enabled and Shell disabled must commit
|
||||
a matching decision receipt, complete the response operation and replay across
|
||||
surfaces after a lost answer with one delivery. This H2/MockMvc test uses a
|
||||
simulated Harness decision and does not prove real Shell execution or a deployment
|
||||
restart.
|
||||
|
||||
Defensive projection tests cover invalid non-null persisted approval and a
|
||||
manually constructed files-off store; SQL forbids null approval, and a validated
|
||||
deployment forbids Shell-on/files-off. Synthetic persisted Shell/2 rows exercise
|
||||
the existing exclusion predicate, without adding public Shell/2 creation.
|
||||
Lifecycle projections also retain the close-capable positive control with Shell
|
||||
enabled. Cached stream/rename reject invalid approval before Harness access;
|
||||
Shell/1 retains the non-executing cwd admission and replay contract.
|
||||
|
||||
The public integration fixture must fail immediately on one-shot Action response
|
||||
assertions. Receipt evidence waits for the attached Harness prompt to become
|
||||
inactive and recovery-blocked, and for all three receipt handlers to finish,
|
||||
before checking exact attempts and asynchronous failure channels. Public prefix
|
||||
checks retain their first failure and rethrow it. Allow stdout must reach the
|
||||
model, and Allow/Deny side effects are rechecked after replay. Teardown runs all
|
||||
cleanup steps even when the output probe fails, retaining the original failure.
|
||||
|
||||
Real Java/Broker/Session Store/Harness tests must prove public Shell Allow has
|
||||
one side effect, Deny has none, and lost response/retry does not execute twice.
|
||||
Cold attachment must preserve profile and approval. FG6f publisher/receipt
|
||||
failure tests require real worker and SQL evidence; macOS substitutes or H2
|
||||
cannot certify Linux physical quiescence. Build, bundle, typecheck, focused
|
||||
package tests, Java packaging and Checkstyle, two clean self-audits and an
|
||||
independent review precede completion. Results live in
|
||||
`.qwen/e2e-tests/public-hosted-shell-admission.md`.
|
||||
|
||||
Historical validation at `9c5e2817`, after merging main bb66a52c, passed 181 focused Java tests, 30
|
||||
WebShell adapter tests, 341 CLI Harness/recovery tests and six real
|
||||
MySQL/Broker/Harness public integration cases. The receipt failure and
|
||||
committed-receipt/lost-reply cases each retain one dispatch and one side effect,
|
||||
with byte-identical receipt retries and verified capture resources. An independent
|
||||
flag-off/cold probe verifies Store/Service admission and the live Connector,
|
||||
including accepted approval/cancellation and a new Harness after natural writer
|
||||
lease expiry. This probe does not restart the complete Java deployment. Linux
|
||||
publisher/worker-kill physical gates remain unverified and keep rollout gated.
|
||||
|
||||
## Rollout, limits and open questions
|
||||
|
||||
Keep the flag off until the deployment has evidence for #12904, #13010 and the
|
||||
public FG6f gates. This feature does not fix those independent issues, add full
|
||||
Shell lifecycle, or bypass a recovery refusal. Deploy all Session readers before
|
||||
creating Shell Sessions; an older binary must not reinterpret the stored
|
||||
profile as files. Upgrade the Harness before enabling the flag. An older
|
||||
Harness ignores `suppressChildAgents` and would advertise the child agent
|
||||
tool. Shell Action responses admit only the Session's recorded owner, not
|
||||
every Workspace operator, because the answer runs a command. The implementation choices for this slice are set; maintainer
|
||||
review of the public contract and rollout sequencing remains required. Missing
|
||||
physical test infrastructure is reported as an unverified gate, never a pass.
|
||||
|
||||
W1c offline Workspace migration remains files/1-only. A persisted Shell
|
||||
definition causes `migration_profile_unsupported` for its storage, so plan any
|
||||
W1c migration before creating Shell Sessions there. This slice does not widen
|
||||
the migration guard or add Shell migration support.
|
||||
165
docs/design/public-hosted-shell-admission.zh-CN.md
Normal file
165
docs/design/public-hosted-shell-admission.zh-CN.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# Hosted Workspace Session 公开前台 Shell 准入
|
||||
|
||||
[English](public-hosted-shell-admission.md) | [简体中文](public-hosted-shell-admission.zh-CN.md)
|
||||
|
||||
## 状态与问题
|
||||
|
||||
本设计实现 #13271,基于 main bb66a52c。Harness 已支持持久化
|
||||
`hosted-workspace-shell/1` 与 Shell 强制审批,但公开 REST 和 WebShell
|
||||
创建仍固定使用 `hosted-workspace-files/1`,无法进入前台 Shell 路径。
|
||||
本次通过部署准入开放已有能力,不完成 G3 Step 3 或 Shell L4。
|
||||
|
||||
## 范围与决策
|
||||
|
||||
部署开关 `qwen.managed-agent.harness.workspace-shell-enabled`
|
||||
(`QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED`)默认 false。启用时要求
|
||||
Workspace files 已开启,并满足其已有可信 local-process、Session 隔离 Broker、
|
||||
挂载、Session Store 与 Harness 条件。审批模式必须为 `default` 或 `auto-edit`;
|
||||
`yolo`、缺失、空白及未知模式在启动时拒绝。内置 `qwen-code` 的 Workspace Session
|
||||
持久化这个部署审批模式。
|
||||
|
||||
开关开启时,内置 `qwen-code` agent 的新 Workspace Session(空创建和带输入创建)固定使用
|
||||
`hosted-workspace-shell/1`,否则使用 files/1。非绑定 Session 与既有 files
|
||||
Session 行为不变。不添加调用者 profile 选择、metadata 覆盖、数据库迁移、公开
|
||||
/2 搜索 profile、后台 Shell、Monitor 或新凭证机制。
|
||||
|
||||
绑定已存储 AgentDefinition 的 Session 保留其固定 revision 和部署 allowlist 编译出的
|
||||
profile 与审批模式。Shell 开关只改变内置 agent 的默认 profile 选择,不覆盖已存储
|
||||
定义。所有 Shell Session 的新执行仍要求 Shell 开关开启和有效的询问审批模式。
|
||||
Harness load 在 child-Workspace、生命周期和停止消息的复制路径中,同时保留
|
||||
AgentDefinition identity 与 `suppressChildAgents`。
|
||||
|
||||
已有 Shell/1 工具声明含 Monitor,但公开创建不配置 capture bytes 或 H3 publication
|
||||
通道。既有准入在审批和 Broker 执行前拒绝 Monitor 与后台命令。真实公开测试主动
|
||||
尝试两种请求并断言零副作用;私有 H3 工具族保持不变。
|
||||
|
||||
公开 Shell 的创建和加载会设置 `suppressChildAgents`。Harness 因此不挂 child-agent
|
||||
编排器,公开工具列表保持文件工具、前台 `run_shell_command` 和 Monitor。Session
|
||||
消息与该编排器共用同一条写链,因此这条车道也没有消息漏斗。未带该标志的 Shell
|
||||
车道仍声明 child agent 工具。
|
||||
|
||||
PR #13674 同时用保留的 `hosted-workspace-team-lead/1` profile 和可信 Java 空创建
|
||||
入口,区分已有内部 TeamLead 环境。公开创建与 AgentDefinition 不能选择这个身份,
|
||||
幂等重放也不能跨越该边界。上述公开 Shell 规则仍只适用于 Shell/1 和 /2。
|
||||
在可信创建的内部 Session 上,按权限提交输入、回答 Actions 和 protocol-0 关闭
|
||||
保留内部策略;开放公开前台 Shell 不会开放团队工具。持久化边界与待完成的
|
||||
Linux/MySQL 进程验收见[内部准入设计](2026-10-11-internal-team-lead-admission.zh-CN.md)。
|
||||
|
||||
## 准入与持久化
|
||||
|
||||
profile 与审批模式写入已有创建事务。它们是服务端决策,不纳入客户端请求摘要。
|
||||
部署开关变化后,创建重放仍返回原 Session。
|
||||
|
||||
从 main fbde5cf0 合入的 V53 actor-role 存储在同一事务中,将 Session 的 creator
|
||||
与 owner 初始化为同一已认证 actor key,Shell 和 files 创建均如此。选择 Shell
|
||||
profile 时保留两个身份列和上游基于 role 的授权;本切片不增加交接或角色管理操作。
|
||||
|
||||
关闭 Shell 后,既有 Shell Session 的新 Turn 返回 `409 workspace_unavailable`。
|
||||
先执行授权,再进行 Service 重放探测;持有 Session 行锁的 Store 也先探测旧命令,
|
||||
再执行 Shell 新准入门禁。原幂等键可以重放,不接受新工作,Store 为最终准入权威。
|
||||
仅关闭 Shell 开关时,只要 Workspace files 仍开启且持久化审批仍为 `default` 或
|
||||
`auto-edit`,已接受回合的审批、取消与收尾仍可运行。files-off 或无效审批可以拒绝
|
||||
attachment,共用 Connector 不绕过这些守卫。W2 cwd 变更不执行 Shell,保留其已有
|
||||
files、actor、路径及 context revision 准入;Shell 开关不代表整个 Session 冻结。
|
||||
|
||||
## 持久化审批与恢复
|
||||
|
||||
返回缓存 attachment 或发出 create/load/recovery 请求前,Connector 检查持久化
|
||||
Shell profile 的审批模式为 `default` 或 `auto-edit`。无效模式不得静默成为 YOLO。
|
||||
Harness 确认值仍须匹配存储值。已有 files 的 YOLO 行为不变。助手同时识别既有
|
||||
Shell /2 记录,但公开创建只生成 /1。
|
||||
|
||||
恢复复用当前 G3:普通 owner 重新 attachment 保持 requested 审批,持久化
|
||||
allow/deny 继续有效。恢复可在 `model_start` 或 `shell_in_flight` 按既有原因拒绝;
|
||||
不得自动重复 Shell,也不得未经验证清除物理 Workspace fence。本次不承诺完整
|
||||
owner takeover。
|
||||
|
||||
## 能力与生命周期
|
||||
|
||||
增加可选公开字段 `foreground_shell` 与 WebShell 字段 `foregroundShell`。
|
||||
files 和非绑定响应省略该字段,schema 不设置默认值。持久化 Shell Session 仅在强制审批及部署准入有效时
|
||||
返回 true,禁用时返回 false。显式 false 让客户端区分既有 Shell 与 files Session,
|
||||
无需开放 profile 选择。`workspaceTurns` 表达 OPERATOR 的提交和重命名能力,并要求
|
||||
Session 的 creator-keyed 执行事实仍成立。关闭 Shell 准入时它仍可为 true,不能单独
|
||||
证明 Turn 可提交。Shell 新发送还要求 `foregroundShell=true`。仅关闭 Shell 时,满足
|
||||
上述前提的已接受取消仍受支持;files-off 或无效审批导致的 false 不保证取消可用。
|
||||
`actions` 能力同样不覆盖 attachment 或响应守卫。取消由持有 OPERATOR、且 Session
|
||||
形状可执行的调用者发起,与新创建授权分开。其独立缓存 attachment 路径执行同一
|
||||
持久化 Shell 审批校验。旧客户端由事务内门禁保护。
|
||||
|
||||
Shell 的 close、archive、unarchive、delete 能力均为 false。新的公开生命周期请求,
|
||||
包括 protocol-0 关闭,在创建 operation 或 command 前拒绝,包含人工构造的
|
||||
CLOSED/ARCHIVED Shell 记录。内部 child 关闭走 8 参 `beginWorkspaceLifecycle`,
|
||||
不受这道 Shell 拒绝约束。公开 Shell 父会话仍然不能关闭。既有命令重放保留原结果。
|
||||
非 Shell 生命周期声明与 L2 行为不变,不据此证明
|
||||
files/2 的 L3 支持。Turn 取消与 Session 生命周期分开,保留其授权、attachment 和
|
||||
恢复守卫。
|
||||
|
||||
## 分层改动
|
||||
|
||||
| 组件 | 改动 |
|
||||
| ----------------------------------------- | -------------------------------------------------- |
|
||||
| ManagedAgentProperties 与 application.yml | 默认关闭开关与启动前提 |
|
||||
| ManagedAgentStore / AgentStateStore | 创建 profile、新 Turn 门禁、生命周期排除及开关读取 |
|
||||
| ManagedAgentService | 按持久化 profile 投影能力及排除 Shell 生命周期 |
|
||||
| QwenHostedHarnessConnector | 缓存或 RPC 前强制审批检查 |
|
||||
| ApiModels / OpenAPI / 生成 WebShell API | 可选 Shell 能力字段 |
|
||||
| Java WebShell provider | 区分新发送与既有取消 |
|
||||
| 单元与 Hosted 公开集成测试 | 准入、重放、审批、生命周期与真实进程覆盖 |
|
||||
|
||||
## 验证与验收
|
||||
|
||||
配置测试覆盖默认关闭、缺失前提及所有审批模式。SQL 准入测试覆盖两个表面、空/带
|
||||
输入创建、metadata/profile 冻结、开关变化、重放、新请求拒绝、ACL 及零生命周期
|
||||
operation。两种审批模式下的空/带输入 Shell 创建,以及关开关后的 files 创建,
|
||||
均须在 creator 和 owner 两列持久化精确 actor key。Connector 覆盖 create/load/缓存/recovery 的校验,无效模式的 Harness
|
||||
调用为零,并保留 files YOLO 对照。WebShell adapter 证明禁用发送仍可取消活动回合。
|
||||
两种 wire 响应断言关 Shell 时显式 false,files 和非绑定时省略字段。生命周期负例
|
||||
先取得 files 成功关闭回执并提供支持关闭的 Runtime,再将持久化 profile 改为 Shell,
|
||||
避免因 Runtime 不可用而使遗漏 Shell 排除的错误仍通过测试。Connector 覆盖关 Shell
|
||||
时冷和缓存 attachment 的审批响应,无效持久化审批在任何 Harness 调用前拒绝。
|
||||
取消测试断言持久化 CANCELLING 状态及 coordinator 派发。绑定 Shell/default 的
|
||||
Session 在 files 开启、Shell 关闭时,requested Action 必须提交匹配的 decision
|
||||
receipt、完成 response operation,并在丢失响应后跨表面重放且仅一次 delivery。
|
||||
该 H2/MockMvc 测试模拟 Harness 决策,不证明真实 Shell 执行或完整部署重启。
|
||||
|
||||
防御性能力投影测试覆盖非 null 的无效持久化审批和手工构造的 files 关闭 store;
|
||||
SQL 不允许 null 审批,经校验的部署不允许 Shell 开启而 files 关闭。人工持久化
|
||||
Shell/2 行只验证已有排除谓词,不增加公开 Shell/2 创建。Shell 开启时的生命周期
|
||||
投影也保留支持关闭的正向对照。缓存 stream/rename 在访问 Harness 前拒绝无效
|
||||
审批;Shell/1 保留非执行 cwd 准入及重放契约。
|
||||
|
||||
公开集成夹具的一次性 Action 响应断言必须立即失败。Receipt 证据等待已挂接的
|
||||
Harness prompt 变为 inactive 且 recovery-blocked,并等待三个 receipt handler
|
||||
全部结束,再核对精确尝试次数和异步错误通道。公开 prefix 校验记录首个错误并
|
||||
继续抛出。Allow stdout 必须传回模型,重放之后再次核对 Allow/Deny 副作用。
|
||||
即使输出探针失败,拆除也执行全部清理步骤并保留原始错误。
|
||||
|
||||
真实 Java/Broker/Session Store/Harness 测试须证明公开 Shell Allow 仅一次副作用、
|
||||
Deny 零副作用、丢失响应与重试不重复执行。冷 attachment 保持 profile 和审批。
|
||||
FG6f publisher/receipt 故障要求真实 worker 与 SQL 证据;macOS 替代和 H2 不证明
|
||||
Linux 物理静默。完成前执行 build、bundle、typecheck、定向包测试、Java 打包和
|
||||
Checkstyle、两轮干净自审及独立审查。结果记录于
|
||||
`.qwen/e2e-tests/public-hosted-shell-admission.md`。
|
||||
|
||||
历史提交 `9c5e2817` 合入 main bb66a52c 后验证通过 181 项定向 Java 测试、30 项 WebShell adapter 测试、
|
||||
341 项 CLI Harness/恢复测试与六项真实 MySQL/Broker/Harness 公开集成测试。receipt 事务失败和已提交 receipt 丢失响应
|
||||
两种场景均保留一次 dispatch、一次副作用,receipt 重试字节一致,capture 资源
|
||||
完整验证。独立关开关/冷恢复探针验证 Store/Service 准入与实际 Connector,包含
|
||||
已接受审批/取消及等待 writer 租约自然过期后的新 Harness。该探针不重启完整 Java
|
||||
部署。Linux publisher/worker-kill 物理门禁仍未验证,发布继续受门禁约束。
|
||||
|
||||
## 发布、限制与开放问题
|
||||
|
||||
在部署具备 #12904、#13010 和公开 FG6f 证据前保持开关关闭。本功能不修复这些独立
|
||||
问题,不增加完整 Shell 生命周期,不绕过恢复拒绝。创建 Shell Session 前统一升级
|
||||
Session 读取端,旧二进制不得把持久化 profile 解释为 files。打开开关前先升级
|
||||
Harness:旧 Harness 会忽略 `suppressChildAgents`,工具列表会重新带上 child agent。
|
||||
Shell 审批只允许该 Session 记录上的 owner 回答,不允许任意 Workspace operator,
|
||||
因为回答会执行命令。本切片的实现选择已确定,
|
||||
公开契约与发布顺序仍需维护者审查。缺少物理测试基础设施时明确报告未验证门禁,
|
||||
不记为通过。
|
||||
|
||||
W1c 离线 Workspace 迁移仍只支持 files/1。持久化的 Shell definition 会使其 storage
|
||||
返回 `migration_profile_unsupported`,因此须在该 storage 创建 Shell Session 前
|
||||
规划 W1c 迁移。本切片不放宽迁移守卫,也不增加 Shell 迁移支持。
|
||||
|
|
@ -10873,7 +10873,11 @@ describe('Hosted Harness no-tool session', () => {
|
|||
await send('A', '3').expect(200);
|
||||
});
|
||||
|
||||
it.each(['hosted-workspace-files/1', 'hosted-workspace-shell/1'])(
|
||||
it.each([
|
||||
'hosted-workspace-files/1',
|
||||
'hosted-workspace-shell/1',
|
||||
'hosted-workspace-team-lead/1',
|
||||
])(
|
||||
'loads a renamed %s Session and verifies its retained title resources',
|
||||
async (toolProfile) => {
|
||||
const server = await app(true);
|
||||
|
|
@ -10922,7 +10926,7 @@ describe('Hosted Harness no-tool session', () => {
|
|||
damaged.mockRestore();
|
||||
const retry = await headers(
|
||||
supertest(server).post(`/session/${SESSION_ID}/load`),
|
||||
).send({ managedSessionStore: store() });
|
||||
).send({ managedSessionStore: store(), toolProfile });
|
||||
expect(retry.status).toBe(200);
|
||||
await headers(supertest(server).delete(`/session/${SESSION_ID}`)).set(
|
||||
'X-Qwen-Client-Id',
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ import {
|
|||
type HostedRuntimeRecoveryReport,
|
||||
} from './hosted-runtime-recovery.js';
|
||||
import { SessionTranscriptChangedError } from '@qwen-code/qwen-code-core/services/session-writer-lease.js';
|
||||
import { hostedShellLaneAttachesChildAgents } from './hosted-workspace-profiles.js';
|
||||
import {
|
||||
HOSTED_AGENT_CALL_NOT_ADMITTED_TEXT,
|
||||
HOSTED_AGENT_CALL_NOT_REACHED_TEXT,
|
||||
|
|
@ -3323,14 +3324,22 @@ export function registerHostedHarnessSessionRoutes(
|
|||
// H4b: the Session's own child orchestrator, on the Shell lanes
|
||||
// the notification wake is proven over — files profiles keep their
|
||||
// exact current surface (their child admission is its own gate).
|
||||
// Public Shell admission suppresses the child agent tool; a private
|
||||
// Shell lane that omits the flag keeps it. Team attachment stays.
|
||||
if (session.shell || session.backgroundLane) {
|
||||
session.childAgents = new HostedChildAgentSession(
|
||||
{
|
||||
authority: session.managed.authority,
|
||||
resources: session.managed.resources,
|
||||
},
|
||||
session.managed.authority.sessionHeader.sessionKey,
|
||||
);
|
||||
if (
|
||||
hostedShellLaneAttachesChildAgents(
|
||||
true,
|
||||
body?.['suppressChildAgents'] === true,
|
||||
)
|
||||
)
|
||||
session.childAgents = new HostedChildAgentSession(
|
||||
{
|
||||
authority: session.managed.authority,
|
||||
resources: session.managed.resources,
|
||||
},
|
||||
session.managed.authority.sessionHeader.sessionKey,
|
||||
);
|
||||
session.teams = new HostedTeamSession(
|
||||
{
|
||||
authority: session.managed.authority,
|
||||
|
|
@ -3338,24 +3347,26 @@ export function registerHostedHarnessSessionRoutes(
|
|||
},
|
||||
session.managed.authority.sessionHeader.sessionKey,
|
||||
);
|
||||
// H4d-b: the Session messages along its lineage on the same
|
||||
// writes chain; a child names its parent from its definition.
|
||||
session.messages = new HostedSessionMessageSession(
|
||||
{
|
||||
authority: session.managed.authority,
|
||||
resources: session.managed.resources,
|
||||
},
|
||||
session.managed.authority.sessionHeader.sessionKey,
|
||||
session.childAgents,
|
||||
session.childDepth !== undefined &&
|
||||
typeof savedLineage?.['parentSessionId'] === 'string' &&
|
||||
typeof savedLineage['parentChildRunId'] === 'string'
|
||||
? {
|
||||
parentSessionId: savedLineage['parentSessionId'],
|
||||
parentChildRunId: savedLineage['parentChildRunId'],
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
// H4d-b: messages share the child orchestrator's write chain, so
|
||||
// a Shell lane that suppressed the agent tool has no funnel.
|
||||
if (session.childAgents) {
|
||||
session.messages = new HostedSessionMessageSession(
|
||||
{
|
||||
authority: session.managed.authority,
|
||||
resources: session.managed.resources,
|
||||
},
|
||||
session.managed.authority.sessionHeader.sessionKey,
|
||||
session.childAgents,
|
||||
session.childDepth !== undefined &&
|
||||
typeof savedLineage?.['parentSessionId'] === 'string' &&
|
||||
typeof savedLineage['parentChildRunId'] === 'string'
|
||||
? {
|
||||
parentSessionId: savedLineage['parentSessionId'],
|
||||
parentChildRunId: savedLineage['parentChildRunId'],
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
25
packages/cli/src/serve/hosted-workspace-profiles.test.ts
Normal file
25
packages/cli/src/serve/hosted-workspace-profiles.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { expect, it } from 'vitest';
|
||||
import {
|
||||
HOSTED_WORKSPACE_TEAM_LEAD_PROFILE,
|
||||
isHostedWorkspaceProfile,
|
||||
isHostedWorkspaceShellProfile,
|
||||
isHostedWorkspaceSearchProfile,
|
||||
} from './hosted-workspace-profiles.js';
|
||||
|
||||
it('admits the saved internal TeamLead as Shell execution without search', () => {
|
||||
expect(isHostedWorkspaceProfile(HOSTED_WORKSPACE_TEAM_LEAD_PROFILE)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isHostedWorkspaceShellProfile(HOSTED_WORKSPACE_TEAM_LEAD_PROFILE),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHostedWorkspaceSearchProfile(HOSTED_WORKSPACE_TEAM_LEAD_PROFILE),
|
||||
).toBe(false);
|
||||
expect(isHostedWorkspaceProfile('hosted-workspace-team-lead/2')).toBe(false);
|
||||
});
|
||||
|
|
@ -12,12 +12,15 @@ export const HOSTED_WORKSPACE_FILE_PROFILE = 'hosted-workspace-files/1';
|
|||
export const HOSTED_WORKSPACE_SHELL_PROFILE = 'hosted-workspace-shell/1';
|
||||
export const HOSTED_WORKSPACE_FILE_PROFILE_V2 = 'hosted-workspace-files/2';
|
||||
export const HOSTED_WORKSPACE_SHELL_PROFILE_V2 = 'hosted-workspace-shell/2';
|
||||
export const HOSTED_WORKSPACE_TEAM_LEAD_PROFILE =
|
||||
'hosted-workspace-team-lead/1';
|
||||
|
||||
export type HostedWorkspaceToolProfile =
|
||||
| typeof HOSTED_WORKSPACE_FILE_PROFILE
|
||||
| typeof HOSTED_WORKSPACE_SHELL_PROFILE
|
||||
| typeof HOSTED_WORKSPACE_FILE_PROFILE_V2
|
||||
| typeof HOSTED_WORKSPACE_SHELL_PROFILE_V2;
|
||||
| typeof HOSTED_WORKSPACE_SHELL_PROFILE_V2
|
||||
| typeof HOSTED_WORKSPACE_TEAM_LEAD_PROFILE;
|
||||
|
||||
export function isHostedWorkspaceProfile(
|
||||
profile: unknown,
|
||||
|
|
@ -26,17 +29,27 @@ export function isHostedWorkspaceProfile(
|
|||
profile === HOSTED_WORKSPACE_FILE_PROFILE ||
|
||||
profile === HOSTED_WORKSPACE_SHELL_PROFILE ||
|
||||
profile === HOSTED_WORKSPACE_FILE_PROFILE_V2 ||
|
||||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2
|
||||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2 ||
|
||||
profile === HOSTED_WORKSPACE_TEAM_LEAD_PROFILE
|
||||
);
|
||||
}
|
||||
|
||||
export function isHostedWorkspaceShellProfile(profile: unknown): boolean {
|
||||
return (
|
||||
profile === HOSTED_WORKSPACE_SHELL_PROFILE ||
|
||||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2
|
||||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2 ||
|
||||
profile === HOSTED_WORKSPACE_TEAM_LEAD_PROFILE
|
||||
);
|
||||
}
|
||||
|
||||
/** Public Shell admission sets suppressChildAgents. Private Shell lanes omit it. */
|
||||
export function hostedShellLaneAttachesChildAgents(
|
||||
shellLane: boolean,
|
||||
suppressChildAgents: boolean,
|
||||
): boolean {
|
||||
return shellLane && !suppressChildAgents;
|
||||
}
|
||||
|
||||
export function isHostedWorkspaceSearchProfile(profile: unknown): boolean {
|
||||
return (
|
||||
profile === HOSTED_WORKSPACE_FILE_PROFILE_V2 ||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ import {
|
|||
} from './hosted-workspace-tool-turn.js';
|
||||
import { HostedChildAgentSession } from './hosted-child-agent-session.js';
|
||||
import { HostedWorkspaceBroker } from './hosted-workspace-broker.js';
|
||||
import {
|
||||
hostedShellLaneAttachesChildAgents,
|
||||
type HostedWorkspaceToolProfile,
|
||||
} from './hosted-workspace-profiles.js';
|
||||
|
||||
// H4b: the kind gate admits `child_agent` for real, so this suite runs
|
||||
// without an enablement mock. The Broker is mocked as in the sibling
|
||||
|
|
@ -111,7 +115,9 @@ function createTurn(
|
|||
promptName = 'prompt',
|
||||
hooksMountHeld = false,
|
||||
childWorkspaces = false,
|
||||
attachChildAgents = true,
|
||||
hooksSession?: HostedHookSession,
|
||||
profile: HostedWorkspaceToolProfile = 'hosted-workspace-team-lead/1',
|
||||
): HostedWorkspaceToolTurn {
|
||||
return new HostedWorkspaceToolTurn(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
|
|
@ -145,13 +151,17 @@ function createTurn(
|
|||
},
|
||||
undefined,
|
||||
{
|
||||
profile: 'hosted-workspace-shell/1',
|
||||
childAgents: {
|
||||
funnel: children,
|
||||
depth,
|
||||
childWorkspaces,
|
||||
queueConsumption: (childRunId) => consumption.push(childRunId),
|
||||
},
|
||||
profile,
|
||||
...(attachChildAgents
|
||||
? {
|
||||
childAgents: {
|
||||
funnel: children,
|
||||
depth,
|
||||
childWorkspaces,
|
||||
queueConsumption: (childRunId) => consumption.push(childRunId),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(hookEvents
|
||||
? {
|
||||
hooks: {
|
||||
|
|
@ -260,6 +270,26 @@ afterEach(async () => {
|
|||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('advertises child agents only on a shell lane that public admission has not suppressed', () => {
|
||||
expect(hostedShellLaneAttachesChildAgents(true, false)).toBe(true);
|
||||
expect(hostedShellLaneAttachesChildAgents(true, true)).toBe(false);
|
||||
expect(hostedShellLaneAttachesChildAgents(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the agent tool when public shell admission attaches no child orchestrator', async () => {
|
||||
const tools = await createTurn(
|
||||
0,
|
||||
undefined,
|
||||
'prompt',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
'hosted-workspace-shell/1',
|
||||
).declarations(new AbortController().signal);
|
||||
expect(tools.some((tool) => tool.name === 'agent')).toBe(false);
|
||||
});
|
||||
|
||||
it('declares the agent tool at the root and hides it from a child Session', async () => {
|
||||
const rootTurn = createTurn(0);
|
||||
const rootTools = await rootTurn.declarations(new AbortController().signal);
|
||||
|
|
@ -1408,7 +1438,7 @@ it.each(
|
|||
eventName,
|
||||
config: { type: 'command' },
|
||||
});
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, hooks);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, true, hooks);
|
||||
try {
|
||||
const result = await executeAgent(
|
||||
turn,
|
||||
|
|
@ -1442,7 +1472,7 @@ it('refuses a background child on a result Hook whose matcher cannot match agent
|
|||
config: { type: 'command' },
|
||||
matcher: 'read_file',
|
||||
});
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, hooks);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, true, hooks);
|
||||
try {
|
||||
expect(hooks.hasMountableToolResultHooks).toBe(true);
|
||||
const result = await executeAgent(
|
||||
|
|
@ -1509,7 +1539,7 @@ it.each([false, true])(
|
|||
session,
|
||||
original.getCatalog()!,
|
||||
);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, hooks);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, true, hooks);
|
||||
try {
|
||||
expect(original.hasMountableToolResultHooks).toBe(true);
|
||||
await original.fire(
|
||||
|
|
@ -1598,7 +1628,7 @@ it('refuses a child while a once Hook is committed but unsettled', async () => {
|
|||
session,
|
||||
catalog,
|
||||
);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, hooks);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, true, hooks);
|
||||
await hooks.ensureReady();
|
||||
expect(hooks.mountHeld).toBe(false);
|
||||
expect(hooks.hasUnsettledExecutions).toBe(true);
|
||||
|
|
@ -1627,7 +1657,7 @@ it.each(
|
|||
'admits result-Hook controls %# (background=$background)',
|
||||
async ({ descriptor, background }) => {
|
||||
const hooks = await restoredHooks(descriptor);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, hooks);
|
||||
const turn = createTurn(0, undefined, 'prompt', false, false, true, hooks);
|
||||
turn.setPromptHookRunner(async (hookConfig, eventName) => ({
|
||||
hookConfig,
|
||||
eventName,
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ function createTurn(
|
|||
{ resources: session.resources, assertWritable: async () => undefined },
|
||||
undefined,
|
||||
{
|
||||
profile: 'hosted-workspace-shell/1',
|
||||
profile: 'hosted-workspace-team-lead/1',
|
||||
childAgents: {
|
||||
funnel: children,
|
||||
depth: options.depth ?? 0,
|
||||
|
|
|
|||
|
|
@ -929,6 +929,7 @@ describe('migration profile evidence', () => {
|
|||
{ toolProfile: 'hosted-workspace-files/2' },
|
||||
{ toolProfile: 'hosted-workspace-shell/1' },
|
||||
{ toolProfile: 'hosted-workspace-shell/2' },
|
||||
{ toolProfile: 'hosted-workspace-team-lead/1' },
|
||||
{ toolProfile: 'hosted-workspace-files/1', hookCatalog: {} },
|
||||
{ toolProfile: 'hosted-workspace-files/1', captureBytes: 1024 },
|
||||
])('rejects unsupported frozen definition %j', async (definition) => {
|
||||
|
|
@ -1604,6 +1605,7 @@ describe('verifyRecoverySession', () => {
|
|||
{ toolProfile: 'hosted-workspace-files/2' },
|
||||
{ toolProfile: 'hosted-workspace-shell/1', captureBytes: 1024 },
|
||||
{ toolProfile: 'hosted-workspace-shell/2', captureBytes: 1024 },
|
||||
{ toolProfile: 'hosted-workspace-team-lead/1', captureBytes: 1024 },
|
||||
])('recovers every saved Workspace profile (%j)', async (definition) => {
|
||||
// One refused profile would abort recovery for the whole Workspace,
|
||||
// so recovery reads the same profile vocabulary creation persists.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ authentication and writer credentials" below.
|
|||
## Integration status
|
||||
|
||||
The Hosted path supports durable no-tool Sessions and private Workspace tool
|
||||
profiles. G0 adds opt-in public creation with an initial file-tool Turn through
|
||||
profiles. G0 adds opt-in public creation with an initial Workspace Turn (file tools, or mandatory-approval foreground Shell under the Shell opt-in) through
|
||||
the production Broker. This is not complete Workspace lifecycle, in-flight
|
||||
recovery or distributed provisioning support. See the G0 section below for its
|
||||
exact deployment and admission boundary, and the historical
|
||||
|
|
@ -506,12 +506,50 @@ Harness or Runtime Broker credentials.
|
|||
|
||||
## Embedded Runtime Broker
|
||||
|
||||
### Initial Workspace file Turn (G0)
|
||||
### Public foreground Shell admission
|
||||
|
||||
Stored AgentDefinition Sessions keep their compiled profile and approval mode;
|
||||
the Shell switch changes only the built-in agent's default profile selection.
|
||||
Every Shell Session still requires the switch and valid asking approval for
|
||||
fresh execution. Harness load copies retain the definition identity alongside
|
||||
`suppressChildAgents`.
|
||||
|
||||
`QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED=true` selects the server-owned
|
||||
`hosted-workspace-shell/1` profile for newly created built-in `qwen-code` Workspace Sessions on both
|
||||
public REST and WebShell, including empty creation. It defaults to false and
|
||||
requires Workspace files plus its deployment prerequisites. Set
|
||||
`QWEN_MANAGED_AGENT_APPROVAL_MODE` to `default` or `auto-edit`; Shell opt-in with
|
||||
`yolo`, an empty or unknown mode fails startup. Shell always asks permission.
|
||||
Existing Sessions keep their stored profile and approval mode.
|
||||
|
||||
Disabling only the Shell flag refuses fresh Turns on existing Shell Sessions
|
||||
with `409 workspace_unavailable`; original idempotency keys, accepted approval
|
||||
responses, cancellation and settlement remain supported while Workspace files
|
||||
remain enabled and persisted approval remains `default` or `auto-edit`. Files-off
|
||||
or invalid approval can refuse attachment; neither case is bypassed by a
|
||||
capability. Files Sessions retain their existing behavior. Shell Session responses
|
||||
add `foreground_shell` (public) or `foregroundShell` (WebShell); false disables
|
||||
fresh sending. Files and unbound Sessions omit the field, with no schema default.
|
||||
`workspaceTurns` expresses creator/grant permission, not sufficient Shell Turn
|
||||
admission: fresh sending also requires `foregroundShell=true`. Controlled W2 cwd
|
||||
changes do not execute Shell and retain their own files, actor, path and
|
||||
context-revision admission; the Shell flag is not a general Session freeze.
|
||||
|
||||
Shell close/archive/unarchive/delete remain unavailable, and are refused before
|
||||
creating a lifecycle operation. Existing G3 recovery refusals remain in force;
|
||||
no dispatched Shell is automatically replayed. Keep the opt-in off until the
|
||||
deployment passes #12904, #13010 and public FG6f physical failure gates. See
|
||||
[the complete design](../../../docs/design/public-hosted-shell-admission.md).
|
||||
|
||||
### Initial Workspace Turn (G0)
|
||||
|
||||
`QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED=true` opts in to an initial
|
||||
Read/Write/Edit Turn supplied with public Session creation. The WebShell creation
|
||||
adapter uses the same admission. This requires the Hosted Harness and HTTP
|
||||
Session Store, a `yolo`, `default` or `auto-edit` approval mode, and a `local-process`, `session`-isolated
|
||||
Workspace Turn supplied with public Session creation: Read/Write/Edit under
|
||||
`hosted-workspace-files/1`, or file tools plus mandatory-approval foreground Shell
|
||||
under `hosted-workspace-shell/1` when the Shell opt-in above is enabled. The
|
||||
WebShell creation adapter uses the same admission. This requires the Hosted
|
||||
Harness and HTTP Session Store, `default` or `auto-edit` approval for Shell
|
||||
(`yolo` is also valid for files), and a `local-process`, `session`-isolated
|
||||
Broker with configured `runtime-broker.workspace-mounts`. Registry entries must
|
||||
use `managed-runtime-tools/1` and `preapproved-workspace-tools/1`, and their
|
||||
tenant/storage identity must have a deployment mount. The trusted ingress must
|
||||
|
|
@ -526,21 +564,29 @@ never enable it where untrusted clients can reach the server.
|
|||
`auto-edit`, any caller with a read grant on the bound Workspace can list and
|
||||
inspect pending permission Actions through the public API or WebShell;
|
||||
answering one needs the Session's recorded owner or a caller holding OPERATOR
|
||||
or above, and that read grant as well. Responses are durable, idempotent
|
||||
or above, and that read grant as well. A Shell Session is the exception:
|
||||
only its recorded owner may answer, because that answer runs a command. Responses are durable, idempotent
|
||||
operations; their final result follows the committed Harness decision.
|
||||
`QWEN_MANAGED_AGENT_APPROVAL_TIMEOUT` defaults to `10m` and accepts `1s` to `24h`.
|
||||
The approval mode is pinned at Session creation and must be confirmed by the
|
||||
Harness on creation and load.
|
||||
|
||||
Submit `agent_id: "qwen-code"`, the existing `workspace` selection and `input`
|
||||
through `POST /v1/agents/sessions`. The server chooses the fixed
|
||||
`hosted-workspace-files/1` private profile and uses the persisted Workspace ID
|
||||
for the Session Store. Public callers cannot choose the profile. Configure the
|
||||
through `POST /v1/agents/sessions`. The server persists
|
||||
`hosted-workspace-shell/1` with mandatory `default`/`auto-edit` approval when the
|
||||
Shell deployment opt-in is enabled, otherwise `hosted-workspace-files/1` with the
|
||||
configured file approval mode. This also applies to empty bound creation. The
|
||||
Session Store uses the persisted Workspace ID; public callers cannot choose the
|
||||
profile. W1c offline migration remains files/1-only and refuses persisted Shell
|
||||
definitions with `migration_profile_unsupported`. Configure the
|
||||
Harness's deployment-owned `--managed-runtime-broker-url` and
|
||||
`--managed-runtime-broker-token` options to reach this Broker. A repeated creation key returns
|
||||
the original Session/Turn; changed input conflicts. Disabling the opt-in refuses
|
||||
creation with input, including replays, while empty bound creation remains
|
||||
available. The directory mounted for a Workspace is trusted deployment data,
|
||||
the original Session/Turn; changed input conflicts. Disabling the Workspace files
|
||||
opt-in refuses creation with input, including replays, while empty bound creation
|
||||
remains available. The Shell opt-in selects the persisted profile; it does not
|
||||
independently admit creation input. Upgrade the Harness before enabling the
|
||||
Shell flag: an older Harness ignores `suppressChildAgents` and would advertise
|
||||
the child agent tool. The directory mounted for a Workspace is trusted deployment data,
|
||||
not a filesystem sandbox.
|
||||
|
||||
Later Turns may be submitted by any caller holding OPERATOR on the bound
|
||||
|
|
@ -549,14 +595,21 @@ facts hold (the Workspace registry still backs the binding and stays `ACTIVE`,
|
|||
and the actor recorded by the Workspace create command keeps OPERATOR or
|
||||
above; the WebShell adapter's per-caller `workspaceTurns` capability flag
|
||||
mirrors exactly that rule, though the public surface publishes no such
|
||||
flag), and such a caller may cancel the Session's running Turns and rename
|
||||
the Session — a readable actor below OPERATOR gets `403
|
||||
session_operation_forbidden`, and an admitted OPERATOR blocked by the shape
|
||||
or fact gates gets `409 workspace_unavailable`. Workspace close follows its
|
||||
separate close capability and lifecycle admission. Archive, delete and
|
||||
unarchive follow their separate retention capabilities after reliable
|
||||
Workspace close. Controlled cwd changes ship below (W2); broad Workspace capability
|
||||
advertisement remains gated. Shell and in-flight recovery are separate slices.
|
||||
flag). Shell Sessions also require the Shell opt-in and valid persisted
|
||||
approval for fresh sending. `workspaceTurns` does not certify Shell admission;
|
||||
Shell clients also require `foregroundShell=true`. Such a caller may cancel
|
||||
the Session's running Turns and rename the Session — a readable actor below
|
||||
OPERATOR gets `403 session_operation_forbidden`, and an admitted OPERATOR
|
||||
blocked by the shape or fact gates gets `409 workspace_unavailable`.
|
||||
Workspace close follows its separate close capability and lifecycle admission.
|
||||
Public Shell close, archive, unarchive and delete stay unavailable, including
|
||||
protocol-0. Archive, delete and unarchive of non-Shell Sessions follow their
|
||||
separate retention capabilities after reliable Workspace close. Controlled cwd
|
||||
changes ship below (W2); broad Workspace capability advertisement remains
|
||||
gated. Public foreground Shell does not publish the child agent tool, and it
|
||||
has no Session message funnel because that funnel shares the child
|
||||
orchestrator's write chain.
|
||||
Background Shell, Monitor and complete in-flight recovery remain separate slices.
|
||||
The existing `EmbeddedRuntimeBroker` is used through production configuration;
|
||||
no direct store admission or test Broker replacement is needed.
|
||||
|
||||
|
|
@ -806,21 +859,26 @@ and Shell can reach other paths allowed by the worker's host permissions.
|
|||
Foreground Shell may create detached descendants. Use this only with trusted
|
||||
local workloads. The W0e recovery above handles trusted host reboot; it
|
||||
does not provide physical isolation or recovery after worker-only death.
|
||||
Public bound Turn admission is limited to the opt-in initial file Turn described
|
||||
in G0 above and to later Turns submitted by any caller holding OPERATOR on the
|
||||
Workspace under the same opt-in while the Session's creator-keyed execution
|
||||
facts hold (the WebShell adapter's per-caller `workspaceTurns` capability
|
||||
flag mirrors that same rule; the public surface publishes no such flag);
|
||||
such a caller may also cancel the Session's running Turns and rename the
|
||||
Session. Later Turns run
|
||||
under the creator's Workspace grants, so an actor without a read grant keeps the
|
||||
existing `session_not_found` invisibility, a readable actor below OPERATOR is
|
||||
refused `session_operation_forbidden`, and an admitted OPERATOR whose Session
|
||||
lost the creator-keyed facts meets the family's domain `workspace_unavailable`.
|
||||
Public close follows its separate close
|
||||
Public bound Turn admission is limited to the initial Workspace Turn described
|
||||
in G0 above (file tools, or mandatory-approval foreground Shell under the Shell
|
||||
opt-in; the public Shell tool list does not include the child agent tool) and
|
||||
later Turns submitted by any caller holding OPERATOR on the Workspace under the
|
||||
same opt-in while the Session's creator-keyed execution facts hold (the WebShell
|
||||
adapter's per-caller `workspaceTurns` capability flag mirrors that same rule;
|
||||
the public surface publishes no such flag). Shell Sessions additionally require
|
||||
the Shell opt-in and valid persisted approval for fresh sending; `workspaceTurns`
|
||||
does not by itself admit a Shell Turn, which also requires `foregroundShell=true`.
|
||||
Such a caller may also cancel the Session's running Turns and rename the Session.
|
||||
Later Turns run under the creator's Workspace grants, so an actor without a read
|
||||
grant keeps the existing `session_not_found` invisibility, a readable actor below
|
||||
OPERATOR is refused `session_operation_forbidden`, and an admitted OPERATOR whose
|
||||
Session lost the creator-keyed facts meets the family's domain
|
||||
`workspace_unavailable`. Public Shell close, archive, unarchive and delete stay
|
||||
unavailable, including protocol-0. Public close follows its separate close
|
||||
capability and lifecycle admission. Archive, delete and unarchive follow their
|
||||
separate retention capabilities after reliable Workspace close;
|
||||
the private Shell profile is not enabled through public creation.
|
||||
the Shell profile reaches public creation only through the deployment Shell
|
||||
opt-in described above; callers cannot select it.
|
||||
See the bilingual [execution design](../../../docs/design/2026-09-26-managed-workspace-execution.md)
|
||||
for the exact boundary.
|
||||
|
||||
|
|
|
|||
|
|
@ -80,12 +80,14 @@ public final class ApiModels {
|
|||
@JsonProperty("session_close") boolean sessionClose,
|
||||
@JsonProperty("session_archive") boolean sessionArchive,
|
||||
@JsonProperty("session_unarchive") boolean sessionUnarchive,
|
||||
@JsonProperty("session_delete") boolean sessionDelete) {
|
||||
@JsonProperty("session_delete") boolean sessionDelete,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("foreground_shell") Boolean foregroundShell) {
|
||||
}
|
||||
|
||||
public record WebShellSessionCapabilities(boolean tasks, boolean artifacts, boolean actions,
|
||||
boolean workspaceTurns, boolean sessionClose, boolean sessionArchive,
|
||||
boolean sessionUnarchive, boolean sessionDelete, boolean cwdChange) {
|
||||
boolean sessionUnarchive, boolean sessionDelete, boolean cwdChange,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean foregroundShell) {
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
|
|
|
|||
|
|
@ -111,6 +111,14 @@ public class ManagedAgentProperties {
|
|||
if (timeout < 1000 || timeout > 86400000) {
|
||||
throw new IllegalStateException("Hosted approval timeout must be between 1s and 24h");
|
||||
}
|
||||
String mode = harness.getApprovalMode();
|
||||
if (harness.isWorkspaceShellEnabled()
|
||||
&& (!harness.isWorkspaceFilesEnabled()
|
||||
|| mode == null
|
||||
|| !Set.of("default", "auto-edit").contains(mode.toLowerCase(Locale.ROOT)))) {
|
||||
throw new IllegalStateException("Hosted Workspace Shell requires Workspace files"
|
||||
+ " and default or auto-edit approval mode");
|
||||
}
|
||||
if (harness.isWorkspaceFilesEnabled()
|
||||
&& (!harness.isEnabled()
|
||||
|| !sessionStore.isEnabled()
|
||||
|
|
@ -118,10 +126,10 @@ public class ManagedAgentProperties {
|
|||
|| !"local-process".equals(runtimeBroker.getProvisioner())
|
||||
|| !"session".equals(runtimeBroker.getIsolationClass())
|
||||
|| runtimeBroker.getWorkspaceMounts().isEmpty()
|
||||
|| mode == null
|
||||
|| !Set.of("yolo", "default", "auto-edit")
|
||||
.contains(
|
||||
harness.getApprovalMode()
|
||||
.toLowerCase(Locale.ROOT)))) {
|
||||
mode.toLowerCase(Locale.ROOT)))) {
|
||||
throw new IllegalStateException(
|
||||
"Hosted Workspace files require"
|
||||
+ " a supported Harness, Session Store and Session-isolated"
|
||||
|
|
@ -200,6 +208,7 @@ public class ManagedAgentProperties {
|
|||
public static class Harness {
|
||||
private boolean enabled;
|
||||
private boolean workspaceFilesEnabled;
|
||||
private boolean workspaceShellEnabled;
|
||||
private String baseUrl = "http://127.0.0.1:4170";
|
||||
private String token = "";
|
||||
private String capabilityDigest = "";
|
||||
|
|
@ -242,6 +251,14 @@ public class ManagedAgentProperties {
|
|||
this.workspaceFilesEnabled = workspaceFilesEnabled;
|
||||
}
|
||||
|
||||
public boolean isWorkspaceShellEnabled() {
|
||||
return workspaceShellEnabled;
|
||||
}
|
||||
|
||||
public void setWorkspaceShellEnabled(boolean workspaceShellEnabled) {
|
||||
this.workspaceShellEnabled = workspaceShellEnabled;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.alibaba.qwen.code.daemon.SessionCreationOutcomeUnknownException;
|
|||
import com.alibaba.qwen.code.daemon.StreamHarnessEvents;
|
||||
import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.WorkspaceToolProfiles;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord;
|
||||
|
|
@ -194,12 +195,15 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
private com.fasterxml.jackson.databind.JsonNode doSettleLifecycle(
|
||||
com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord operation) {
|
||||
SessionRecord session = sessions.requireSession(operation.tenantId(), operation.sessionId());
|
||||
requireShellApproval(session);
|
||||
AttachmentKey key = new AttachmentKey(operation.tenantId(), operation.sessionId());
|
||||
HarnessSessionRef ref = attachments.get(key);
|
||||
if (ref == null) {
|
||||
ref = client().loadSession(new LoadHarnessSession(session.sessionId(), managedSessionStore(session),
|
||||
false, toolProfile(session), false)
|
||||
.withAgentDefinition(agentDefinition(session, false)).withChildWorkspaces(childWorkspaces)
|
||||
false, toolProfile(session), false, false,
|
||||
WorkspaceToolProfiles.isPublicShell(toolProfile(session)))
|
||||
.withAgentDefinition(agentDefinition(session, false))
|
||||
.withChildWorkspaces(childWorkspaces)
|
||||
.forLifecycle(operation.operationId(), operation.claimGeneration()));
|
||||
attachments.put(key, ref);
|
||||
}
|
||||
|
|
@ -266,6 +270,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
boolean loadExisting, boolean passiveManagedRuntimeRecovery,
|
||||
boolean actionResponse) {
|
||||
SessionRecord session = sessions.requireSession(tenantId, sessionId);
|
||||
requireShellApproval(session);
|
||||
if (session.workspace() != null) {
|
||||
if (!isWorkspaceFilesAvailable()) {
|
||||
throw new IllegalStateException("Hosted Workspace files are disabled");
|
||||
|
|
@ -618,12 +623,13 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
*/
|
||||
private HarnessSessionRef messageStopAttachment(String tenantId,
|
||||
String sessionId) {
|
||||
SessionRecord session = sessions.requireSession(tenantId, sessionId);
|
||||
requireShellApproval(session);
|
||||
AttachmentKey key = new AttachmentKey(tenantId, sessionId);
|
||||
HarnessSessionRef cached = attachments.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
SessionRecord session = sessions.requireSession(tenantId, sessionId);
|
||||
if (session.workspace() != null) {
|
||||
if (!isWorkspaceFilesAvailable()) {
|
||||
throw new IllegalStateException("Hosted Workspace files are disabled");
|
||||
|
|
@ -638,7 +644,8 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
new LoadHarnessSession(session.sessionId(),
|
||||
managedSessionStore(session),
|
||||
session.harnessBootId() != null, toolProfile(session),
|
||||
false, false)
|
||||
false, false,
|
||||
WorkspaceToolProfiles.isPublicShell(toolProfile(session)))
|
||||
.withAgentDefinition(agentDefinition(session, false))
|
||||
.withChildWorkspaces(childWorkspaces)
|
||||
.withStoppedMessages());
|
||||
|
|
@ -756,6 +763,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
}
|
||||
|
||||
private HarnessSessionRef attachment(String tenantId, String sessionId, boolean newWork) {
|
||||
requireShellApproval(sessions.requireSession(tenantId, sessionId));
|
||||
AttachmentKey key = new AttachmentKey(tenantId, sessionId);
|
||||
HarnessSessionRef attachment = attachments.get(key);
|
||||
if (attachment == null) {
|
||||
|
|
@ -767,6 +775,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
}
|
||||
|
||||
private HarnessSessionRef cancellationAttachment(String tenantId, String sessionId) {
|
||||
requireShellApproval(sessions.requireSession(tenantId, sessionId));
|
||||
AttachmentKey key = new AttachmentKey(tenantId, sessionId);
|
||||
HarnessSessionRef attached = attachments.get(key);
|
||||
if (attached == null) {
|
||||
|
|
@ -778,6 +787,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
|
||||
private SessionRecord requireReadyForNewWork(String tenantId, String sessionId, boolean actionResponse) {
|
||||
SessionRecord session = sessions.requireSession(tenantId, sessionId);
|
||||
requireShellApproval(session);
|
||||
if (session.workspace() != null) {
|
||||
if (!isWorkspaceFilesAvailable()) {
|
||||
throw new IllegalStateException("Hosted Workspace files are disabled");
|
||||
|
|
@ -808,6 +818,9 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
.approvalTimeoutMs(properties.getApprovalTimeout().toMillis())
|
||||
.toolProfile(toolProfile(session))
|
||||
.childWorkspaces(childWorkspaces);
|
||||
if (WorkspaceToolProfiles.isPublicShell(toolProfile(session))) {
|
||||
builder.suppressChildAgents();
|
||||
}
|
||||
ManagedSessionStoreConnection store = managedSessionStore(
|
||||
session);
|
||||
if (store != null) {
|
||||
|
|
@ -854,8 +867,10 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
ManagedSessionStoreConnection store = managedSessionStore(session);
|
||||
return client().loadSession(new LoadHarnessSession(session.sessionId(), store,
|
||||
passiveManagedRuntimeRecovery, profile,
|
||||
driveRuntimeRecovery, cancellationTakeover)
|
||||
.withAgentDefinition(agentDefinition(session, false)).withChildWorkspaces(childWorkspaces));
|
||||
driveRuntimeRecovery, cancellationTakeover,
|
||||
WorkspaceToolProfiles.isPublicShell(profile))
|
||||
.withAgentDefinition(agentDefinition(session, false))
|
||||
.withChildWorkspaces(childWorkspaces));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -948,6 +963,7 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
private SessionRecord requireRecoverableSession(String tenantId,
|
||||
String sessionId, boolean cancellation) {
|
||||
SessionRecord session = sessions.requireSession(tenantId, sessionId);
|
||||
requireShellApproval(session);
|
||||
if (session.workspace() != null) {
|
||||
if (!isWorkspaceFilesAvailable()) {
|
||||
throw new IllegalStateException("Hosted Workspace files are disabled");
|
||||
|
|
@ -1003,6 +1019,14 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
|||
cached.getHarnessEventEpoch());
|
||||
}
|
||||
|
||||
private void requireShellApproval(SessionRecord session) {
|
||||
if (session.workspace() != null && WorkspaceToolProfiles.isPublicShell(session.toolProfile())
|
||||
&& (actions == null || !WorkspaceToolProfiles.requiresApproval(
|
||||
actions.approvalMode(session.tenantId(), session.sessionId())))) {
|
||||
throw new IllegalStateException("Hosted Workspace Shell requires persisted default or auto-edit approval mode");
|
||||
}
|
||||
}
|
||||
|
||||
private static String toolProfile(SessionRecord session) {
|
||||
if (session.workspace() == null) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellTranscript;
|
|||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellTurn;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.store.WorkspaceToolProfiles;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedArtifactReader;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionProjection;
|
||||
|
|
@ -110,7 +111,8 @@ public class ManagedAgentService {
|
|||
}
|
||||
|
||||
private boolean supportsClose(SessionRecord session) {
|
||||
return session.workspace() == null || store.workspaceFilesEnabled()
|
||||
return session.workspace() == null || !WorkspaceToolProfiles.isPublicShell(session.toolProfile())
|
||||
&& store.workspaceFilesEnabled()
|
||||
&& runtimeWarmer != null && runtimeWarmer.supportsWorkspaceClose()
|
||||
&& harness.supportsLifecycle();
|
||||
}
|
||||
|
|
@ -784,7 +786,8 @@ public class ManagedAgentService {
|
|||
private static boolean retention(SessionRecord session,
|
||||
Set<String> closed) {
|
||||
return session.workspace() == null
|
||||
|| closed.contains(session.sessionId());
|
||||
|| !WorkspaceToolProfiles.isPublicShell(session.toolProfile())
|
||||
&& closed.contains(session.sessionId());
|
||||
}
|
||||
|
||||
private PublicSession publicSession(SessionRecord session) {
|
||||
|
|
@ -818,7 +821,8 @@ public class ManagedAgentService {
|
|||
true,
|
||||
session.workspace() == null,
|
||||
true,
|
||||
hasActions(session), supportsClose(session), retention, retention, supportsDelete(session, retention)),
|
||||
hasActions(session), supportsClose(session), retention, retention, supportsDelete(session, retention),
|
||||
foregroundShell(session)),
|
||||
publicWorkspace(session));
|
||||
}
|
||||
|
||||
|
|
@ -914,14 +918,23 @@ public class ManagedAgentService {
|
|||
new WebShellSessionCapabilities(true, hasArtifacts(session),
|
||||
hasActions(session), maySubmit, supportsClose(session),
|
||||
retention, retention, supportsDelete(session, retention),
|
||||
mayChangeCwd));
|
||||
mayChangeCwd, foregroundShell(session)));
|
||||
}
|
||||
|
||||
private boolean supportsDelete(SessionRecord session, boolean retention) {
|
||||
return retention || session.workspace() != null && store.workspaceFilesEnabled()
|
||||
return retention || session.workspace() != null && !WorkspaceToolProfiles.isPublicShell(session.toolProfile())
|
||||
&& store.workspaceFilesEnabled()
|
||||
&& runtimeWarmer != null && runtimeWarmer.supportsWorkspaceClose() && harness.supportsLifecycle();
|
||||
}
|
||||
|
||||
private Boolean foregroundShell(SessionRecord session) {
|
||||
if (session.workspace() == null || !WorkspaceToolProfiles.isPublicShell(session.toolProfile())) {
|
||||
return null;
|
||||
}
|
||||
return store.workspaceFilesEnabled() && store.workspaceShellEnabled()
|
||||
&& WorkspaceToolProfiles.requiresApproval(session.approvalMode());
|
||||
}
|
||||
|
||||
private static WebShellWorkspace webShellWorkspace(SessionRecord session) {
|
||||
return session.workspace() == null ? null
|
||||
: new WebShellWorkspace(session.workspace().getWorkspaceId(),
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ final class AgentDefinitionCompiler {
|
|||
|| !(entry.get("profile") instanceof String profile)) {
|
||||
throw unsupported("tools");
|
||||
} else {
|
||||
if (!toolProfiles.contains(profile)) {
|
||||
if (WorkspaceToolProfiles.INTERNAL_TEAM_LEAD.equals(profile)
|
||||
|| !toolProfiles.contains(profile)) {
|
||||
throw unsupported("tools");
|
||||
}
|
||||
// A Shell profile always asks (#13271); it may never pair
|
||||
|
|
|
|||
|
|
@ -167,6 +167,10 @@ public interface AgentStateStore {
|
|||
return false;
|
||||
}
|
||||
|
||||
default boolean workspaceShellEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
default OperationAdmission beginWorkspaceClose(String tenantId, String sessionId,
|
||||
String actorId, String actorDigest, String key, String digest, boolean supported) {
|
||||
throw new UnsupportedOperationException("Workspace close is unavailable");
|
||||
|
|
|
|||
|
|
@ -71,11 +71,11 @@ public class ManagedActionStore {
|
|||
|
||||
private OwnerRow ownerRow(String tenantId, String sessionId) {
|
||||
return DataAccessUtils.nullableSingleResult(jdbc.query(
|
||||
"SELECT owner_actor_key, creator_actor_key, workspace_id"
|
||||
"SELECT owner_actor_key, creator_actor_key, workspace_id, tool_profile"
|
||||
+ " FROM managed_agent_session WHERE"
|
||||
+ " tenant_id = ? AND session_id = ?",
|
||||
(result, row) -> new OwnerRow(result.getBytes(1),
|
||||
result.getBytes(2), result.getString(3)),
|
||||
result.getBytes(2), result.getString(3), result.getString(4)),
|
||||
tenantId, sessionId));
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ public class ManagedActionStore {
|
|||
tenantId, session.workspaceId(), key).isEmpty();
|
||||
}
|
||||
|
||||
private record OwnerRow(byte[] owner, byte[] creator, String workspaceId) {
|
||||
private record OwnerRow(byte[] owner, byte[] creator, String workspaceId, String toolProfile) {
|
||||
}
|
||||
|
||||
private static ApiException forbidden() {
|
||||
|
|
@ -137,6 +137,13 @@ public class ManagedActionStore {
|
|||
"Only the Session's owner or a Workspace operator may answer its Actions.");
|
||||
}
|
||||
|
||||
private static ApiException shellForbidden() {
|
||||
return new ApiException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"action_forbidden",
|
||||
"Only the Session's recorded owner may answer its Shell Actions.");
|
||||
}
|
||||
|
||||
void apply(
|
||||
String tenantId,
|
||||
String workspaceId,
|
||||
|
|
@ -426,9 +433,12 @@ public class ManagedActionStore {
|
|||
}
|
||||
return new OperationAdmission(existing, true);
|
||||
}
|
||||
if (!admitted && !workspaceOperatorAdmits(tenantId, ownerRow,
|
||||
actorKey)) {
|
||||
throw forbidden();
|
||||
// A Shell answer runs a command, so the Workspace-operator arm does
|
||||
// not apply. Other Actions keep that arm.
|
||||
boolean shell = ownerRow != null && WorkspaceToolProfiles.isPublicShell(ownerRow.toolProfile());
|
||||
if (!admitted && (shell || !workspaceOperatorAdmits(tenantId, ownerRow,
|
||||
actorKey))) {
|
||||
throw shell ? shellForbidden() : forbidden();
|
||||
}
|
||||
// Every bound admission certifies delivery exactly like the
|
||||
// submitter family: the recorded create-command actor must still
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
private static final Pattern DEFINITION_REVISION = Pattern.compile(
|
||||
"^[1-9][0-9]{0,17}$");
|
||||
private final boolean workspaceFilesEnabled;
|
||||
private final boolean workspaceShellEnabled;
|
||||
private final List<ManagedAgentProperties.RuntimeBroker.WorkspaceMount> workspaceMounts;
|
||||
private final RowMapper<SessionRecord> sessionMapper = (result, row) ->
|
||||
new SessionRecord(result.getString("tenant_id"),
|
||||
|
|
@ -253,6 +254,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
this.definitionToolProfiles = properties.getDefinitionToolProfiles();
|
||||
this.definitionModels = properties.getDefinitionModels();
|
||||
this.workspaceFilesEnabled = properties.getHarness().isWorkspaceFilesEnabled();
|
||||
this.workspaceShellEnabled = properties.getHarness().isWorkspaceShellEnabled();
|
||||
this.workspaceMounts = properties.getRuntimeBroker().getWorkspaceMounts();
|
||||
if (agentRevision == null || agentRevision.isBlank()
|
||||
|| agentRevision.length() > 128) {
|
||||
|
|
@ -292,10 +294,14 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
actorId, idempotencyKey);
|
||||
if (!existing.isEmpty()) {
|
||||
return replayWorkspaceCommand(tenantId, actorId,
|
||||
requestDigest, existing.getFirst());
|
||||
requestDigest, existing.getFirst(), false);
|
||||
}
|
||||
AgentPin pin = resolveAgentPin(tenantId, agentId, requestedRevision,
|
||||
true);
|
||||
if (!input.isEmpty() && WorkspaceToolProfiles.isPublicShell(pin.toolProfile())
|
||||
&& !workspaceShellEnabled) {
|
||||
throw workspaceExecutionUnavailable();
|
||||
}
|
||||
requireCreationScope(tenantId, idempotencyKey, true);
|
||||
ResolvedBinding workspace = workspaces.resolveForCreation(
|
||||
tenantId, actorId, selection);
|
||||
|
|
@ -315,6 +321,35 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
workspace, actorId, pin);
|
||||
}
|
||||
|
||||
/** Trusted internal creation; public requests and definitions cannot select this policy. */
|
||||
@Transactional
|
||||
public Admission insertInternalTeamLeadSessionCommand(String tenantId,
|
||||
String actorId, String idempotencyKey, String requestDigest,
|
||||
String title, WorkspaceSelection selection) {
|
||||
WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId);
|
||||
List<WorkspaceCommand> existing = findWorkspaceCommand(tenantId,
|
||||
actorId, idempotencyKey);
|
||||
if (!existing.isEmpty()) {
|
||||
return replayWorkspaceCommand(tenantId, actorId,
|
||||
requestDigest, existing.getFirst(), true);
|
||||
}
|
||||
if (!workspaceFilesEnabled) {
|
||||
throw workspaceExecutionUnavailable();
|
||||
}
|
||||
requireCreationScope(tenantId, idempotencyKey, true);
|
||||
ResolvedBinding workspace = workspaces.resolveForCreation(tenantId, actorId, selection);
|
||||
WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, workspace.binding().getStorageId());
|
||||
if (!WorkspaceExecutionProfile.CONFIG_REF.equals(workspace.configRef())
|
||||
|| !WorkspaceExecutionProfile.POLICY_REF.equals(workspace.policyRef())
|
||||
|| workspaceMounts.stream().noneMatch(mount -> tenantId.equals(mount.tenantId())
|
||||
&& workspace.binding().getStorageId().equals(mount.storageId()))) {
|
||||
throw workspaceExecutionUnavailable();
|
||||
}
|
||||
return insertSession(tenantId, "CREATE_SESSION", idempotencyKey, requestDigest,
|
||||
"qwen-code", title, List.of(), null, workspace, actorId,
|
||||
new AgentPin(agentRevision, null, approvalMode, WorkspaceToolProfiles.INTERNAL_TEAM_LEAD));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Admission replayWorkspaceSessionCommand(String tenantId,
|
||||
|
|
@ -326,7 +361,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
"idempotency_conflict", "The creation command is missing.");
|
||||
}
|
||||
return replayWorkspaceCommand(tenantId, actorId, requestDigest,
|
||||
existing.getFirst());
|
||||
existing.getFirst(), false);
|
||||
}
|
||||
|
||||
private static String childActorOf(String parentSessionId) {
|
||||
|
|
@ -588,14 +623,17 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
}
|
||||
|
||||
private Admission replayWorkspaceCommand(String tenantId, String actorId,
|
||||
String requestDigest, WorkspaceCommand command) {
|
||||
if (!command.requestDigest().equals(requestDigest)) {
|
||||
String requestDigest, WorkspaceCommand command, boolean internalTeamLead) {
|
||||
SessionRecord session = requireSession(tenantId, command.sessionId());
|
||||
if (!command.requestDigest().equals(requestDigest)
|
||||
|| internalTeamLead != WorkspaceToolProfiles.INTERNAL_TEAM_LEAD.equals(session.toolProfile())
|
||||
|| internalTeamLead && (!"qwen-code".equals(session.agentId())
|
||||
|| session.agentDefinitionDigest() != null)) {
|
||||
throw new ApiException(HttpStatus.CONFLICT,
|
||||
"idempotency_conflict",
|
||||
"The idempotency key was reused with different content.");
|
||||
}
|
||||
ContextBinding bound = requireSession(tenantId,
|
||||
command.sessionId()).workspace();
|
||||
ContextBinding bound = session.workspace();
|
||||
if (bound == null) {
|
||||
throw new ApiException(HttpStatus.CONFLICT,
|
||||
"workspace_unavailable", "Workspace binding is missing.");
|
||||
|
|
@ -684,8 +722,11 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
"unsupported_feature",
|
||||
"Only the current agent revision can be selected.");
|
||||
}
|
||||
return new AgentPin(agentRevision, null, approvalMode,
|
||||
workspaceBound ? "hosted-workspace-files/1" : null);
|
||||
String toolProfile = workspaceBound
|
||||
? (workspaceShellEnabled ? WorkspaceToolProfiles.SHELL
|
||||
: WorkspaceToolProfiles.FILES)
|
||||
: null;
|
||||
return new AgentPin(agentRevision, null, approvalMode, toolProfile);
|
||||
}
|
||||
if (definitions == null) {
|
||||
throw ManagedAgentDefinitionStore.notFound();
|
||||
|
|
@ -816,6 +857,9 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
return replayCommand(tenantId, operation, idempotencyKey,
|
||||
requestDigest);
|
||||
}
|
||||
if (WorkspaceToolProfiles.isPublicShell(session.toolProfile()) && !workspaceShellEnabled) {
|
||||
throw workspaceExecutionUnavailable();
|
||||
}
|
||||
if (session.workspace() != null) {
|
||||
WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId());
|
||||
}
|
||||
|
|
@ -1167,6 +1211,11 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
return workspaceFilesEnabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean workspaceShellEnabled() {
|
||||
return workspaceShellEnabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OperationAdmission beginWorkspaceClose(String tenantId, String sessionId, String actorId,
|
||||
|
|
@ -1178,14 +1227,16 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
@Transactional
|
||||
public OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId,
|
||||
OperationKind kind, String actorId, String actorDigest, String key, String digest, boolean closeSupported) {
|
||||
return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, closeSupported);
|
||||
// H4b child close is the only production caller. Public lifecycle uses
|
||||
// the protocol overload and still refuses a Shell profile.
|
||||
return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, closeSupported, 0, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId,
|
||||
OperationKind kind, String actorId, String actorDigest, String key, String digest, boolean supported, int protocolVersion) {
|
||||
return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, supported, protocolVersion);
|
||||
return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, supported, protocolVersion, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -1244,6 +1295,9 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
}
|
||||
return new SessionMutation(session, true);
|
||||
}
|
||||
if (WorkspaceToolProfiles.isPublicShell(session.toolProfile())) {
|
||||
throw workspaceExecutionUnavailable();
|
||||
}
|
||||
WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId());
|
||||
requireSessionStatus(session.status(), "ARCHIVED");
|
||||
requireNoOpenOperation(tenantId, sessionId);
|
||||
|
|
@ -1264,11 +1318,17 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
|
||||
private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind,
|
||||
String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported) {
|
||||
return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, actorId, supported, 0);
|
||||
return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, actorId, supported, 0, true);
|
||||
}
|
||||
|
||||
private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind,
|
||||
String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion) {
|
||||
return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, actorId, supported, protocolVersion, true);
|
||||
}
|
||||
|
||||
private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind,
|
||||
String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion,
|
||||
boolean refuseShell) {
|
||||
WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId);
|
||||
if (protocolVersion == 1) {
|
||||
ToolPublicationRetentionStore.lockTenant(jdbc, tenantId);
|
||||
|
|
@ -1294,6 +1354,9 @@ public class ManagedAgentStore implements AgentStateStore {
|
|||
}
|
||||
return new OperationAdmission(existing.get(), true);
|
||||
}
|
||||
if (refuseShell && WorkspaceToolProfiles.isPublicShell(session.toolProfile())) {
|
||||
throw workspaceExecutionUnavailable();
|
||||
}
|
||||
if (hasPendingRenameDelivery(tenantId, sessionId)) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "session_operation_active",
|
||||
"The Session has a title delivery awaiting completion.");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
public final class WorkspaceToolProfiles {
|
||||
public static final String FILES = "hosted-workspace-files/1";
|
||||
public static final String SHELL = "hosted-workspace-shell/1";
|
||||
public static final String INTERNAL_TEAM_LEAD = "hosted-workspace-team-lead/1";
|
||||
|
||||
private WorkspaceToolProfiles() {
|
||||
}
|
||||
|
||||
public static boolean isPublicShell(String profile) {
|
||||
return SHELL.equals(profile) || "hosted-workspace-shell/2".equals(profile);
|
||||
}
|
||||
|
||||
public static boolean requiresApproval(String mode) {
|
||||
return "default".equals(mode) || "auto-edit".equals(mode);
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ qwen:
|
|||
harness:
|
||||
enabled: '${QWEN_MANAGED_AGENT_HARNESS_ENABLED:false}'
|
||||
workspace-files-enabled: '${QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED:false}'
|
||||
workspace-shell-enabled: '${QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED:false}'
|
||||
base-url: '${QWEN_MANAGED_AGENT_HARNESS_BASE_URL:http://127.0.0.1:4170}'
|
||||
token: '${QWEN_MANAGED_AGENT_HARNESS_TOKEN:}'
|
||||
capability-digest: '${QWEN_MANAGED_AGENT_CAPABILITY_DIGEST:}'
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,8 +3,11 @@ package com.alibaba.qwen.code.managedagent;
|
|||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
import com.alibaba.qwen.code.daemon.HarnessSessionRef;
|
||||
import com.alibaba.qwen.code.daemon.HostedHarnessClient;
|
||||
import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor;
|
||||
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.service.EmbeddedRuntimeBroker;
|
||||
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
|
@ -27,12 +30,16 @@ import java.nio.file.attribute.PosixFilePermissions;
|
|||
import java.security.Principal;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -70,7 +77,16 @@ class HostedPublicWorkspaceIT {
|
|||
private Path decoy;
|
||||
private String node;
|
||||
private boolean approvals;
|
||||
private boolean shell;
|
||||
private String shellDecision = "allow";
|
||||
private boolean durableClose;
|
||||
private String shellFault;
|
||||
private HttpServer storeRelay;
|
||||
private ExecutorService relayExecutor;
|
||||
private HostedShellOutputProbe shellProbe;
|
||||
private Map<String, Object> faultSession;
|
||||
private final List<byte[]> receiptRequests = new CopyOnWriteArrayList<>();
|
||||
private final AtomicInteger receiptResponses = new AtomicInteger();
|
||||
private final java.util.Set<String> answered = new java.util.HashSet<>();
|
||||
// Sessions whose first approval was answered by the operator who is not
|
||||
// the Session owner, proving R1's second-operator handoff.
|
||||
|
|
@ -95,6 +111,155 @@ class HostedPublicWorkspaceIT {
|
|||
assertThat(operatorAnswered).hasSize(2);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"allow", "deny"})
|
||||
@Timeout(150)
|
||||
void publicForegroundShellUsesMandatoryApprovalOnBothSurfaces(String decision) throws Exception {
|
||||
shell = true;
|
||||
approvals = true;
|
||||
shellDecision = decision;
|
||||
List<Path> roots = boot();
|
||||
for (int index = 0; index < roots.size(); index++) {
|
||||
boolean web = index == 1;
|
||||
String workspace = "workspace-" + index;
|
||||
register(workspace, "storage-" + index);
|
||||
Map<String, Object> input = Map.of("type", "input_text", "text", "G0_SHELL");
|
||||
String route = web ? "/api/agent/web-shell/v1/sessions/create" : "/v1/agents/sessions";
|
||||
Map<String, Object> body = web
|
||||
? Map.of("agentId", "qwen-code", "idempotencyKey", workspace, "input", List.of(),
|
||||
"workspace", Map.of("workspaceId", workspace, "cwdRelative", "child"))
|
||||
: Map.of("agent_id", "qwen-code", "input", List.of(input), "workspace",
|
||||
Map.of("workspace_id", workspace, "cwd_relative", "child"));
|
||||
JsonNode created = request("POST", route, body, workspace, "actor", 202);
|
||||
String session = created.path(web ? "sessionId" : "id").asText();
|
||||
JsonNode supported = web ? request("POST", "/api/agent/web-shell/v1/sessions/get",
|
||||
Map.of("sessionId", session), null, "actor", 200) : created;
|
||||
assertThat(supported.at(web ? "/capabilities/foregroundShell" : "/capabilities/foreground_shell").asBoolean()).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT tool_profile FROM managed_agent_session WHERE session_id = ?",
|
||||
String.class, session)).isEqualTo("hosted-workspace-shell/1");
|
||||
assertThat(jdbc.queryForObject("SELECT approval_mode FROM managed_agent_session WHERE session_id = ?",
|
||||
String.class, session)).isEqualTo("deny".equals(decision) ? "auto-edit" : "default");
|
||||
if (web) request("POST", "/api/agent/web-shell/v1/turns/submit",
|
||||
Map.of("sessionId", session, "idempotencyKey", "shell-turn", "input", List.of(input)),
|
||||
"shell-turn", "actor", 202);
|
||||
await().atMost(Duration.ofSeconds(35)).failFast(() -> {
|
||||
if (modelFailure.get() != null) throw new AssertionError("Model fixture failed", modelFailure.get());
|
||||
}).untilAsserted(() -> {
|
||||
answerActions(session, web);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM managed_agent_turn WHERE session_id = ?",
|
||||
String.class, session)).isEqualTo("COMPLETED");
|
||||
});
|
||||
Path marker = roots.get(index).resolve("child/shell-proof.txt");
|
||||
if ("allow".equals(decision)) assertThat(Files.readString(marker)).isEqualTo("once\n");
|
||||
else assertThat(marker).doesNotExist();
|
||||
assertThat(decoy.resolve("shell-proof.txt")).doesNotExist();
|
||||
assertThat(roots.get(index).resolve("child/monitor-proof.txt")).doesNotExist();
|
||||
assertThat(roots.get(index).resolve("child/background-proof.txt")).doesNotExist();
|
||||
assertThat(request("POST", route, body, workspace, "actor", 202)
|
||||
.path(web ? "sessionId" : "id").asText()).isEqualTo(session);
|
||||
for (String kind : List.of("close", "archive", "delete")) {
|
||||
boolean restDelete = !web && "delete".equals(kind);
|
||||
String lifecycle = web ? "/api/agent/web-shell/v1/sessions/" + kind
|
||||
: "/v1/agents/sessions/" + session + (restDelete ? "" : "/" + kind);
|
||||
Object command = web ? Map.of("sessionId", session, "idempotencyKey", kind) : null;
|
||||
assertUnavailable(request(restDelete ? "DELETE" : "POST", lifecycle, command, kind, "actor", 409));
|
||||
}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_operation WHERE session_id = ?"
|
||||
+ " AND operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE')", Integer.class, session)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_turn WHERE session_id = ?",
|
||||
Integer.class, session)).isEqualTo(1);
|
||||
if ("allow".equals(decision)) assertThat(Files.readString(marker)).isEqualTo("once\n");
|
||||
else assertThat(marker).doesNotExist();
|
||||
}
|
||||
assertThat(answered).hasSize(2);
|
||||
assertThat(modelFailure.get()).isNull();
|
||||
assertThat(modelRequests).hasSize(8);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"receipt-failure", "receipt-reply"})
|
||||
@Timeout(150)
|
||||
void publicShellReceiptFaultsNeverRepeatEffects(String fault) throws Exception {
|
||||
assertThat(System.getProperty("mysql.url")).as("Public FG6f requires real MySQL").startsWith("jdbc:mysql:");
|
||||
shell = true;
|
||||
approvals = true;
|
||||
shellFault = fault;
|
||||
Path directory = boot().getFirst().resolve("child");
|
||||
register("workspace-0", "storage-0");
|
||||
Files.writeString(directory.resolve("proof.txt"), "x");
|
||||
var body = Map.of("agent_id", "qwen-code", "input", List.of(), "workspace",
|
||||
Map.of("workspace_id", "workspace-0", "cwd_relative", "child"));
|
||||
String session = request("POST", "/v1/agents/sessions", body, "fault-create", "actor", 202).path("id").asText();
|
||||
faultSession = Map.of("sessionId", session, "workspaceId", "workspace-0", "directory", directory.toString(), "fault", fault);
|
||||
shellProbe = new HostedShellOutputProbe(jdbc, tenant, List.of(faultSession),
|
||||
spring.getBean(EmbeddedRuntimeBroker.class), storeRelay);
|
||||
String trigger = "public_shell_" + UUID.randomUUID().toString().replace("-", "");
|
||||
if ("receipt-failure".equals(fault)) {
|
||||
assertThat(tenant).matches("g0-[0-9a-f-]{36}");
|
||||
assertThat(session).matches("[0-9a-f-]{36}");
|
||||
jdbc.execute("CREATE TRIGGER " + trigger + " BEFORE INSERT ON qwen_managed_session_journal_tx FOR EACH ROW BEGIN IF"
|
||||
+ " NEW.tenant_id = '" + tenant + "' AND NEW.session_id = '" + session + "'"
|
||||
+ " AND NEW.operation = 'recordToolResult'"
|
||||
+ " AND LOCATE('\"kind\":\"tool.receipt\"', CONVERT(NEW.record_bytes USING utf8mb4)) > 0"
|
||||
+ " THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PUBLIC_FG6F_receipt-failure'; END IF; END");
|
||||
}
|
||||
try {
|
||||
var input = List.of(Map.of("type", "input_text", "text", "G0_SHELL_FAULT"));
|
||||
String turn = request("POST", "/v1/agents/sessions/" + session + "/events", Map.of("type", "agent.session.input.message", "input", input),
|
||||
"fault-turn", "actor", 202).path("turn_id").asText();
|
||||
await().atMost(Duration.ofSeconds(60)).failFast(() -> {
|
||||
if (modelFailure.get() != null) throw new AssertionError("Fault fixture failed", modelFailure.get());
|
||||
}).untilAsserted(() -> {
|
||||
answerActions(session, false);
|
||||
assertThat(receiptRequests).hasSize(3);
|
||||
assertThat(jdbc.queryForList("SELECT execution_state FROM qwen_tool_execution WHERE harness_session_id = ?",
|
||||
String.class, session)).containsExactly("UNKNOWN");
|
||||
});
|
||||
var connector = spring.getBean(HarnessConnector.class);
|
||||
var attachments = (Map<?, ?>) ReflectionTestUtils.getField(connector, "attachments");
|
||||
var attached = attachments.values().stream().map(HarnessSessionRef.class::cast)
|
||||
.filter(ref -> session.equals(ref.getHarnessSessionId())).findFirst().orElseThrow();
|
||||
var client = (HostedHarnessClient) ReflectionTestUtils.getField(connector, "client");
|
||||
// The third request is counted before its handler finishes; wait for the
|
||||
// producer's prompt and all receipt handlers before taking final evidence.
|
||||
await().atMost(Duration.ofSeconds(15)).failFast(() -> {
|
||||
if (modelFailure.get() != null) throw new AssertionError("Fault fixture failed", modelFailure.get());
|
||||
}).untilAsserted(() -> {
|
||||
var status = client.getStatus(attached);
|
||||
assertThat(status.hasActivePrompt()).isFalse();
|
||||
assertThat(status.getRaw().get("recoveryBlocked")).isEqualTo(true);
|
||||
assertThat(receiptResponses.get()).isEqualTo(3);
|
||||
});
|
||||
assertThat(receiptRequests).hasSize(3);
|
||||
assertThat(answered).hasSize(1);
|
||||
assertThat(modelRequests).hasSize(1);
|
||||
assertThat(jdbc.queryForObject("SELECT tool_profile FROM managed_agent_session WHERE session_id = ?",
|
||||
String.class, session)).isEqualTo("hosted-workspace-shell/1");
|
||||
assertThat(jdbc.queryForObject("SELECT approval_mode FROM managed_agent_session WHERE session_id = ?",
|
||||
String.class, session)).isEqualTo("default");
|
||||
var original = json.readTree(receiptRequests.getFirst());
|
||||
for (byte[] retry : receiptRequests) assertThat(retry).isEqualTo(receiptRequests.getFirst());
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_tx WHERE tenant_id = ?"
|
||||
+ " AND session_id = ? AND transaction_id = ?", Integer.class, tenant, session,
|
||||
original.path("transactionId").asText())).isEqualTo("receipt-reply".equals(fault) ? 1 : 0);
|
||||
assertThat(jdbc.queryForObject("SELECT prompt_id FROM managed_agent_turn WHERE session_id = ? AND turn_id = ?",
|
||||
String.class, session, turn)).isEqualTo(jdbc.queryForObject(
|
||||
"SELECT turn_id FROM qwen_tool_execution WHERE harness_session_id = ?", String.class, session));
|
||||
assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", Map.of("type", "agent.session.input.message", "input", input),
|
||||
"fault-turn", "actor", 202).path("turn_id").asText()).isEqualTo(turn);
|
||||
shellProbe.assertPublicFault(faultSession);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_turn WHERE session_id = ?", Integer.class, session)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM managed_agent_turn WHERE session_id = ?", String.class, session)).isNotEqualTo("COMPLETED");
|
||||
assertThat(decoy.resolve("proof.txt")).doesNotExist();
|
||||
assertThat(modelFailure.get()).isNull();
|
||||
assertThat(receiptRequests).hasSize(3);
|
||||
System.out.println("PUBLIC_FG6F " + fault + " session=" + session + " turn=" + turn
|
||||
+ " receiptAttempts=" + receiptRequests.size() + " effect=once");
|
||||
} finally {
|
||||
if ("receipt-failure".equals(fault)) jdbc.execute("DROP TRIGGER " + trigger);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
@EnabledOnOs(OS.LINUX)
|
||||
|
|
@ -454,6 +619,13 @@ class HostedPublicWorkspaceIT {
|
|||
model = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
model.createContext("/v1/chat/completions", this::modelReply);
|
||||
model.start();
|
||||
if (shellFault != null) {
|
||||
storeRelay = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
relayExecutor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
storeRelay.setExecutor(relayExecutor);
|
||||
storeRelay.createContext("/internal/managed-session-store/", this::relayStore);
|
||||
storeRelay.start();
|
||||
}
|
||||
startSpring(cli, roots, harnessPort, brokerPort);
|
||||
startHarness(cli, harnessPort, brokerPort);
|
||||
return roots;
|
||||
|
|
@ -787,7 +959,8 @@ class HostedPublicWorkspaceIT {
|
|||
"--spring.datasource.username=" + System.getProperty("mysql.user", "sa"),
|
||||
"--spring.datasource.password=" + System.getProperty("mysql.password", ""),
|
||||
"--qwen.managed-agent.session-store.enabled=true",
|
||||
"--qwen.managed-agent.session-store.base-url=http://127.0.0.1:" + port,
|
||||
"--qwen.managed-agent.session-store.base-url=http://127.0.0.1:"
|
||||
+ (storeRelay == null ? port : storeRelay.getAddress().getPort()),
|
||||
"--qwen.managed-agent.session-store.workspace-id=unused-global-workspace",
|
||||
"--qwen.managed-agent.harness.enabled=true",
|
||||
"--qwen.managed-agent.harness.workspace-files-enabled=true",
|
||||
|
|
@ -805,7 +978,9 @@ class HostedPublicWorkspaceIT {
|
|||
"--qwen.managed-agent.runtime-broker.node-executable=" + node,
|
||||
"--qwen.managed-agent.runtime-broker.worker-entry=" + cli,
|
||||
"--qwen.managed-agent.runtime-broker.cli-entry=" + cli));
|
||||
if (approvals) arguments.add("--qwen.managed-agent.harness.approval-mode=default");
|
||||
if (approvals) arguments.add("--qwen.managed-agent.harness.approval-mode="
|
||||
+ (shell && "deny".equals(shellDecision) ? "auto-edit" : "default"));
|
||||
if (shell) arguments.add("--qwen.managed-agent.harness.workspace-shell-enabled=true");
|
||||
arguments.add("--qwen.managed-agent.runtime-broker.durable-local-process=" + durableClose);
|
||||
for (int i = 0; i < roots.size(); i++) {
|
||||
String prefix = "--qwen.managed-agent.runtime-broker.workspace-mounts[" + i + "].";
|
||||
|
|
@ -858,6 +1033,15 @@ class HostedPublicWorkspaceIT {
|
|||
}
|
||||
|
||||
private void answerActions(String session, boolean web) throws Exception {
|
||||
try {
|
||||
doAnswerActions(session, web);
|
||||
} catch (AssertionError error) {
|
||||
// untilAsserted must not retry a one-shot response after its ID is deduped.
|
||||
throw new IllegalStateException("Action response fixture failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
private void doAnswerActions(String session, boolean web) throws Exception {
|
||||
JsonNode capability = request("GET", "/v1/agents/sessions/" + session, null, null, "actor", 200);
|
||||
assertThat(capability.at("/capabilities/actions").asBoolean()).isTrue();
|
||||
JsonNode page = web ? request("POST", "/api/agent/web-shell/v1/actions/query",
|
||||
|
|
@ -869,21 +1053,19 @@ class HostedPublicWorkspaceIT {
|
|||
if (!answered.add(id)) continue;
|
||||
String route = web ? "/api/agent/web-shell/v1/actions/respond"
|
||||
: "/v1/agents/sessions/" + session + "/actions/" + id + "/responses";
|
||||
Map<String, Object> response = web ? Map.of("kind", "permission", "optionId", "allow",
|
||||
Map<String, Object> response = web ? Map.of("kind", "permission", "optionId", shellDecision,
|
||||
"inputRevision", action.path("inputRevision").asLong(), "policyRevision", action.path("policyRevision").asText())
|
||||
: Map.of("kind", "permission", "option_id", "allow", "input_revision", action.path("input_revision").asLong(),
|
||||
: Map.of("kind", "permission", "option_id", shellDecision, "input_revision", action.path("input_revision").asLong(),
|
||||
"policy_revision", action.path("policy_revision").asText());
|
||||
Map<String, Object> body = web ? Map.of("sessionId", session, "actionId", id, "idempotencyKey", id,
|
||||
"requestId", "d6b-action", "response", response) : response;
|
||||
assertThat(request("POST", route, body, id, "reader", 403).at("/error/code").asText()).isEqualTo("action_forbidden");
|
||||
// The live Harness retains this approval while the dispatcher's attachment cache is cold.
|
||||
((Map<?, ?>) ReflectionTestUtils.getField(spring.getBean(HarnessConnector.class), "attachments")).clear();
|
||||
// The first approval of each session is answered by a second
|
||||
// OPERATOR who is not the Session owner (R1's handoff); the
|
||||
// rest by the owner, so both responder paths stay covered. The
|
||||
// set records ONLY the chosen responder, so collapsing the
|
||||
// ternary to the owner empties it instead of staying full.
|
||||
String responder = fixedAnswered.add(session) ? "operator2" : "actor";
|
||||
// Files sessions answer the first approval as a second OPERATOR
|
||||
// (R1's handoff) and the rest as the owner. A Shell answer runs
|
||||
// a command, so only the recorded owner may answer it.
|
||||
String responder = !shell && fixedAnswered.add(session) ? "operator2" : "actor";
|
||||
if ("operator2".equals(responder)) {
|
||||
operatorAnswered.add(session);
|
||||
}
|
||||
|
|
@ -946,7 +1128,8 @@ class HostedPublicWorkspaceIT {
|
|||
modelRequests.add(body);
|
||||
List<String> tools = new ArrayList<>();
|
||||
body.path("tools").forEach(tool -> tools.add(tool.path("function").path("name").asText()));
|
||||
assertThat(tools).containsExactlyInAnyOrder("read_file", "write_file", "edit");
|
||||
if (shell) assertThat(tools).containsExactlyInAnyOrder("read_file", "write_file", "edit", "run_shell_command", "monitor");
|
||||
else assertThat(tools).containsExactlyInAnyOrder("read_file", "write_file", "edit");
|
||||
// Count only this Turn's tool results, after the latest fixture prompt, so a later
|
||||
// Turn in the same Session runs the same write, edit and read sequence. Other user
|
||||
// messages the Harness may add do not restart the count.
|
||||
|
|
@ -969,18 +1152,35 @@ class HostedPublicWorkspaceIT {
|
|||
return;
|
||||
}
|
||||
int step = results.size();
|
||||
if (step == 3) assertThat(results.get(2).toString()).contains("after");
|
||||
if (!shell && step == 3) assertThat(results.get(2).toString()).contains("after");
|
||||
if (shell && shellFault == null && step >= 1) assertThat(results.get(0).toString()).contains("Hosted Monitor is unavailable");
|
||||
if (shell && shellFault == null && step >= 2) assertThat(results.get(1).toString()).contains("Hosted Shell requires a foreground command");
|
||||
if (shell && shellFault == null && step >= 3 && "allow".equals(shellDecision)) {
|
||||
assertThat(results.get(2).toString()).contains("G0_SHELL_STDOUT");
|
||||
}
|
||||
var chunk = json.createObjectNode().put("id", "g0").put("object", "chat.completion.chunk")
|
||||
.put("created", 0).put("model", "g0-fixture");
|
||||
var choice = chunk.putArray("choices").addObject().put("index", 0);
|
||||
var delta = choice.putObject("delta").put("role", "assistant");
|
||||
if (step < 3) {
|
||||
String name = List.of("write_file", "edit", "read_file").get(step);
|
||||
Map<String, String> args = switch (step) {
|
||||
boolean toolCall = step < (shellFault == null ? 3 : 1);
|
||||
if (toolCall) {
|
||||
String name = shell ? (shellFault == null && step == 0 ? "monitor" : "run_shell_command")
|
||||
: List.of("write_file", "edit", "read_file").get(step);
|
||||
Map<String, Object> args = shell ? switch (step) {
|
||||
case 0 -> Map.of("command", "printf 'monitor\\n' >> monitor-proof.txt");
|
||||
case 1 -> Map.of("command", "printf 'background\\n' >> background-proof.txt", "is_background", true);
|
||||
default -> Map.of("command", "printf 'once\\n' >> shell-proof.txt; pwd; printf 'G0_SHELL_STDOUT\\n'", "description", "Write a Shell proof");
|
||||
} : switch (step) {
|
||||
case 0 -> Map.of("file_path", "proof.txt", "content", "before");
|
||||
case 1 -> Map.of("file_path", "proof.txt", "old_string", "before", "new_string", "after");
|
||||
default -> Map.of("file_path", "proof.txt");
|
||||
};
|
||||
if (shellFault != null) {
|
||||
String producer = "const fs = require('fs'); fs.appendFileSync('proof.txt', 'x');"
|
||||
+ " process.stdout.write(Buffer.alloc(1024 * 1024, 0x61), () => {"
|
||||
+ " process.stdout.write('stdout-tail\\n'); process.stderr.write('stderr-tail\\n'); });";
|
||||
args = Map.of("command", shellQuote(node) + " -e " + shellQuote(producer), "timeout", 60000);
|
||||
}
|
||||
delta.putArray("tool_calls").addObject().put("index", 0).put("id", "g0-tool-" + step)
|
||||
.put("type", "function").putObject("function").put("name", name)
|
||||
.put("arguments", json.writeValueAsString(args));
|
||||
|
|
@ -988,7 +1188,7 @@ class HostedPublicWorkspaceIT {
|
|||
choice.putNull("finish_reason");
|
||||
String first = "data: " + json.writeValueAsString(chunk) + "\n\n";
|
||||
choice.putObject("delta");
|
||||
choice.put("finish_reason", step < 3 ? "tool_calls" : "stop");
|
||||
choice.put("finish_reason", toolCall ? "tool_calls" : "stop");
|
||||
byte[] response = (first + "data: " + json.writeValueAsString(chunk) + "\n\ndata: [DONE]\n\n")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
|
||||
|
|
@ -1001,6 +1201,55 @@ class HostedPublicWorkspaceIT {
|
|||
}
|
||||
}
|
||||
|
||||
private void relayStore(HttpExchange exchange) throws IOException {
|
||||
boolean receipt = false;
|
||||
try {
|
||||
byte[] body = exchange.getRequestBody().readAllBytes();
|
||||
JsonNode fields = body.length == 0 ? json.createObjectNode() : json.readTree(body);
|
||||
String records = fields.has("recordBytesBase64")
|
||||
? new String(Base64.getDecoder().decode(fields.path("recordBytesBase64").asText()), StandardCharsets.UTF_8) : "";
|
||||
receipt = records.lines().anyMatch(line -> {
|
||||
try { return "tool.receipt".equals(json.readTree(line).path("managedSession").path("kind").asText()); }
|
||||
catch (IOException error) { throw new IllegalStateException(error); }
|
||||
});
|
||||
if (receipt) {
|
||||
receiptRequests.add(body);
|
||||
assertThat(receiptRequests).as("Harness receipt retry budget").hasSizeLessThanOrEqualTo(3);
|
||||
}
|
||||
HttpRequest.Builder forwarded = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + exchange.getRequestURI()))
|
||||
.timeout(Duration.ofSeconds(15)).method(exchange.getRequestMethod(), body.length == 0
|
||||
? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
exchange.getRequestHeaders().forEach((name, values) -> {
|
||||
if (!List.of("host", "connection", "content-length", "upgrade", "expect").contains(name.toLowerCase(java.util.Locale.ROOT)))
|
||||
values.forEach(value -> forwarded.header(name, value));
|
||||
});
|
||||
var response = http.send(forwarded.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (receipt) {
|
||||
assertThat(response.statusCode()).isEqualTo("receipt-failure".equals(shellFault) ? 500 : 200);
|
||||
if ("receipt-reply".equals(shellFault)) return;
|
||||
} else if ("managed-tool-result-content".equals(fields.path("kind").asText()) && fields.path("byteLength").asInt() == 1024 * 1024) {
|
||||
assertThat(response.statusCode()).as("%s: %s", exchange.getRequestURI(), new String(response.body(), StandardCharsets.UTF_8)).isEqualTo(200);
|
||||
shellProbe.observePublicPrefix(faultSession);
|
||||
}
|
||||
response.headers().map().forEach((name, values) -> {
|
||||
if (!List.of("connection", "content-length", "transfer-encoding").contains(name.toLowerCase(java.util.Locale.ROOT))) {
|
||||
exchange.getResponseHeaders().put(name, values);
|
||||
}
|
||||
});
|
||||
exchange.sendResponseHeaders(response.statusCode(), response.body().length);
|
||||
exchange.getResponseBody().write(response.body());
|
||||
} catch (Exception | AssertionError error) {
|
||||
modelFailure.compareAndSet(null, error);
|
||||
} finally {
|
||||
exchange.close();
|
||||
if (receipt) receiptResponses.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private static String shellQuote(String value) {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
private JsonNode request(String method, String path, Object body, String key, String actor, int expected)
|
||||
throws Exception {
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
|
||||
|
|
@ -1026,13 +1275,30 @@ class HostedPublicWorkspaceIT {
|
|||
|
||||
@AfterEach
|
||||
void stop() throws Exception {
|
||||
if (harness != null) {
|
||||
harness.descendants().forEach(ProcessHandle::destroyForcibly);
|
||||
harness.destroyForcibly();
|
||||
harness.waitFor(10, TimeUnit.SECONDS);
|
||||
Throwable failure = null;
|
||||
List<org.junit.jupiter.api.function.Executable> cleanup = List.of(
|
||||
() -> {
|
||||
if (harness != null) {
|
||||
harness.descendants().forEach(ProcessHandle::destroyForcibly);
|
||||
harness.destroyForcibly();
|
||||
harness.waitFor(10, TimeUnit.SECONDS);
|
||||
}
|
||||
},
|
||||
() -> { if (shellProbe != null) shellProbe.close(); },
|
||||
() -> { if (spring != null) spring.close(); },
|
||||
() -> { if (storeRelay != null) storeRelay.stop(0); },
|
||||
() -> { if (relayExecutor != null) relayExecutor.close(); },
|
||||
() -> { if (durableClose) HostedDurableWorkers.stop(temporary.resolve("broker"), json); },
|
||||
() -> { if (model != null) model.stop(0); });
|
||||
for (var step : cleanup) {
|
||||
try {
|
||||
step.execute();
|
||||
} catch (Throwable error) {
|
||||
if (failure == null) failure = error;
|
||||
else failure.addSuppressed(error);
|
||||
}
|
||||
}
|
||||
if (spring != null) spring.close();
|
||||
if (durableClose) HostedDurableWorkers.stop(temporary.resolve("broker"), json);
|
||||
if (model != null) model.stop(0);
|
||||
if (failure instanceof Exception error) throw error;
|
||||
if (failure instanceof Error error) throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,6 +319,29 @@ final class HostedShellOutputProbe implements AutoCloseable {
|
|||
}
|
||||
}
|
||||
|
||||
void observePublicPrefix(Map<String, Object> session) throws Exception {
|
||||
try {
|
||||
String id = session.get("sessionId").toString();
|
||||
assertThat(owners.putIfAbsent(id, owner(session))).isNull();
|
||||
capture(worker(execution(id)));
|
||||
check(session, "prefix");
|
||||
} catch (Exception | AssertionError error) {
|
||||
failure.compareAndSet(null, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
void assertPublicFault(Map<String, Object> session) throws Exception {
|
||||
assertThat(failure.get()).as("public Shell output probe").isNull();
|
||||
check(session, "finished");
|
||||
var row = execution(session.get("sessionId").toString());
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_tool_execution WHERE harness_session_id = ?",
|
||||
Integer.class, session.get("sessionId"))).isEqualTo(1);
|
||||
assertThat(((Number) row.get("dispatch_generation")).longValue()).isEqualTo(1);
|
||||
System.out.println("PUBLIC_FG6F_LEDGER " + session.get("fault") + " original=" + row.get("execution_call_id")
|
||||
+ " state=UNKNOWN owner=retained");
|
||||
}
|
||||
|
||||
void assertReport(Map<String, Object> session, JsonNode report) throws Exception {
|
||||
assertThat(failure.get()).as("independent Shell output probe").isNull();
|
||||
assertThat(report.path("fault").asText()).isEqualTo(session.get("fault"));
|
||||
|
|
|
|||
|
|
@ -638,15 +638,9 @@ class HostedTeamLeadMySqlIT {
|
|||
private String createLead(String marker) throws Exception {
|
||||
TransactionTemplate transaction = new TransactionTemplate(spring.getBean(PlatformTransactionManager.class));
|
||||
ManagedAgentStore store = spring.getBean(ManagedAgentStore.class);
|
||||
String session = transaction.execute(status -> {
|
||||
var created = store.insertWorkspaceSessionCommand(tenant, "actor", "create-" + marker, DIGEST,
|
||||
"qwen-code", null, null, List.of(), null, new WorkspaceSelection("workspace-0", "child"));
|
||||
// No public route creates a Shell-laned Session: set the profile
|
||||
// before anything can read the row.
|
||||
jdbc.update("UPDATE managed_agent_session SET tool_profile = 'hosted-workspace-shell/1'"
|
||||
+ " WHERE tenant_id = ? AND session_id = ?", tenant, created.sessionId());
|
||||
return created.sessionId();
|
||||
});
|
||||
String session = transaction.execute(status -> store.insertInternalTeamLeadSessionCommand(
|
||||
tenant, "actor", "create-" + marker, DIGEST, null,
|
||||
new WorkspaceSelection("workspace-0", "child")).sessionId());
|
||||
leadSession = session;
|
||||
waiting().atMost(Duration.ofSeconds(30)).pollInterval(Duration.ofSeconds(1)).ignoreExceptions().untilAsserted(() ->
|
||||
request("POST", "/v1/agents/sessions/" + session + "/events", Map.of("type",
|
||||
|
|
|
|||
|
|
@ -18,12 +18,15 @@ import ch.qos.logback.core.read.ListAppender;
|
|||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor;
|
||||
import com.alibaba.qwen.code.managedagent.api.TenantContextFilter;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.service.ActionResponseCoordinator;
|
||||
import com.alibaba.qwen.code.managedagent.service.ManagedActionService;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedActionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitTransactionRequest;
|
||||
|
|
@ -46,6 +49,10 @@ import org.slf4j.LoggerFactory;
|
|||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.bean.override.convention.TestBean;
|
||||
|
|
@ -76,6 +83,7 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||
"qwen.managed-agent.dispatch.scan-delay=50ms"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@Import(ManagedActionsTest.Configuration.class)
|
||||
class ManagedActionsTest {
|
||||
@Autowired private MockMvc mvc;
|
||||
@Autowired private ObjectMapper json;
|
||||
|
|
@ -83,6 +91,7 @@ class ManagedActionsTest {
|
|||
@Autowired private ManagedSessionStore journals;
|
||||
@Autowired private ManagedActionStore actions;
|
||||
@Autowired private AgentStateStore sessions;
|
||||
@Autowired private ManagedAgentStore store;
|
||||
|
||||
@TestBean(methodName = "createHarness")
|
||||
private HarnessConnector harness;
|
||||
|
|
@ -104,6 +113,17 @@ class ManagedActionsTest {
|
|||
logged.stop();
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class Configuration {
|
||||
@Bean @Primary
|
||||
ManagedAgentStore actionStore(JdbcTemplate jdbc, ObjectMapper mapper, ManagedWorkspaceRegistry registry,
|
||||
Clock clock, com.alibaba.qwen.code.managedagent.store.CommittedEventPublisher publisher) {
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
return new ManagedAgentStore(jdbc, mapper, clock, publisher, registry, properties);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<String, Answer<Void>> responses = new ConcurrentHashMap<>();
|
||||
|
||||
static HarnessConnector createHarness() {
|
||||
|
|
@ -571,11 +591,30 @@ class ManagedActionsTest {
|
|||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerResponseReplaysAcrossSurfacesAndUsesCommittedDecisionAfterLostAnswer()
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void ownerResponseReplaysAcrossSurfacesAndUsesCommittedDecisionAfterLostAnswer(boolean shell)
|
||||
throws Exception {
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
if (shell) {
|
||||
assertThat(store.workspaceFilesEnabled()).isTrue();
|
||||
assertThat(store.workspaceShellEnabled()).isFalse();
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES (?, 'workspace-actions', 1, 'storage', 'workspace', ?, ?, 'ACTIVE')",
|
||||
tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
for (String actor : List.of("owner", "other")) {
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace-actions', ?, 'OPERATOR')", tenant, actor.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
jdbc.update("UPDATE managed_agent_session SET workspace_storage_id = 'storage', workspace_id = 'workspace-actions',"
|
||||
+ " workspace_generation = 1, cwd_relative = '.', context_config_ref = ?, context_revision = 1,"
|
||||
+ " workspace_config_ref = ?, workspace_policy_ref = ?, tool_profile = 'hosted-workspace-shell/1', approval_mode = 'default'"
|
||||
+ " WHERE tenant_id = ? AND session_id = ?", WorkspaceExecutionProfile.CONTEXT_CONFIG_REF,
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF, tenant, session);
|
||||
assertThat(store.requireSession(tenant, session).toolProfile()).isEqualTo("hosted-workspace-shell/1");
|
||||
assertThat(store.requireSession(tenant, session).approvalMode()).isEqualTo("default");
|
||||
}
|
||||
ActionJournal journal =
|
||||
action(tenant, session, System.currentTimeMillis(), 9007199254740991L);
|
||||
AtomicInteger delivered = new AtomicInteger();
|
||||
|
|
@ -623,6 +662,9 @@ class ManagedActionsTest {
|
|||
.validate("/components/schemas/PublicCommandOperation", result))
|
||||
.isEmpty();
|
||||
assertThat(result.at("/action_resolution/outcome").asText()).isEqualTo("decided");
|
||||
var decision = actions.find(tenant, session, journal.id).orElseThrow();
|
||||
assertThat(decision.state()).isEqualTo("decided");
|
||||
assertThat(decision.decisionReceiptId()).isEqualTo(result.at("/action_resolution/decision_receipt_id").asText());
|
||||
assertThat(result.at("/action_resolution/decision_receipt_id").asText())
|
||||
.startsWith("decision_");
|
||||
JsonNode replay =
|
||||
|
|
|
|||
|
|
@ -11,16 +11,20 @@ import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock;
|
|||
import com.alibaba.qwen.code.managedagent.api.AuthenticatedTenantActor;
|
||||
import com.alibaba.qwen.code.managedagent.api.TenantContextFilter;
|
||||
import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.service.ManagedAgentService;
|
||||
import com.alibaba.qwen.code.managedagent.service.RequestDigests;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
|
||||
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -29,6 +33,8 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* D8b and D8c-1 Session admission: a stored AgentDefinition revision pins
|
||||
|
|
@ -67,6 +73,115 @@ class ManagedAgentDefinitionAdmissionTest {
|
|||
private ManagedAgentStore store;
|
||||
@Autowired
|
||||
private ManagedWorkspaceRegistry registry;
|
||||
@Autowired
|
||||
private ManagedAgentDefinitionStore definitions;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({
|
||||
"false, default", "false, yolo", "true, default", "true, yolo"
|
||||
})
|
||||
void shellOptInSelectsOnlyTheBuiltInProfile(boolean shellEnabled,
|
||||
String definitionApproval) throws Exception {
|
||||
String tenant = tenant();
|
||||
register(tenant, "ws-a", "storage-a");
|
||||
grant(tenant, "ws-a", "actor", true);
|
||||
JsonNode definition = define(tenant, "definition",
|
||||
FILES_DEFINITION.replace("\"default\"",
|
||||
"\"" + definitionApproval + "\""));
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
properties.getHarness().setWorkspaceShellEnabled(shellEnabled);
|
||||
properties.getHarness().setApprovalMode("default");
|
||||
var selected = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(),
|
||||
ignored -> {}, registry, properties);
|
||||
selected.setManagedAgentDefinitionStore(definitions);
|
||||
var transaction = new TransactionTemplate(transactionManager);
|
||||
var workspace = new WorkspaceSelection("ws-a", ".");
|
||||
String builtin = transaction.execute(status ->
|
||||
selected.insertWorkspaceSessionCommand(tenant, "actor",
|
||||
"builtin", "builtin-digest", "qwen-code", null,
|
||||
null, List.of(), null, workspace).sessionId());
|
||||
String custom = transaction.execute(status ->
|
||||
selected.insertWorkspaceSessionCommand(tenant, "actor",
|
||||
"custom", "custom-digest", definition.get("id").asText(),
|
||||
null, null, List.of(), null, workspace).sessionId());
|
||||
assertThat(selected.requireSession(tenant, builtin).toolProfile())
|
||||
.isEqualTo(shellEnabled ? "hosted-workspace-shell/1"
|
||||
: "hosted-workspace-files/1");
|
||||
var pinned = selected.requireSession(tenant, custom);
|
||||
assertThat(pinned.toolProfile()).isEqualTo("hosted-workspace-files/1");
|
||||
assertThat(pinned.agentDefinitionDigest())
|
||||
.isEqualTo(definition.get("digest").asText());
|
||||
assertThat(jdbc.queryForObject("SELECT approval_mode FROM managed_agent_session"
|
||||
+ " WHERE tenant_id = ? AND session_id = ?",
|
||||
String.class, tenant, custom)).isEqualTo(definitionApproval);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"default", "auto-edit"})
|
||||
void customShellInitialInputRequiresOptInButEmptyCreationAndReplayDoNot(
|
||||
String mode) throws Exception {
|
||||
String tenant = tenant();
|
||||
register(tenant, "ws-a", "storage-a", WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
grant(tenant, "ws-a", "actor", true);
|
||||
JsonNode definition = define(tenant, "definition", FILES_DEFINITION
|
||||
.replace("hosted-workspace-files/1", "hosted-workspace-shell/1")
|
||||
.replace("\"default\"", "\"" + mode + "\""));
|
||||
String agentId = definition.get("id").asText();
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.setDefinitionToolProfiles(List.of("hosted-workspace-shell/1"));
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
properties.getHarness().setWorkspaceShellEnabled(true);
|
||||
properties.getRuntimeBroker().setWorkspaceMounts(List.of(
|
||||
new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(
|
||||
tenant, "storage-a", "/workspace")));
|
||||
var enabled = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(),
|
||||
ignored -> {}, registry, properties);
|
||||
enabled.setManagedAgentDefinitionStore(definitions);
|
||||
var transaction = new TransactionTemplate(transactionManager);
|
||||
var workspace = new WorkspaceSelection("ws-a", ".");
|
||||
var input = List.<Map<String, Object>>of(
|
||||
Map.of("type", "input_text", "text", "go"));
|
||||
var initial = transaction.execute(status -> enabled.insertWorkspaceSessionCommand(
|
||||
tenant, "actor", "initial", "initial-digest", agentId, null,
|
||||
null, input, "payload-digest", workspace));
|
||||
assertThat(initial.turnId()).isNotNull();
|
||||
assertThat(enabled.requireSession(tenant, initial.sessionId()).approvalMode())
|
||||
.isEqualTo(mode);
|
||||
properties.getHarness().setWorkspaceShellEnabled(false);
|
||||
var disabled = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(),
|
||||
ignored -> {}, registry, properties);
|
||||
disabled.setManagedAgentDefinitionStore(definitions);
|
||||
var empty = transaction.execute(status -> disabled.insertWorkspaceSessionCommand(
|
||||
tenant, "actor", "empty", "empty-digest", agentId, null,
|
||||
null, List.of(), null, workspace));
|
||||
assertThat(empty.turnId()).isNull();
|
||||
assertThat(disabled.requireSession(tenant, empty.sessionId()).toolProfile())
|
||||
.isEqualTo("hosted-workspace-shell/1");
|
||||
assertThat(transaction.execute(status -> disabled.insertWorkspaceSessionCommand(
|
||||
tenant, "actor", "initial", "initial-digest", agentId, null,
|
||||
null, input, "payload-digest", workspace)).sessionId())
|
||||
.isEqualTo(initial.sessionId());
|
||||
assertThatThrownBy(() -> transaction.execute(status -> disabled.insertWorkspaceSessionCommand(
|
||||
tenant, "actor", "fresh", "fresh-digest", agentId, null,
|
||||
null, input, "payload-digest", workspace)))
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertThat(error.getCode()).isEqualTo("workspace_unavailable"));
|
||||
assertThatThrownBy(() -> transaction.execute(status -> disabled.insertTurnCommand(
|
||||
tenant, "SUBMIT_TURN", "later", "later-digest", empty.sessionId(),
|
||||
input, "payload-digest")))
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertThat(error.getCode()).isEqualTo("workspace_unavailable"));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?",
|
||||
Integer.class, tenant)).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_turn WHERE tenant_id = ?",
|
||||
Integer.class, tenant)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_workspace_create_command WHERE tenant_id = ?",
|
||||
Integer.class, tenant)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void omittedAndExplicitRevisionsPinAndExposeTheDigest() throws Exception {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,22 @@ class ManagedCwdChangeOperationTest {
|
|||
private static final String STORAGE = "storage-a";
|
||||
private final AtomicLong now = new AtomicLong(1_000_000);
|
||||
|
||||
@Test
|
||||
void shellProfileRetainsNonExecutingCwdAdmissionAndReplay() {
|
||||
Fixture fixture = fixture(true);
|
||||
String session = fixture.createBoundSession(TENANT, WS);
|
||||
fixture.jdbc.update("UPDATE managed_agent_session SET tool_profile = 'hosted-workspace-shell/1' WHERE session_id = ?", session);
|
||||
var admitted = begin(fixture, session, "shell-cwd", "digest", "services/b", 1);
|
||||
assertThat(admitted.operation().kind()).isEqualTo(OperationKind.CWD_CHANGE);
|
||||
assertThat(admitted.operation().targetCwdRelative()).isEqualTo("services/b");
|
||||
assertThat(admitted.operation().expectedContextRevision()).isEqualTo(1);
|
||||
assertThat(admitted.replayed()).isFalse();
|
||||
var replay = begin(fixture, session, "shell-cwd", "digest", "services/b", 1);
|
||||
assertThat(replay.replayed()).isTrue();
|
||||
assertThat(replay.operation().operationId())
|
||||
.isEqualTo(admitted.operation().operationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void admissionAcceptsReplaysAndConflictsByDigest() {
|
||||
Fixture fixture = fixture(true);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
|
@ -29,6 +30,7 @@ import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
|||
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind;
|
||||
import com.alibaba.qwen.code.managedagent.store.WorkspaceExecutionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.WorkspaceToolProfiles;
|
||||
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
|
@ -42,6 +44,7 @@ import java.util.concurrent.TimeUnit;
|
|||
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
|
|
@ -85,6 +88,282 @@ class ManagedWorkspaceAdmissionTest {
|
|||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"false, yolo", "false, default", "false, auto-edit", "true, default", "true, auto-edit"})
|
||||
void internalTeamLeadPinsPrivatePolicyAndSeparatesPublicReplay(boolean shellEnabled, String mode) {
|
||||
String tenant = "tenant-" + UUID.randomUUID();
|
||||
register(tenant, "ws-lead", "storage-lead", WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
grant(tenant, "ws-lead", "actor-a", true);
|
||||
grant(tenant, "ws-lead", "reader", false);
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
properties.getHarness().setWorkspaceShellEnabled(shellEnabled);
|
||||
properties.getHarness().setApprovalMode(mode);
|
||||
properties.getRuntimeBroker().setWorkspaceMounts(List.of(
|
||||
new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(tenant, "storage-lead", "/workspace")));
|
||||
var admitted = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {}, registry, properties);
|
||||
var transaction = new TransactionTemplate(transactionManager);
|
||||
var selection = new WorkspaceSelection("ws-lead", ".");
|
||||
var first = transaction.execute(status -> admitted.insertInternalTeamLeadSessionCommand(
|
||||
tenant, "actor-a", "internal", "digest", "lead", selection));
|
||||
var session = admitted.requireSession(tenant, first.sessionId());
|
||||
assertThat(session.agentId()).isEqualTo("qwen-code");
|
||||
assertThat(session.agentRevision()).isEqualTo(properties.getAgentRevision());
|
||||
assertThat(session.agentDefinitionDigest()).isNull();
|
||||
assertThat(session.approvalMode()).isEqualTo(mode);
|
||||
assertThat(session.toolProfile()).isEqualTo(WorkspaceToolProfiles.INTERNAL_TEAM_LEAD);
|
||||
assertThat(first.turnId()).isNull();
|
||||
assertThat(WorkspaceToolProfiles.isPublicShell(session.toolProfile())).isFalse();
|
||||
byte[] actorKey = "actor-a".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ? AND session_id = ?"
|
||||
+ " AND creator_actor_key = ? AND owner_actor_key = ?", Integer.class,
|
||||
tenant, first.sessionId(), actorKey, actorKey)).isEqualTo(1);
|
||||
properties.setAgentRevision("new-deployment");
|
||||
properties.getHarness().setApprovalMode("default");
|
||||
properties.getHarness().setWorkspaceShellEnabled(!shellEnabled);
|
||||
properties.getHarness().setWorkspaceFilesEnabled(false);
|
||||
var changed = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {}, registry, properties);
|
||||
var replay = transaction.execute(status -> changed.insertInternalTeamLeadSessionCommand(
|
||||
tenant, "actor-a", "internal", "digest", "ignored", selection));
|
||||
assertThat(replay.replayed()).isTrue();
|
||||
assertThat(replay.sessionId()).isEqualTo(first.sessionId());
|
||||
assertThat(changed.requireSession(tenant, first.sessionId())).isEqualTo(session);
|
||||
assertRefused(() -> transaction.execute(status -> changed.insertInternalTeamLeadSessionCommand(
|
||||
tenant, "actor-a", "fresh-disabled", "digest", null, selection)), "workspace_unavailable");
|
||||
assertRefused(() -> transaction.execute(status -> changed.insertInternalTeamLeadSessionCommand(
|
||||
tenant, "actor-a", "internal", "different", null, selection)), "idempotency_conflict");
|
||||
assertRefused(() -> transaction.execute(status -> admitted.insertWorkspaceSessionCommand(tenant, "actor-a",
|
||||
"internal", "digest", "qwen-code", null, null, List.of(), null, selection)), "idempotency_conflict");
|
||||
assertRefused(() -> admitted.replayWorkspaceSessionCommand(tenant, "actor-a", "internal", "digest"),
|
||||
"idempotency_conflict");
|
||||
var publicSession = transaction.execute(status -> admitted.insertWorkspaceSessionCommand(tenant, "actor-a",
|
||||
"public", "digest", "qwen-code", null, null, List.of(), null, selection));
|
||||
assertThat(admitted.requireSession(tenant, publicSession.sessionId()).toolProfile())
|
||||
.isEqualTo(shellEnabled ? WorkspaceToolProfiles.SHELL : WorkspaceToolProfiles.FILES);
|
||||
assertRefused(() -> transaction.execute(status -> admitted.insertInternalTeamLeadSessionCommand(
|
||||
tenant, "actor-a", "public", "digest", null, selection)), "idempotency_conflict");
|
||||
var turn = transaction.execute(status -> admitted.insertTurnCommand(tenant, "SUBMIT_TURN", "lead-turn",
|
||||
"turn-digest", first.sessionId(), List.of(Map.of("type", "text", "text", "go")), "turn-digest"));
|
||||
assertThat(turn.turnId()).isNotNull();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?",
|
||||
Integer.class, tenant)).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_turn WHERE tenant_id = ?",
|
||||
Integer.class, tenant)).isEqualTo(1);
|
||||
var projection = new ManagedAgentService(admitted, new RequestDigests(), mock(HarnessCoordinator.class),
|
||||
new UnavailableHarnessConnector(), registry);
|
||||
assertThat(projection.getPublicSession(tenant, "actor-a", first.sessionId()).capabilities().foregroundShell()).isNull();
|
||||
var lineage = new com.alibaba.qwen.code.managedagent.store.StoreModels.SessionLineage(
|
||||
first.sessionId(), first.sessionId(), "member-run", 1);
|
||||
var child = transaction.execute(status -> admitted.insertChildSessionCommand(tenant, first.sessionId(),
|
||||
"member-create", "member-digest", "member", List.of(), null, lineage));
|
||||
var childRow = admitted.requireSession(tenant, child.sessionId());
|
||||
assertThat(childRow.toolProfile()).isEqualTo(WorkspaceToolProfiles.INTERNAL_TEAM_LEAD);
|
||||
assertThat(childRow.approvalMode()).isEqualTo(mode);
|
||||
assertThat(admitted.findChildLineage(tenant, child.sessionId())).isEqualTo(lineage);
|
||||
jdbc.update("UPDATE managed_agent_turn SET status = 'COMPLETED' WHERE tenant_id = ? AND session_id = ?",
|
||||
tenant, first.sessionId());
|
||||
var close = transaction.execute(status -> admitted.beginWorkspaceLifecycle(tenant, first.sessionId(),
|
||||
com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind.CLOSE,
|
||||
"actor-a", "actor-digest", "close-lead", "close-digest", true, 0));
|
||||
assertThat(close.operation().lifecycleProtocolVersion()).isZero();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"files-off", "reader", "missing-mount", "config", "policy", "registry", "migration"})
|
||||
void internalTeamLeadCannotMintWithoutWorkspaceExecutionPrerequisites(String refusal) {
|
||||
String tenant = "tenant-" + UUID.randomUUID();
|
||||
register(tenant, "ws-lead", "storage-lead",
|
||||
"config".equals(refusal) ? "other" : WorkspaceExecutionProfile.CONFIG_REF,
|
||||
"policy".equals(refusal) ? "other" : WorkspaceExecutionProfile.POLICY_REF);
|
||||
grant(tenant, "ws-lead", "actor-a", !"reader".equals(refusal));
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(!"files-off".equals(refusal));
|
||||
if (!"missing-mount".equals(refusal)) {
|
||||
properties.getRuntimeBroker().setWorkspaceMounts(List.of(
|
||||
new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(tenant, "storage-lead", "/workspace")));
|
||||
}
|
||||
if ("registry".equals(refusal)) {
|
||||
jdbc.update("UPDATE managed_workspace_registry SET state = 'DRAINING' WHERE tenant_id = ?", tenant);
|
||||
}
|
||||
if ("migration".equals(refusal)) {
|
||||
jdbc.update("INSERT INTO qwen_runtime_storage_fence"
|
||||
+ " (tenant_key, storage_key, tenant_id, storage_id, operation_id) VALUES (?, ?, ?, ?, ?)",
|
||||
com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository.storageFenceKey(tenant),
|
||||
com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository.storageFenceKey("storage-lead"),
|
||||
tenant, "storage-lead", UUID.randomUUID().toString());
|
||||
}
|
||||
var defensive = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {}, registry, properties);
|
||||
assertThatThrownBy(() -> new TransactionTemplate(transactionManager).execute(status ->
|
||||
defensive.insertInternalTeamLeadSessionCommand(tenant, "actor-a", "internal", "digest", null,
|
||||
new WorkspaceSelection("ws-lead", "."))))
|
||||
.isInstanceOfAny(ApiException.class, com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?",
|
||||
Integer.class, tenant)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_workspace_create_command WHERE tenant_id = ?",
|
||||
Integer.class, tenant)).isZero();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"true, yolo", "true, unknown", "true, plan", "true, ''", "false, default", "false, auto-edit"})
|
||||
void foregroundShellRequiresFilesAndPersistedAskingApproval(boolean filesEnabled, String mode) {
|
||||
String tenant = "tenant-" + UUID.randomUUID();
|
||||
register(tenant, "ws-shell", "storage-shell", WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
grant(tenant, "ws-shell", "actor-a", true);
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
properties.getHarness().setWorkspaceShellEnabled(true);
|
||||
properties.getHarness().setApprovalMode("default");
|
||||
var enabled = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {}, registry, properties);
|
||||
String session = new TransactionTemplate(transactionManager).execute(status ->
|
||||
enabled.insertWorkspaceSessionCommand(tenant, "actor-a", "create", "digest", "qwen-code",
|
||||
null, null, List.of(), null, new WorkspaceSelection("ws-shell", ".")).sessionId());
|
||||
jdbc.update("UPDATE managed_agent_session SET approval_mode = ? WHERE tenant_id = ? AND session_id = ?", mode, tenant, session);
|
||||
properties.getHarness().setWorkspaceFilesEnabled(filesEnabled);
|
||||
var defensive = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {}, registry, properties);
|
||||
var projection = new ManagedAgentService(defensive, new RequestDigests(), mock(HarnessCoordinator.class),
|
||||
new UnavailableHarnessConnector(), registry);
|
||||
assertThat(projection.getPublicSession(tenant, "actor-a", session).capabilities().foregroundShell()).isFalse();
|
||||
assertThat(projection.getWebShellSession(tenant, "actor-a", session).capabilities().foregroundShell()).isFalse();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void sessionReadsOmitForegroundShellForFilesAndUnbound(boolean bound) throws Exception {
|
||||
String tenant = "tenant-" + UUID.randomUUID();
|
||||
if (bound) {
|
||||
register(tenant, "ws-a", "storage-a");
|
||||
grant(tenant, "ws-a", "actor-a", true);
|
||||
}
|
||||
String body = bound
|
||||
? "{\"agent_id\":\"qwen-code\",\"workspace\":{\"workspace_id\":\"ws-a\"}}"
|
||||
: "{\"agent_id\":\"qwen-code\"}";
|
||||
var created = mvc.perform(post("/v1/agents/sessions")
|
||||
.header(TenantContextFilter.HEADER, tenant)
|
||||
.header("Idempotency-Key", "create")
|
||||
.principal(actor(tenant, "actor-a"))
|
||||
.contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.capabilities").isMap())
|
||||
.andExpect(jsonPath("$.capabilities.foreground_shell").doesNotHaveJsonPath())
|
||||
.andReturn();
|
||||
String session = mapper.readTree(created.getResponse().getContentAsString()).get("id").asText();
|
||||
assertThat(store.requireSession(tenant, session).toolProfile())
|
||||
.isEqualTo(bound ? "hosted-workspace-files/1" : null);
|
||||
mvc.perform(get("/v1/agents/sessions/" + session)
|
||||
.header(TenantContextFilter.HEADER, tenant)
|
||||
.principal(actor(tenant, "actor-a")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.capabilities").isMap())
|
||||
.andExpect(jsonPath("$.capabilities.foreground_shell").doesNotHaveJsonPath());
|
||||
mvc.perform(post("/api/agent/web-shell/v1/sessions/get")
|
||||
.header(TenantContextFilter.HEADER, tenant)
|
||||
.principal(actor(tenant, "actor-a"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"sessionId\":\"" + session + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.capabilities").isMap())
|
||||
.andExpect(jsonPath("$.capabilities.foregroundShell").doesNotHaveJsonPath());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"default", "auto-edit"})
|
||||
void shellFlagChangesPreserveCreationAndTurnReplayButBlockFreshWorkAndLifecycle(String mode) {
|
||||
String tenant = "tenant-" + UUID.randomUUID();
|
||||
register(tenant, "ws-shell", "storage-shell", WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
grant(tenant, "ws-shell", "actor-a", true);
|
||||
grant(tenant, "ws-shell", "reader", false);
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
properties.getHarness().setWorkspaceShellEnabled(true);
|
||||
properties.getHarness().setApprovalMode(mode);
|
||||
properties.getRuntimeBroker().setWorkspaceMounts(List.of(
|
||||
new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(tenant, "storage-shell", "/workspace")));
|
||||
ManagedAgentStore enabled = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {
|
||||
}, registry, properties);
|
||||
var transaction = new TransactionTemplate(transactionManager);
|
||||
String digest = "sha256:" + "a".repeat(64);
|
||||
var selection = new WorkspaceSelection("ws-shell", ".");
|
||||
String session = transaction.execute(status -> enabled.insertWorkspaceSessionCommand(tenant,
|
||||
"actor-a", "create-shell", digest, "qwen-code", null, null, List.of(), null, selection)).sessionId();
|
||||
var initial = transaction.execute(status -> enabled.insertWorkspaceSessionCommand(tenant,
|
||||
"actor-a", "initial-shell", digest, "qwen-code", null, null,
|
||||
List.of(Map.of("type", "text", "text", "initial")), digest, selection));
|
||||
byte[] actorKey = "actor-a".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
for (String id : List.of(session, initial.sessionId())) {
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ? AND session_id = ?"
|
||||
+ " AND creator_actor_key = ? AND owner_actor_key = ?", Integer.class,
|
||||
tenant, id, actorKey, actorKey)).isEqualTo(1);
|
||||
assertThat(enabled.requireSession(tenant, id).toolProfile()).isEqualTo("hosted-workspace-shell/1");
|
||||
assertThat(enabled.requireSession(tenant, id).approvalMode()).isEqualTo(mode);
|
||||
}
|
||||
UnavailableHarnessConnector harness = new UnavailableHarnessConnector() {
|
||||
@Override
|
||||
public boolean isAvailable() { return true; }
|
||||
@Override
|
||||
public boolean isWorkspaceFilesAvailable() { return true; }
|
||||
};
|
||||
var coordinator = mock(com.alibaba.qwen.code.managedagent.service.HarnessCoordinator.class);
|
||||
ManagedAgentService admitted = new ManagedAgentService(enabled, new RequestDigests(), coordinator, harness, registry);
|
||||
List<InputBlock> input = List.of(new InputBlock("text", "go"));
|
||||
var original = transaction.execute(status -> admitted.submitTurn(tenant, "actor-a", "original", session, input));
|
||||
assertThat(admitted.getPublicSession(tenant, "actor-a", session).capabilities().foregroundShell()).isTrue();
|
||||
properties.getHarness().setWorkspaceShellEnabled(false);
|
||||
ManagedAgentStore disabled = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {
|
||||
}, registry, properties);
|
||||
ManagedAgentService stopped = new ManagedAgentService(disabled, new RequestDigests(), coordinator, harness, registry);
|
||||
assertThat(transaction.execute(status -> disabled.insertWorkspaceSessionCommand(tenant,
|
||||
"actor-a", "create-shell", digest, "qwen-code", null, null, List.of(), null, selection)).sessionId())
|
||||
.isEqualTo(session);
|
||||
assertThat(transaction.execute(status -> stopped.submitTurn(tenant, "actor-a", "original", session, input)).turnId())
|
||||
.isEqualTo(original.turnId());
|
||||
assertThatThrownBy(() -> transaction.execute(status -> stopped.submitTurn(tenant, "actor-a", "fresh", session, input)))
|
||||
.isInstanceOfSatisfying(ApiException.class, error -> assertThat(error.getCode()).isEqualTo("workspace_unavailable"));
|
||||
assertThatThrownBy(() -> stopped.submitTurn(tenant, "reader", "original", session, input))
|
||||
.isInstanceOf(ApiException.class);
|
||||
var capabilities = stopped.getWebShellSession(tenant, "actor-a", session).capabilities();
|
||||
assertThat(capabilities.workspaceTurns()).isTrue();
|
||||
assertThat(capabilities.foregroundShell()).isFalse();
|
||||
assertThat(capabilities.sessionClose()).isFalse();
|
||||
assertThat(capabilities.sessionDelete()).isFalse();
|
||||
var cancellation = transaction.execute(status -> stopped.cancelTurn(tenant, "actor-a", "cancel", session, original.turnId()));
|
||||
assertThat(cancellation.turnId()).isEqualTo(original.turnId());
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM managed_agent_turn WHERE tenant_id = ? AND turn_id = ?",
|
||||
String.class, tenant, original.turnId())).isEqualTo("CANCELLING");
|
||||
verify(coordinator).cancel(tenant, session, original.turnId());
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_turn WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session)).isEqualTo(1);
|
||||
for (String state : List.of("ACTIVE", "CLOSED", "ARCHIVED")) {
|
||||
jdbc.update("UPDATE managed_agent_session SET status = ? WHERE tenant_id = ? AND session_id = ?", state, tenant, session);
|
||||
for (var kind : List.of(com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind.CLOSE,
|
||||
com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind.ARCHIVE,
|
||||
com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind.DELETE)) {
|
||||
assertThatThrownBy(() -> transaction.execute(status -> disabled.beginWorkspaceLifecycle(tenant, session,
|
||||
kind, "actor-a", digest, state + kind, digest, true, 1))).isInstanceOf(ApiException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> transaction.execute(status -> disabled.unarchiveWorkspaceSession(tenant, session,
|
||||
"actor-a", "unarchive-" + state, digest))).isInstanceOf(ApiException.class);
|
||||
var read = stopped.getPublicSession(tenant, "actor-a", session).capabilities();
|
||||
assertThat(read.sessionArchive()).isFalse();
|
||||
assertThat(read.sessionUnarchive()).isFalse();
|
||||
}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_operation WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_command WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session)).isEqualTo(2);
|
||||
String files = transaction.execute(status -> disabled.insertWorkspaceSessionCommand(tenant,
|
||||
"actor-a", "create-files", digest, "qwen-code", null, null, List.of(), null, selection)).sessionId();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ? AND session_id = ?"
|
||||
+ " AND creator_actor_key = ? AND owner_actor_key = ?", Integer.class,
|
||||
tenant, files, actorKey, actorKey)).isEqualTo(1);
|
||||
assertThat(disabled.requireSession(tenant, files).toolProfile()).isEqualTo("hosted-workspace-files/1");
|
||||
assertThat(stopped.getPublicSession(tenant, "actor-a", files).capabilities().foregroundShell()).isNull();
|
||||
var fileTurn = transaction.execute(status -> stopped.submitTurn(tenant, "actor-a", "files-turn", files, input));
|
||||
assertThat(fileTurn).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrationFenceBlocksNewTurnsAndMutationsButPreservesReceipts() throws Exception {
|
||||
String tenant = "tenant-" + UUID.randomUUID();
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import java.util.concurrent.CompletionStage;
|
|||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
|
|
@ -69,11 +70,112 @@ class WorkspaceSessionRetentionTest {
|
|||
@Autowired OnceCloseRuntime runtime;
|
||||
@Autowired PlatformTransactionManager transactions;
|
||||
@Autowired SessionLifecycleCoordinator lifecycle;
|
||||
@Autowired ManagedWorkspaceRegistry registry;
|
||||
@Autowired HarnessConnector harness;
|
||||
@Autowired com.alibaba.qwen.code.managedagent.service.HarnessCoordinator coordinator;
|
||||
|
||||
@Test
|
||||
void archivesAndUnarchivesAcrossSurfacesWithoutReopeningOrRepeatingCleanup() throws Exception {
|
||||
@ParameterizedTest
|
||||
@CsvSource({"ACTIVE, hosted-workspace-shell/1", "CLOSED, hosted-workspace-shell/1", "ARCHIVED, hosted-workspace-shell/1",
|
||||
"ACTIVE, hosted-workspace-shell/2", "CLOSED, hosted-workspace-shell/2", "ARCHIVED, hosted-workspace-shell/2"})
|
||||
void shellCapabilitiesAndLifecycleStayDisabledDespiteCloseSupportAndProof(String state, String profile) throws Exception {
|
||||
String tenant = tenant();
|
||||
String session = closed(tenant, true);
|
||||
assertThat(runtime.supportsWorkspaceClose()).isTrue();
|
||||
assertThat(store.workspaceFilesEnabled()).isTrue();
|
||||
assertThat(store.workspaceShellEnabled()).isFalse();
|
||||
request(get(PUBLIC + session), tenant, "owner", null)
|
||||
.andExpect(jsonPath("$.capabilities.session_close").value(true))
|
||||
.andExpect(jsonPath("$.capabilities.session_archive").value(true))
|
||||
.andExpect(jsonPath("$.capabilities.session_unarchive").value(true))
|
||||
.andExpect(jsonPath("$.capabilities.session_delete").value(true));
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
properties.getHarness().setWorkspaceShellEnabled(true);
|
||||
var enabled = new ManagedAgentStore(jdbc, mapper, Clock.systemUTC(), ignored -> {}, registry, properties);
|
||||
var projection = new com.alibaba.qwen.code.managedagent.service.ManagedAgentService(enabled,
|
||||
new com.alibaba.qwen.code.managedagent.service.RequestDigests(), coordinator, harness, registry);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(projection, "runtimeWarmer", runtime);
|
||||
assertThat(projection.getPublicSession(tenant, "owner", session).capabilities().sessionClose()).isTrue();
|
||||
assertThat(projection.getWebShellSession(tenant, "owner", session).capabilities().sessionDelete()).isTrue();
|
||||
int operations = jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_operation WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session);
|
||||
int commands = jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_command WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session);
|
||||
jdbc.update("UPDATE managed_agent_session SET tool_profile = ?, approval_mode = 'default', status = ?"
|
||||
+ " WHERE tenant_id = ? AND session_id = ?", profile, state, tenant, session);
|
||||
var publicCapabilities = projection.getPublicSession(tenant, "owner", session).capabilities();
|
||||
assertThat(publicCapabilities.foregroundShell()).isTrue();
|
||||
assertThat(publicCapabilities.sessionClose()).isFalse();
|
||||
assertThat(publicCapabilities.sessionArchive()).isFalse();
|
||||
assertThat(publicCapabilities.sessionUnarchive()).isFalse();
|
||||
assertThat(publicCapabilities.sessionDelete()).isFalse();
|
||||
var webCapabilities = projection.getWebShellSession(tenant, "owner", session).capabilities();
|
||||
assertThat(webCapabilities.foregroundShell()).isTrue();
|
||||
assertThat(webCapabilities.sessionClose()).isFalse();
|
||||
assertThat(webCapabilities.sessionArchive()).isFalse();
|
||||
assertThat(webCapabilities.sessionUnarchive()).isFalse();
|
||||
assertThat(webCapabilities.sessionDelete()).isFalse();
|
||||
request(get(PUBLIC + session), tenant, "owner", null)
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.capabilities.foreground_shell").hasJsonPath())
|
||||
.andExpect(jsonPath("$.capabilities.foreground_shell").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.session_close").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.session_archive").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.session_unarchive").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.session_delete").value(false));
|
||||
request(post(WEB + "/sessions/get").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(mapper.writeValueAsString(java.util.Map.of("sessionId", session))), tenant, "owner", null)
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.capabilities.foregroundShell").hasJsonPath())
|
||||
.andExpect(jsonPath("$.capabilities.foregroundShell").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.sessionClose").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.sessionArchive").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.sessionUnarchive").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.sessionDelete").value(false));
|
||||
for (String operation : new String[] {"close", "archive", "unarchive", "delete"}) {
|
||||
web(operation, tenant, session, "owner", "shell-" + operation)
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error.code").value("workspace_unavailable"));
|
||||
var mutation = "delete".equals(operation) ? delete(PUBLIC + session) : post(PUBLIC + session + "/" + operation);
|
||||
request(mutation, tenant, "owner", "shell-public-" + operation)
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error.code").value("workspace_unavailable"));
|
||||
}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_operation WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session)).isEqualTo(operations);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_command WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session)).isEqualTo(commands);
|
||||
assertThat(runtime.calls.get(session)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalChildCloseStillAdmitsShellSessionsThatPublicLifecycleRefuses() throws Exception {
|
||||
String tenant = tenant();
|
||||
String session = create(tenant);
|
||||
jdbc.update("UPDATE managed_agent_session SET tool_profile = 'hosted-workspace-shell/1', approval_mode = 'default'"
|
||||
+ " WHERE tenant_id = ? AND session_id = ?", tenant, session);
|
||||
web("close", tenant, session, "owner", "shell-public-close")
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error.code").value("workspace_unavailable"));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_operation WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session)).isZero();
|
||||
var admission = store.beginWorkspaceLifecycle(tenant, session, OperationKind.CLOSE, "owner",
|
||||
"owner-digest", "child-close-run", "close-digest", true);
|
||||
jdbc.update("UPDATE managed_agent_operation SET state = 'COMPLETED', delivery_state = 'CONFIRMED',"
|
||||
+ " available_at = available_at + 3600000 WHERE tenant_id = ? AND session_id = ? AND operation_id = ?",
|
||||
tenant, session, admission.operation().operationId());
|
||||
assertThat(admission.replayed()).isFalse();
|
||||
assertThat(admission.operation().kind()).isEqualTo(OperationKind.CLOSE);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_operation WHERE tenant_id = ? AND session_id = ?",
|
||||
Integer.class, tenant, session)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"hosted-workspace-files/1", "hosted-workspace-files/2"})
|
||||
void archivesAndUnarchivesAcrossSurfacesWithoutReopeningOrRepeatingCleanup(String profile) throws Exception {
|
||||
String tenant = tenant();
|
||||
String session = closed(tenant, true);
|
||||
jdbc.update("UPDATE managed_agent_session SET tool_profile = ? WHERE tenant_id = ? AND session_id = ?", profile, tenant, session);
|
||||
request(get(PUBLIC + session), tenant, "owner", null)
|
||||
.andExpect(jsonPath("$.capabilities.session_archive").value(true))
|
||||
.andExpect(jsonPath("$.capabilities.session_unarchive").value(true))
|
||||
|
|
|
|||
|
|
@ -112,6 +112,45 @@ class ManagedAgentPropertiesTest {
|
|||
.hasRootCauseMessage("Hosted Harness requires the Managed Session Store"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shellAdmissionIsDefaultOffAndRequiresAnAskingModeAndFiles() throws Exception {
|
||||
String key = "qwen.managed-agent.harness.workspace-shell-enabled";
|
||||
assertThat(applicationYmlValues()).containsEntry(key,
|
||||
"${QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED:false}");
|
||||
assertThat(new ManagedAgentProperties().getHarness().isWorkspaceShellEnabled()).isFalse();
|
||||
ApplicationContextRunner supported = new ApplicationContextRunner()
|
||||
.withUserConfiguration(PropertiesConfiguration.class)
|
||||
.withPropertyValues(
|
||||
key + "=true",
|
||||
"qwen.managed-agent.harness.enabled=true",
|
||||
"qwen.managed-agent.harness.workspace-files-enabled=true",
|
||||
"qwen.managed-agent.harness.approval-mode=default",
|
||||
"qwen.managed-agent.session-store.enabled=true",
|
||||
"qwen.managed-agent.runtime-broker.enabled=true",
|
||||
"qwen.managed-agent.runtime-broker.workspace-mounts[0].tenant-id=tenant",
|
||||
"qwen.managed-agent.runtime-broker.workspace-mounts[0].storage-id=storage",
|
||||
"qwen.managed-agent.runtime-broker.workspace-mounts[0].root=/workspace");
|
||||
for (String mode : List.of("default", "auto-edit", "DEFAULT")) {
|
||||
supported.withPropertyValues("qwen.managed-agent.harness.approval-mode=" + mode)
|
||||
.run(started -> assertThat(started).hasNotFailed());
|
||||
}
|
||||
for (String mode : List.of("yolo", "", " ", "plan", "unknown")) {
|
||||
supported.withPropertyValues("qwen.managed-agent.harness.approval-mode=" + mode)
|
||||
.run(started -> assertThat(started).hasFailed());
|
||||
}
|
||||
for (String invalid : List.of("harness.workspace-files-enabled=false", "harness.enabled=false",
|
||||
"session-store.enabled=false", "runtime-broker.enabled=false",
|
||||
"runtime-broker.isolation-class=workspace", "runtime-broker.provisioner=kubernetes")) {
|
||||
supported.withPropertyValues("qwen.managed-agent." + invalid)
|
||||
.run(started -> assertThat(started).hasFailed());
|
||||
}
|
||||
ManagedAgentProperties missing = new ManagedAgentProperties();
|
||||
missing.getHarness().setWorkspaceShellEnabled(true);
|
||||
missing.getHarness().setWorkspaceFilesEnabled(true);
|
||||
missing.getHarness().setApprovalMode(null);
|
||||
assertThatThrownBy(missing::validateWorkspaceFiles).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(ManagedAgentProperties.class)
|
||||
static class PropertiesConfiguration {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,8 @@ class QwenHostedHarnessConnectorTest {
|
|||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"hosted-workspace-files/1", "hosted-workspace-files/2"})
|
||||
@ValueSource(strings = {"hosted-workspace-files/1", "hosted-workspace-files/2",
|
||||
"hosted-workspace-shell/1", "hosted-workspace-shell/2", "hosted-workspace-team-lead/1"})
|
||||
void boundCreateConflictLoadsOriginalWorkspaceAndProfileAndRechecksCachedGrant(String profile) {
|
||||
HostedHarnessClient client = mock(HostedHarnessClient.class);
|
||||
HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class);
|
||||
|
|
@ -168,7 +169,7 @@ class QwenHostedHarnessConnectorTest {
|
|||
SessionRecord session = new SessionRecord("tenant-a", SESSION_ID, "qwen-code", null,
|
||||
null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1,
|
||||
new ContextBinding("tenant-a", "selected-workspace", 1, "storage", "child",
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "yolo", profile);
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "default", profile);
|
||||
when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session);
|
||||
WorkspaceExecutionStore execution = mock(WorkspaceExecutionStore.class);
|
||||
ManagedAgentProperties properties = properties();
|
||||
|
|
@ -194,18 +195,30 @@ class QwenHostedHarnessConnectorTest {
|
|||
.contains("toolProfile=" + profile, "workspaceId=selected-workspace", "tenantId=tenant-a")
|
||||
.doesNotContain("workspaceId=workspace-a");
|
||||
}
|
||||
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(create.getValue(), "toJson"))
|
||||
Map<String, Object> created = ReflectionTestUtils.invokeMethod(create.getValue(), "toJson");
|
||||
assertThat(created)
|
||||
.containsEntry("approvalMode", "default")
|
||||
.containsEntry("approvalTimeoutMs", properties.getHarness().getApprovalTimeout().toMillis())
|
||||
.containsEntry("toolProfile", profile)
|
||||
.doesNotContainKey("childWorkspaces");
|
||||
if (profile.startsWith("hosted-workspace-shell/")) {
|
||||
assertThat(created).containsEntry("suppressChildAgents", true);
|
||||
} else {
|
||||
assertThat(created).doesNotContainKey("suppressChildAgents");
|
||||
}
|
||||
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(load.getValue(), "toJson"))
|
||||
.doesNotContainKey("childWorkspaces");
|
||||
QwenHostedHarnessConnector restarted = new QwenHostedHarnessConnector(properties, sessions, execution, actions);
|
||||
ReflectionTestUtils.setField(restarted, "client", client);
|
||||
restarted.recoverManagedRuntime("tenant-a", SESSION_ID, false);
|
||||
verify(client, times(4)).loadSession(load.capture());
|
||||
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(load.getValue(), "toJson"))
|
||||
.containsEntry("toolProfile", profile);
|
||||
Map<String, Object> recovered = ReflectionTestUtils.invokeMethod(load.getValue(), "toJson");
|
||||
assertThat(recovered).containsEntry("toolProfile", profile);
|
||||
if (profile.startsWith("hosted-workspace-shell/")) {
|
||||
assertThat(recovered).containsEntry("suppressChildAgents", true);
|
||||
} else {
|
||||
assertThat(recovered).doesNotContainKey("suppressChildAgents");
|
||||
}
|
||||
QwenHostedHarnessConnector renameRetry = new QwenHostedHarnessConnector(properties, sessions, execution, actions);
|
||||
ReflectionTestUtils.setField(renameRetry, "client", client);
|
||||
renameRetry.createOrLoad("tenant-a", SESSION_ID, false, true);
|
||||
|
|
@ -235,15 +248,16 @@ class QwenHostedHarnessConnectorTest {
|
|||
// #13753 I2: the child Workspace capability rides every create and
|
||||
// load the connector sends (attach, recovery, lifecycle settle), so the
|
||||
// Hosted Agent tool admits a worktree child only on a host that serves it.
|
||||
@Test
|
||||
void childWorkspaceCapabilityRidesEveryCreateAndLoad() {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"hosted-workspace-files/1", "hosted-workspace-team-lead/1"})
|
||||
void childWorkspaceCapabilityRidesEveryCreateAndLoad(String profile) {
|
||||
HostedHarnessClient client = mock(HostedHarnessClient.class);
|
||||
HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class);
|
||||
HarnessSessionRef attached = mock(HarnessSessionRef.class);
|
||||
when(client.capabilities()).thenReturn(capabilities);
|
||||
when(capabilities.getBootId()).thenReturn(BOOT_ID);
|
||||
when(attached.getHarnessBootId()).thenReturn(BOOT_ID);
|
||||
when(attached.getApprovalMode()).thenReturn("default");
|
||||
when(attached.getApprovalMode()).thenReturn("yolo");
|
||||
when(client.createSession(any())).thenReturn(attached);
|
||||
when(client.loadSession(any())).thenReturn(attached);
|
||||
when(client.settleLifecycle(any(), any())).thenReturn(Map.of());
|
||||
|
|
@ -251,13 +265,13 @@ class QwenHostedHarnessConnectorTest {
|
|||
SessionRecord session = new SessionRecord("tenant-a", SESSION_ID, "qwen-code", null,
|
||||
null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1,
|
||||
new ContextBinding("tenant-a", "selected-workspace", 1, "storage", "child",
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "yolo", "hosted-workspace-files/1");
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "yolo", profile);
|
||||
when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session);
|
||||
ManagedAgentProperties properties = properties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
properties.getRuntimeBroker().setChildWorkspacesEnabled(true);
|
||||
ManagedActionStore actions = mock(ManagedActionStore.class);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default");
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("yolo");
|
||||
OperationRecord operation = mock(OperationRecord.class);
|
||||
when(operation.tenantId()).thenReturn("tenant-a");
|
||||
when(operation.sessionId()).thenReturn(SESSION_ID);
|
||||
|
|
@ -275,21 +289,98 @@ class QwenHostedHarnessConnectorTest {
|
|||
fresh.get().createOrLoad("tenant-a", SESSION_ID, true);
|
||||
fresh.get().recoverManagedRuntime("tenant-a", SESSION_ID, false);
|
||||
fresh.get().settleLifecycle(operation);
|
||||
fresh.get().rename("tenant-a", SESSION_ID, "internal lead");
|
||||
|
||||
ArgumentCaptor<CreateHarnessSession> create = ArgumentCaptor.forClass(CreateHarnessSession.class);
|
||||
ArgumentCaptor<LoadHarnessSession> load = ArgumentCaptor.forClass(LoadHarnessSession.class);
|
||||
verify(client).createSession(create.capture());
|
||||
verify(client, times(3)).loadSession(load.capture());
|
||||
verify(client, times(4)).loadSession(load.capture());
|
||||
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(create.getValue(), "toJson"))
|
||||
.containsEntry("childWorkspaces", true);
|
||||
.containsEntry("childWorkspaces", true)
|
||||
.containsEntry("toolProfile", profile)
|
||||
.containsEntry("approvalMode", "yolo")
|
||||
.doesNotContainKey("suppressChildAgents");
|
||||
for (LoadHarnessSession request : load.getAllValues()) {
|
||||
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(request, "toJson"))
|
||||
.containsEntry("childWorkspaces", true);
|
||||
.containsEntry("childWorkspaces", true)
|
||||
.containsEntry("toolProfile", profile)
|
||||
.doesNotContainKey("suppressChildAgents");
|
||||
}
|
||||
assertThat(ReflectionTestUtils.<Map<String, Object>>invokeMethod(load.getAllValues().get(2), "toJson"))
|
||||
.containsEntry("lifecycleAuthority", Map.of("operationId", "close-1", "claimGeneration", 2L));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "yolo", "plan", "unknown"})
|
||||
void invalidPersistedShellApprovalRefusesEveryAttachmentPathEvenAfterCaching(String mode) {
|
||||
var sessions = sessions();
|
||||
SessionRecord session = new SessionRecord("tenant-a", SESSION_ID, "qwen-code", null,
|
||||
null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1,
|
||||
new ContextBinding("tenant-a", "selected-workspace", 1, "storage", ".",
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "default", "hosted-workspace-shell/1");
|
||||
when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session);
|
||||
var properties = properties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
var actions = mock(ManagedActionStore.class);
|
||||
var client = mock(HostedHarnessClient.class);
|
||||
var capabilities = mock(HostedHarnessCapabilities.class);
|
||||
when(client.capabilities()).thenReturn(capabilities);
|
||||
when(capabilities.getBootId()).thenReturn(BOOT_ID);
|
||||
var attached = mock(HarnessSessionRef.class);
|
||||
when(attached.getHarnessBootId()).thenReturn(BOOT_ID);
|
||||
when(attached.getApprovalMode()).thenReturn("default");
|
||||
when(client.loadSession(any())).thenReturn(attached);
|
||||
var connector = new QwenHostedHarnessConnector(properties, sessions, mock(WorkspaceExecutionStore.class), actions);
|
||||
ReflectionTestUtils.setField(connector, "client", client);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn(mode);
|
||||
OperationRecord operation = mock(OperationRecord.class);
|
||||
when(operation.tenantId()).thenReturn("tenant-a");
|
||||
when(operation.sessionId()).thenReturn(SESSION_ID);
|
||||
when(operation.operationId()).thenReturn("close-1");
|
||||
when(operation.claimGeneration()).thenReturn(2L);
|
||||
when(operation.kind()).thenReturn(OperationKind.CLOSE);
|
||||
for (boolean exists : new boolean[] {false, true}) {
|
||||
assertThatThrownBy(() -> connector.createOrLoad("tenant-a", SESSION_ID, exists))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
}
|
||||
assertThatThrownBy(() -> connector.settleLifecycle(operation))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.runMessageOperation("tenant-a", SESSION_ID, Map.of(
|
||||
"operationId", "66666666-6666-4666-8666-666666666666", "kind", "stop")))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
verifyNoInteractions(client);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default");
|
||||
connector.createOrLoad("tenant-a", SESSION_ID, true);
|
||||
clearInvocations(client);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn(mode);
|
||||
assertThatThrownBy(() -> connector.createOrLoad("tenant-a", SESSION_ID, true))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.recoverManagedRuntime("tenant-a", SESSION_ID, false))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.recoverManagedCancellation("tenant-a", SESSION_ID))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.cancel("tenant-a", SESSION_ID))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.cancelManagedRuntime("tenant-a", SESSION_ID,
|
||||
"prompt", "checkpoint", "activation"))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.resolveAction("tenant-a", SESSION_ID, "action",
|
||||
new ObjectMapper().createObjectNode().put("optionId", "allow")
|
||||
.put("inputRevision", 1L).put("policyRevision", "hosted-tool-approval/1")))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.stream("tenant-a", SESSION_ID, 0, "epoch"))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.rename("tenant-a", SESSION_ID, "title"))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.settleLifecycle(operation))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
assertThatThrownBy(() -> connector.runMessageOperation("tenant-a", SESSION_ID, Map.of(
|
||||
"operationId", "66666666-6666-4666-8666-666666666666", "kind", "stop")))
|
||||
.hasMessageContaining("requires persisted default or auto-edit");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" "})
|
||||
|
|
@ -832,6 +923,7 @@ class QwenHostedHarnessConnectorTest {
|
|||
loads.getValue(), "toJson"))
|
||||
.containsEntry("stopMessages", true)
|
||||
.containsEntry("passiveManagedRuntimeRecovery", true)
|
||||
.containsEntry("suppressChildAgents", true)
|
||||
.doesNotContainKey("driveRuntimeRecovery");
|
||||
verify(execution).authorizeCommittedStop(session);
|
||||
verify(execution, never()).authorize(session);
|
||||
|
|
@ -886,6 +978,7 @@ class QwenHostedHarnessConnectorTest {
|
|||
"agentId", "agent_00112233445566778899aabbccddeeff",
|
||||
"revision", "2",
|
||||
"digest", "a".repeat(64)))
|
||||
.containsEntry("suppressChildAgents", true)
|
||||
.containsEntry("stopMessages", true);
|
||||
}
|
||||
|
||||
|
|
@ -990,8 +1083,11 @@ class QwenHostedHarnessConnectorTest {
|
|||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void resolvesActionsThroughAuthorizedColdAndCachedWorkspaceAttachments(boolean verifiedRecovery) {
|
||||
@org.junit.jupiter.params.provider.CsvSource({
|
||||
"hosted-workspace-files/1, false", "hosted-workspace-files/1, true",
|
||||
"hosted-workspace-shell/1, false", "hosted-workspace-shell/1, true"
|
||||
})
|
||||
void resolvesActionsThroughAuthorizedColdAndCachedWorkspaceAttachments(String profile, boolean verifiedRecovery) {
|
||||
HostedHarnessClient client = mock(HostedHarnessClient.class);
|
||||
HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class);
|
||||
HarnessSessionRef attached = mock(HarnessSessionRef.class);
|
||||
|
|
@ -1003,7 +1099,8 @@ class QwenHostedHarnessConnectorTest {
|
|||
SessionRecord session = new SessionRecord("tenant-a", SESSION_ID, "qwen-code", null,
|
||||
null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1,
|
||||
new ContextBinding("tenant-a", "selected-workspace", 1, "storage", "child",
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1), "yolo", "hosted-workspace-files/1");
|
||||
WorkspaceExecutionProfile.CONTEXT_CONFIG_REF, 1),
|
||||
"hosted-workspace-shell/1".equals(profile) ? "default" : "yolo", profile);
|
||||
AgentStateStore sessions = mock(AgentStateStore.class);
|
||||
when(sessions.requireSession("tenant-a", SESSION_ID)).thenReturn(session);
|
||||
WorkspaceExecutionStore execution = mock(WorkspaceExecutionStore.class);
|
||||
|
|
@ -1012,6 +1109,7 @@ class QwenHostedHarnessConnectorTest {
|
|||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default");
|
||||
ManagedAgentProperties properties = properties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
assertThat(properties.getHarness().isWorkspaceShellEnabled()).isFalse();
|
||||
QwenHostedHarnessConnector connector = new QwenHostedHarnessConnector(properties, sessions, execution, actions);
|
||||
ReflectionTestUtils.setField(connector, "client", client);
|
||||
String actionId = "tool_approval_" + "a".repeat(32);
|
||||
|
|
@ -1035,8 +1133,13 @@ class QwenHostedHarnessConnectorTest {
|
|||
verify(client, times(2)).loadSession(loads.capture());
|
||||
for (LoadHarnessSession load : loads.getAllValues()) {
|
||||
Map<String, Object> wire = ReflectionTestUtils.invokeMethod(load, "toJson");
|
||||
assertThat(wire).containsEntry("toolProfile", "hosted-workspace-files/1")
|
||||
assertThat(wire).containsEntry("toolProfile", profile)
|
||||
.containsEntry("passiveManagedRuntimeRecovery", true);
|
||||
if (profile.startsWith("hosted-workspace-shell/")) {
|
||||
assertThat(wire).containsEntry("suppressChildAgents", true);
|
||||
} else {
|
||||
assertThat(wire).doesNotContainKey("suppressChildAgents");
|
||||
}
|
||||
assertThat(wire.get("managedSessionStore").toString())
|
||||
.contains("tenantId=tenant-a", "workspaceId=selected-workspace")
|
||||
.doesNotContain("workspaceId=workspace-a");
|
||||
|
|
|
|||
|
|
@ -87,6 +87,19 @@ class AgentDefinitionCompilerTest {
|
|||
.isEqualTo("hosted-workspace-shell/1");
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"yolo", "default", "auto-edit"})
|
||||
void definitionsCannotAcquireInternalTeamLeadEvenWhenAllowlisted(String mode) {
|
||||
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(definition(
|
||||
hostedProfile(WorkspaceToolProfiles.INTERNAL_TEAM_LEAD),
|
||||
"{\"approval_mode\":\"" + mode + "\"}"), true, mode,
|
||||
List.of(WorkspaceToolProfiles.INTERNAL_TEAM_LEAD)))
|
||||
.isInstanceOfSatisfying(ApiException.class, error -> {
|
||||
assertThat(error.getCode()).isEqualTo("agent_definition_unsupported");
|
||||
assertThat(error.getDetails().get("field")).isEqualTo("tools");
|
||||
});
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource(textBlock = """
|
||||
'{"model":{"id":"qwen3"},"instructions":"","tools":[],"permission_policy":{}}', model
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ public final class CreateHarnessSession {
|
|||
private final Long approvalTimeoutMs;
|
||||
private final Map<String, Object> agentDefinition;
|
||||
private final Map<String, Object> lineage;
|
||||
private final boolean suppressChildAgents;
|
||||
private final boolean childWorkspaces;
|
||||
|
||||
private CreateHarnessSession(Builder builder) {
|
||||
|
|
@ -23,6 +24,7 @@ public final class CreateHarnessSession {
|
|||
this.approvalTimeoutMs = builder.approvalTimeoutMs;
|
||||
this.agentDefinition = builder.agentDefinition;
|
||||
this.lineage = builder.lineage;
|
||||
this.suppressChildAgents = builder.suppressChildAgents;
|
||||
this.childWorkspaces = builder.childWorkspaces;
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +49,9 @@ public final class CreateHarnessSession {
|
|||
if (toolProfile != null) {
|
||||
result.put("toolProfile", toolProfile);
|
||||
}
|
||||
if (suppressChildAgents) {
|
||||
result.put("suppressChildAgents", true);
|
||||
}
|
||||
if (approvalTimeoutMs != null) {
|
||||
result.put("approvalTimeoutMs", approvalTimeoutMs);
|
||||
}
|
||||
|
|
@ -70,6 +75,7 @@ public final class CreateHarnessSession {
|
|||
private Long approvalTimeoutMs;
|
||||
private Map<String, Object> agentDefinition;
|
||||
private Map<String, Object> lineage;
|
||||
private boolean suppressChildAgents;
|
||||
private boolean childWorkspaces;
|
||||
|
||||
/**
|
||||
|
|
@ -132,6 +138,12 @@ public final class CreateHarnessSession {
|
|||
return this;
|
||||
}
|
||||
|
||||
/** Public Shell admission keeps the private Shell lane's child agent tool off this Session. */
|
||||
public Builder suppressChildAgents() {
|
||||
this.suppressChildAgents = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* H4b: a child Session's ancestry. The body names the parent and
|
||||
* root Sessions, the launching child run id and the tree depth; the
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public final class LoadHarnessSession {
|
|||
private final String toolProfile;
|
||||
private final boolean driveRuntimeRecovery;
|
||||
private final boolean cancellationTakeover;
|
||||
private final boolean suppressChildAgents;
|
||||
private Map<String, Object> lifecycleAuthority;
|
||||
private Map<String, Object> agentDefinition;
|
||||
private boolean stopMessages;
|
||||
|
|
@ -28,7 +29,8 @@ public final class LoadHarnessSession {
|
|||
|
||||
public LoadHarnessSession withChildWorkspaces(boolean value) {
|
||||
LoadHarnessSession copy = new LoadHarnessSession(harnessSessionId, managedSessionStore,
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover);
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover,
|
||||
suppressChildAgents);
|
||||
copy.agentDefinition = agentDefinition;
|
||||
copy.lifecycleAuthority = lifecycleAuthority;
|
||||
copy.childWorkspaces = value;
|
||||
|
|
@ -41,7 +43,8 @@ public final class LoadHarnessSession {
|
|||
throw new IllegalArgumentException("Invalid lifecycle authority");
|
||||
}
|
||||
LoadHarnessSession copy = new LoadHarnessSession(harnessSessionId, managedSessionStore,
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover);
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover,
|
||||
suppressChildAgents);
|
||||
copy.agentDefinition = agentDefinition;
|
||||
copy.lifecycleAuthority = Map.of("operationId", operationId, "claimGeneration", claimGeneration);
|
||||
copy.childWorkspaces = childWorkspaces;
|
||||
|
|
@ -53,7 +56,8 @@ public final class LoadHarnessSession {
|
|||
* of its message inputs, from before the load can kick it. */
|
||||
public LoadHarnessSession withStoppedMessages() {
|
||||
LoadHarnessSession copy = new LoadHarnessSession(harnessSessionId, managedSessionStore,
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover);
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover,
|
||||
suppressChildAgents);
|
||||
copy.agentDefinition = agentDefinition;
|
||||
copy.lifecycleAuthority = lifecycleAuthority;
|
||||
copy.childWorkspaces = childWorkspaces;
|
||||
|
|
@ -101,6 +105,15 @@ public final class LoadHarnessSession {
|
|||
ManagedSessionStoreConnection managedSessionStore,
|
||||
boolean passiveManagedRuntimeRecovery, String toolProfile,
|
||||
boolean driveRuntimeRecovery, boolean cancellationTakeover) {
|
||||
this(harnessSessionId, managedSessionStore, passiveManagedRuntimeRecovery,
|
||||
toolProfile, driveRuntimeRecovery, cancellationTakeover, false);
|
||||
}
|
||||
|
||||
public LoadHarnessSession(String harnessSessionId,
|
||||
ManagedSessionStoreConnection managedSessionStore,
|
||||
boolean passiveManagedRuntimeRecovery, String toolProfile,
|
||||
boolean driveRuntimeRecovery, boolean cancellationTakeover,
|
||||
boolean suppressChildAgents) {
|
||||
this.harnessSessionId = HostedHarnessClient.requireUuid(
|
||||
harnessSessionId, "harnessSessionId");
|
||||
this.managedSessionStore = managedSessionStore;
|
||||
|
|
@ -108,6 +121,7 @@ public final class LoadHarnessSession {
|
|||
this.toolProfile = toolProfile;
|
||||
this.driveRuntimeRecovery = driveRuntimeRecovery;
|
||||
this.cancellationTakeover = cancellationTakeover;
|
||||
this.suppressChildAgents = suppressChildAgents;
|
||||
}
|
||||
|
||||
String getHarnessSessionId() {
|
||||
|
|
@ -129,6 +143,9 @@ public final class LoadHarnessSession {
|
|||
if (toolProfile != null) {
|
||||
result.put("toolProfile", toolProfile);
|
||||
}
|
||||
if (suppressChildAgents) {
|
||||
result.put("suppressChildAgents", true);
|
||||
}
|
||||
if (driveRuntimeRecovery) {
|
||||
result.put("driveRuntimeRecovery", true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.alibaba.qwen.code.daemon;
|
|||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -9,6 +10,29 @@ import org.junit.jupiter.api.Test;
|
|||
class HostedAgentDefinitionRequestTest {
|
||||
private static final String SESSION = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
@Test
|
||||
void shellLoadsRetainTheDefinitionAndSuppressionThroughEveryCopy() {
|
||||
Map<String, Object> identity = Map.of("agentId", "agent_" + "a".repeat(32),
|
||||
"revision", "1", "digest", "b".repeat(64));
|
||||
LoadHarnessSession original = new LoadHarnessSession(SESSION, null,
|
||||
true, "hosted-workspace-shell/1", true, true, true)
|
||||
.withAgentDefinition(identity);
|
||||
for (LoadHarnessSession load : java.util.List.of(
|
||||
original.withChildWorkspaces(true),
|
||||
original.forLifecycle("close-1", 2),
|
||||
original.withStoppedMessages(),
|
||||
original.withChildWorkspaces(true).forLifecycle("close-1", 2)
|
||||
.withStoppedMessages())) {
|
||||
Map<String, Object> wire = load.toJson();
|
||||
assertEquals(identity, wire.get("agentDefinition"));
|
||||
assertEquals("hosted-workspace-shell/1", wire.get("toolProfile"));
|
||||
assertTrue((Boolean) wire.get("suppressChildAgents"));
|
||||
assertTrue((Boolean) wire.get("passiveManagedRuntimeRecovery"));
|
||||
assertTrue((Boolean) wire.get("driveRuntimeRecovery"));
|
||||
assertTrue((Boolean) wire.get("cancellationTakeover"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDeliversTheFullPinAndLifecycleLoadRetainsOnlyTheIdentity() {
|
||||
Map<String, Object> identity = Map.of("agentId", "agent_" + "a".repeat(32),
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission with public Session creation: the fixed server-owned profile for the built-in qwen-code agent, and the profile selected by a stored definition's single allowlisted hosted_profile tools entry for a definition-pinned Session. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grant it needs survives the cancellation), and the Session's own liveness keeps OPERATOR access even when its Workspace's background-exit observation closes some other bound Session; cancel is always allowed for its own running Turn even outside the Workspace files opt-in. A definition-pinned Session keeps its pinned revision's tool profile, model and instructions for its whole life, and a replay of the create command reuses the original pin; a WebShell Session inherits its Workspace's default Workspace selection when the request omits one. */
|
||||
/** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission with public Session creation: the server-selected profile for the built-in qwen-code agent, and the profile selected by a stored definition's single allowlisted hosted_profile tools entry for a definition-pinned Session. Newly bound qwen-code Sessions, including empty creation, persist hosted-workspace-shell/1 with mandatory default/auto-edit approval when the Shell deployment opt-in is enabled; otherwise they persist hosted-workspace-files/1 with the configured file approval mode. The switch does not override a stored AgentDefinition's compiled profile or approval mode. Public callers cannot select the built-in profile. Any caller holding the OPERATOR role may submit later Turns and rename the Session under the same opt-in, while the Workspace registry row stays ACTIVE and still backs the binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session on the frozen execution profile or pinned to a stored definition; Workspace close follows its separate close capability and lifecycle admission; the same caller may cancel a running Turn (its delivery re-checks no grant it needs survives the cancellation), and the Session's own liveness keeps OPERATOR access even when its Workspace's background-exit observation closes some other bound Session; cancel is always allowed for its own running Turn even outside the Workspace files opt-in. A definition-pinned Session keeps its pinned revision's tool profile, model and instructions for its whole life, and a replay of the create command reuses the original pin; a WebShell Session inherits its Workspace's default Workspace selection when the request omits one. Shell Sessions additionally require the Shell opt-in and valid persisted approval for fresh Turns; disabling only Shell admission preserves original-key replay and accepted approvals and cancellation while Workspace files remain enabled and persisted approval remains valid. The public Shell tool list does not include the child agent tool. Invalid approval or files-off can refuse attachment. Public Shell close, archive, unarchive and delete stay unavailable, including protocol-0. Fresh Shell sending also requires foregroundShell=true; workspaceTurns advertises submit/rename permission and is not sufficient Shell Turn admission. */
|
||||
post: operations["webShellCreateSession"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
|
|
@ -175,7 +175,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
post: operations["queryWebShellActions"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
|
|
@ -192,7 +192,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
post: operations["getWebShellAction"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
|
|
@ -209,7 +209,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
/** @description D6 serves Hosted permission Actions. Question Actions and vote_recorded remain planned. Reads follow Session authorization; the Session's recorded owner or any caller holding OPERATOR on the bound Workspace may respond; a Shell Session's Actions may be answered only by its recorded owner (an unbound Session with no recorded creator and no recorded create command answers to any caller in its tenant, matching its read ACL; a bound Session without an owner record answers through the create-command actor recorded for it, or through the Workspace role arm). Responses are durable operations, reconciled against the original journal decision. */
|
||||
post: operations["respondWebShellAction"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
|
|
@ -551,7 +551,7 @@ export interface components {
|
|||
/** @default false */
|
||||
actions: boolean;
|
||||
/**
|
||||
* @description True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session or a Session pinned to a stored definition (both on the Workspace's frozen execution profile). Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it.
|
||||
* @description True when the caller may submit later Turns of this Workspace-bound Session or rename it: the deployment enables Workspace files, the caller holds the OPERATOR role on a Workspace registry row whose state is ACTIVE and still backs the Session's binding, the actor recorded by the Workspace create command keeps OPERATOR or above, and the Session is active and undeleted and is either a qwen-code Session or a Session pinned to a stored definition (both on the Workspace's frozen execution profile). Cancelling only aborts work already running, so an OPERATOR caller may cancel a running Turn even when this is false; the capability does not gate cancellation. False for every other caller and for unbound Sessions, which do not use it. Shell Sessions also require foregroundShell=true for fresh sending; workspaceTurns may remain true with Shell admission disabled and is not sufficient Shell Turn admission.
|
||||
* @default false
|
||||
*/
|
||||
workspaceTurns: boolean;
|
||||
|
|
@ -577,6 +577,8 @@ export interface components {
|
|||
* @default false
|
||||
*/
|
||||
sessionDelete?: boolean;
|
||||
/** @description Present only for persisted Shell Sessions, with no schema default; files and unbound Sessions omit it. True when Workspace files, Shell deployment admission and persisted default/auto-edit approval are valid. False prevents fresh sending. Disabling only the Shell flag preserves accepted cancellation and approval while Workspace files remain enabled and persisted approval remains valid; files-off or invalid approval can refuse attachment. This capability does not override authorization or recovery refusals. */
|
||||
foregroundShell?: boolean;
|
||||
};
|
||||
WebShellSessionPage: {
|
||||
data: components["schemas"]["WebShellSession"][];
|
||||
|
|
@ -712,7 +714,7 @@ export interface components {
|
|||
* @default false
|
||||
*/
|
||||
workspaceContext: boolean;
|
||||
/** @description Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Read/Write/Edit Turn at creation; callers holding the OPERATOR role may submit later Turns or rename under the same opt-in while the registry row stays ACTIVE and still backs the Session's binding and the actor recorded by the Workspace create command keeps OPERATOR or above, and may cancel a running Turn (its delivery re-checks no grants), while close follows its separate capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close. */
|
||||
/** @description Supports authorized Workspace discovery, Session creation, and saved binding read-back. This capability does not advertise execution readiness. Deployments may separately opt in to an initial Workspace Turn at creation: Read/Write/Edit under hosted-workspace-files/1, or file tools plus mandatory-approval foreground Shell under hosted-workspace-shell/1 when the Shell opt-in is enabled. The public Shell tool list does not include the child agent tool. Callers holding the OPERATOR role may submit later Turns or rename under the same opt-in while the registry row stays ACTIVE and still backs the Session's binding and the actor recorded by the Workspace create command keeps OPERATOR or above, and may cancel a running Turn (its delivery re-checks no grants). Shell fresh sending also requires foregroundShell=true. Public Shell close, archive, unarchive and delete stay unavailable, including protocol-0, while close of other profiles follows its separate capability and lifecycle admission; archive, delete and unarchive follow their separate retention capabilities after reliable Workspace close. */
|
||||
workspaceBinding: boolean;
|
||||
};
|
||||
/** @description Same authorized explicit default as default_workspace, including when outside this page; null if absent or not creatable. A non-null default is active and has canCreateSession=true. */
|
||||
|
|
@ -1567,7 +1569,7 @@ export interface operations {
|
|||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
/** @description Responding requires the Session's recorded owner or a Workspace OPERATOR (action_forbidden). An unbound Session with no recorded creator and no recorded create command (anonymous open-mode creation, or an unbound Session created before the V40 migration that also has no Workspace creation record) answers to any caller in its tenant, matching its read ACL; a bound Session with no recorded owner answers through the create-command actor recorded for it, or through the Workspace role arm. The tenant filter's 403 actor_scope_mismatch also applies on this route. */
|
||||
/** @description Responding requires the Session's recorded owner or a Workspace OPERATOR (action_forbidden); a Shell Session's Actions may be answered only by its recorded owner. An unbound Session with no recorded creator and no recorded create command (anonymous open-mode creation, or an unbound Session created before the V40 migration that also has no Workspace creation record) answers to any caller in its tenant, matching its read ACL; a bound Session with no recorded owner answers through the create-command actor recorded for it, or through the Workspace role arm. The tenant filter's 403 actor_scope_mismatch also applies on this route. */
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown or unreadable Session (session_not_found), or unknown Action (action_not_found). */
|
||||
404: components["responses"]["NotFound"];
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,48 @@ describe('createJavaManagedAgentProvider', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([true, false])(
|
||||
'gates fresh Shell sending with foregroundShell=%s while retaining cancellation',
|
||||
async (enabled) => {
|
||||
const base = {
|
||||
sessionId: 'shell-1',
|
||||
status: 'ACTIVE',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastSequence: 5,
|
||||
workspace: { workspaceId: 'ws-a', cwdRelative: '.' },
|
||||
capabilities: {
|
||||
tasks: true,
|
||||
workspaceTurns: true,
|
||||
foregroundShell: enabled,
|
||||
},
|
||||
};
|
||||
const provider = createJavaManagedAgentProvider({
|
||||
baseUrl: 'https://product.example',
|
||||
fetch: vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse(base))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
...base,
|
||||
activeTurn: {
|
||||
turnId: 'turn-1',
|
||||
sessionId: 'shell-1',
|
||||
status: 'RUNNING',
|
||||
submittedAt: 2,
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(
|
||||
(await provider.getSession('shell-1', { clientId: 'c' })).capabilities,
|
||||
).toEqual({ canSend: enabled, canCancel: false, workspaceTurns: true });
|
||||
expect(
|
||||
(await provider.getSession('shell-1', { clientId: 'c' })).capabilities,
|
||||
).toEqual({ canSend: false, canCancel: true, workspaceTurns: true });
|
||||
},
|
||||
);
|
||||
|
||||
it('passes download cancellation through the host sink to the content fetch', async () => {
|
||||
const abort = new AbortController();
|
||||
const fetchImpl = vi
|
||||
|
|
|
|||
|
|
@ -385,7 +385,10 @@ function toSessionSummary(
|
|||
capabilities: {
|
||||
...(session.capabilities?.artifacts === true ? { artifacts: true } : {}),
|
||||
canSend:
|
||||
sessionActive && !active && (!session.workspace || workspaceTurns),
|
||||
sessionActive &&
|
||||
!active &&
|
||||
(!session.workspace ||
|
||||
(workspaceTurns && session.capabilities?.foregroundShell !== false)),
|
||||
// The creator may cancel a running bound Turn after the Workspace stops
|
||||
// admitting new work, so cancel is not tied to workspaceTurns; the
|
||||
// server refuses anyone else.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue