mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 14:38:23 -10:00
fix(sdk-java): align bound-Session later-Turn admission with the execution authority
Round-2 review of #13112 found the admission predicate certifying Turns that execution can never run. maySubmitWorkspaceTurn now also requires the predicates the cited authority fixes at creation (Session ACTIVE, the qwen-code agent, the frozen execution profile refs) and the creator's can_create on an ACTIVE registry, read through the same grant row shape; a can_read-revoked creator still falls through to the documented 404. Cancellation attaches passively so an abort no longer depends on the physical mount still verifying. renameSession answers a non-retryable refusal with its own status and code instead of a transient 503. requireSubmitter loads the Session once per call, and the session read paths skip the canRead probe their entry already established. The OpenAPI contract, README and both design docs now state the creator admission, its grants qualifier and the still-gated lifecycle/cwd operations consistently, and the generated WebShell types are regenerated from the corrected contract. Tests pin the bound rename admission, the two empty-creation profile escapes and the can_create-revoked arm. Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
5d4499cf95
commit
26de98cd7e
10 files changed
+193
-42
No files matched your search
@@ -14,11 +14,10 @@ and uses the deployment's global Workspace for every Session Store connection.
|
||||
|
||||
G0 enables one initial file-tool Turn admitted with Session creation. It uses
|
||||
the existing public REST route and the WebShell creation adapter that shares its
|
||||
service. The follow-up below admits later Turns for the Session's creator under
|
||||
the same opt-in, including cancel and rename; lifecycle and cwd operations
|
||||
retain their existing Workspace gates. Discovery continues to advertise only
|
||||
service. Discovery continues to advertise only
|
||||
Workspace binding, not complete Workspace execution support. G0 requires no UI
|
||||
changes; the follow-up's only UI change is enabling the creator's composer.
|
||||
changes; the follow-up's only UI changes are enabling the creator's composer and
|
||||
its Cancel control.
|
||||
|
||||
A follow-up admits later Turns for the Session's creator under the same opt-in,
|
||||
and lets the creator cancel a running Turn, which the Hosted Harness aborts and
|
||||
@@ -68,18 +67,17 @@ teardown yet.
|
||||
| Existing Broker/worker | Reuse production routing and fencing | Selected Runtime and persisted Workspace |
|
||||
| Contract and README | Document the narrow creation capability and remaining gates | Public REST and WebShell adapter |
|
||||
|
||||
Production behavior changes only under `packages/sdk-java/managed-agent-server`
|
||||
and in the private Hosted DTOs in `packages/sdk-java/qwencode`; it stays limited
|
||||
to the initial Workspace Read/Write/Edit Turn. No core authority, tool
|
||||
Production behavior changes under `packages/sdk-java/managed-agent-server`, in
|
||||
the private Hosted DTOs in `packages/sdk-java/qwencode`, and in the WebShell
|
||||
managed Sessions page and its providers (`packages/web-shell`); it covers the
|
||||
initial Workspace Read/Write/Edit Turn and the creator's later-Turn submit,
|
||||
cancel and rename admission. No core authority, tool
|
||||
execution loop, database schema or public request field needs a new
|
||||
abstraction.
|
||||
|
||||
The merged change also touched three places outside that scope, none of which
|
||||
The merged change also touched two places outside that scope, neither of which
|
||||
adds runtime behavior:
|
||||
|
||||
- **Generated WebShell types.** `packages/web-shell` regenerates
|
||||
`managed-agent-api.ts` from the updated OpenAPI descriptions; only the
|
||||
documentation comments change.
|
||||
- **Runtime Broker fault gate.** `DurableLocalRuntimeFaultGateTest` holds the
|
||||
worker's `execute` response in its fault proxy, so the first Broker cannot
|
||||
record the result before it is killed. The replacement Broker's `acquire`
|
||||
@@ -105,7 +103,9 @@ key and verify the same Session/Turn and no extra model/tool effects. Verify a
|
||||
different payload conflicts, unauthorized tenants/actors cannot create or read,
|
||||
unsupported profiles and unavailable Workspaces refuse, and disabling the
|
||||
opt-in preserves the current gate. Exercise the shared WebShell create adapter,
|
||||
unchanged later-operation gates, and unbound no-tool regression paths.
|
||||
the later-operation gates that changed (the creator's later-Turn submit, cancel
|
||||
and rename are admitted; lifecycle and cwd operations stay gated), and unbound
|
||||
no-tool regression paths.
|
||||
|
||||
Focused SDK serialization, connector, store/admission and coordinator tests
|
||||
cover create/load identity, authorization rechecks and disabled gates. Run the
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Hosted Read/Write/Edit 已能经过生产 Broker 和 worker 执行,但目前只有私有集成测试把它接到持久 Workspace 会话。公开创建在 service 和 SQL store 两层拒绝初始输入;coordinator 也拒绝有绑定的会话。Java connector 不传工具 profile,所有 Session Store 连接均使用部署的全局 Workspace。
|
||||
|
||||
G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。下文的后续改动在同一开关下为会话创建者开放后续 Turn 以及取消与重命名;生命周期与 cwd 操作保持现有 Workspace 门禁。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框。
|
||||
G0 开放随创建会话准入的一次初始文件工具轮次,使用现有公开 REST 路由及共享 service 的 WebShell 创建适配器。发现接口仍仅广播 Workspace 绑定能力,不宣称完整的 Workspace 执行支持。G0 本身无需修改 UI;后续改动唯一的 UI 变化是为创建者开放输入框与取消控件。
|
||||
|
||||
后续改动在同一开关下为会话创建者开放后续 Turn,并允许创建者取消正在运行的 Turn,由 Hosted Harness 中止该 Turn 并按原始 Runtime 身份结算。执行时每个 Turn 都按创建者的 Workspace 授权校验,因此其他 actor 以及未开启该开关的部署仍得到现有拒绝:actor 可读取该 Workspace 时为 `workspace_unavailable`,不能读取时为 `session_not_found`。创建者也可以重命名该会话。关闭、归档、删除、取消归档与 cwd 操作仍保持门禁:Runtime Broker 的 drain 只是停止为已关闭会话预热,尚无 Harness 级别的回收。
|
||||
|
||||
@@ -32,11 +32,10 @@ G0 开放随创建会话准入的一次初始文件工具轮次,使用现有
|
||||
| 现有 Broker/worker | 复用生产路由与 fencing | 所选 Runtime 及持久 Workspace |
|
||||
| 契约与 README | 记录有限的创建能力及剩余门禁 | 公开 REST 与 WebShell 适配器 |
|
||||
|
||||
生产行为只在 `packages/sdk-java/managed-agent-server` 和 `packages/sdk-java/qwencode` 的私有 Hosted DTO 中变化,且仅限于初始 Workspace Read/Write/Edit Turn。不需要为 core authority、工具执行循环、数据库 schema 或公开请求字段新增抽象。
|
||||
生产行为在 `packages/sdk-java/managed-agent-server`、`packages/sdk-java/qwencode` 的私有 Hosted DTO,以及 WebShell 托管会话页及其 provider(`packages/web-shell`)中变化,覆盖初始 Workspace Read/Write/Edit Turn 与创建者后续 Turn 的提交、取消和重命名准入。不需要为 core authority、工具执行循环、数据库 schema 或公开请求字段新增抽象。
|
||||
|
||||
合入的改动还涉及该范围之外的三处,均不增加运行时行为:
|
||||
合入的改动还涉及该范围之外的两处,均不增加运行时行为:
|
||||
|
||||
- **生成的 WebShell 类型。** `packages/web-shell` 根据更新后的 OpenAPI 描述重新生成 `managed-agent-api.ts`,只有文档注释变化。
|
||||
- **Runtime Broker 故障门禁。** `DurableLocalRuntimeFaultGateTest` 在故障代理中扣住 worker 的 `execute` 响应,使第一个 Broker 在被终止前无法记录结果。随后替换 Broker 的 `acquire` 通过 #12964 的接管对账结算该调用,测试断言这一结果(`ALREADY_SETTLED`,且只有一次物理执行),而不再同时接受取决于时序的已结算或已解决两种状态。
|
||||
- **Core resume 测试。** `background-agent-resume.test.ts` 的一个用例把 Skill 工具报告为已注册,使其列表断言不会空洞通过。该覆盖目前仍在 `main` 上。
|
||||
|
||||
@@ -44,7 +43,7 @@ G0 开放随创建会话准入的一次初始文件工具轮次,使用现有
|
||||
|
||||
使用确定性的本地模型和打包 CLI,运行真实 Spring coordinator、SQL Store、按部署配置启动的生产 Broker 及独立 worker。仅模型和可信网关 principal 使用测试夹具。Workspace registry 和 grants 作为部署数据预置;会话必须通过公开 HTTP 创建。
|
||||
|
||||
初始轮次必须在所选 Workspace 的相对 cwd 下写、编辑并读取文件,产生持久工具历史和恰好一个公开终态事件,且不改动 Harness 的诱饵目录。重复创建幂等键,验证相同 Session/Turn 且无额外模型/工具副作用。验证改变载荷冲突、未授权租户/actor 无法创建或读取、不支持的 profile 与不可用 Workspace 被拒绝,以及关闭开关后保持原门禁。覆盖共享 WebShell 创建适配器、未改变的后续操作门禁和无绑定无工具回归路径。
|
||||
初始轮次必须在所选 Workspace 的相对 cwd 下写、编辑并读取文件,产生持久工具历史和恰好一个公开终态事件,且不改动 Harness 的诱饵目录。重复创建幂等键,验证相同 Session/Turn 且无额外模型/工具副作用。验证改变载荷冲突、未授权租户/actor 无法创建或读取、不支持的 profile 与不可用 Workspace 被拒绝,以及关闭开关后保持原门禁。覆盖共享 WebShell 创建适配器、实际发生变化的后续操作门禁(创建者的后续 Turn 提交、取消与重命名被放行,生命周期与 cwd 操作仍受限)和无绑定无工具回归路径。
|
||||
|
||||
SDK 序列化、connector、store/准入及 coordinator 的定向测试覆盖 create/load 身份、权限复核与关闭的门禁。本地通过 H2 跑 Hosted 集成,并加入现有 Hosted MySQL CI 套件;单独记录本地 MySQL 是否可用。完成前执行 build、typecheck、bundle、定向测试和两轮无发现的完整 diff 自查。
|
||||
|
||||
|
||||
@@ -304,8 +304,10 @@ creation with input, including replays, while empty bound creation remains
|
||||
available. The directory mounted for a Workspace is trusted deployment data,
|
||||
not a filesystem sandbox.
|
||||
|
||||
Later Turns may be submitted and cancelled by the Session's creator under the
|
||||
same opt-in, and the creator may rename the Session. Close, archive, delete,
|
||||
Later Turns may be submitted by the Session's creator under the
|
||||
same opt-in while they can still read the Workspace (the per-caller
|
||||
`workspaceTurns` capability flag reflects this), and the creator may cancel the
|
||||
Session's running Turns and rename the Session. Close, archive, delete,
|
||||
unarchive and cwd operations and broad Workspace capability advertisement
|
||||
remain gated. Shell and in-flight recovery are separate slices.
|
||||
The existing `EmbeddedRuntimeBroker` is used through production configuration;
|
||||
@@ -436,7 +438,9 @@ local workloads. The opt-in 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 the Session's creator under the same
|
||||
opt-in; the creator may also cancel them and rename the Session. Later Turns run
|
||||
opt-in while they can still read the Workspace (the per-caller `workspaceTurns`
|
||||
capability flag reflects this); the creator may also cancel the Session's
|
||||
running Turns and rename the Session. Later Turns run
|
||||
under the creator's Workspace grants, so any other actor keeps the existing
|
||||
refusal: `workspace_unavailable` when the actor can read the Workspace,
|
||||
`session_not_found` when they cannot. Public close, archive, delete and
|
||||
|
||||
+5
-1
@@ -550,9 +550,13 @@ public class HarnessCoordinator {
|
||||
&& !harness.isWorkspaceFilesAvailable()) {
|
||||
return;
|
||||
}
|
||||
// Attach passively, like the cancellation-recovery path above:
|
||||
// an abort must not depend on the physical mount still
|
||||
// verifying, or a cancel the API already answered would be
|
||||
// dropped with only a WARN to show for it.
|
||||
Attachment attachment = harness.createOrLoad(
|
||||
session.tenantId(), session.sessionId(),
|
||||
session.harnessBootId() != null);
|
||||
session.harnessBootId() != null, true);
|
||||
if (store.bindHarness(tenantId, sessionId,
|
||||
turnId, owner, attachment.bootId())) {
|
||||
harness.cancel(session.tenantId(), session.sessionId());
|
||||
|
||||
+60
-12
@@ -41,6 +41,8 @@ import com.alibaba.qwen.code.managedagent.store.StoreModels.SnapshotRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnPage;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnSummary;
|
||||
import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException;
|
||||
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
@@ -264,6 +266,19 @@ public class ManagedAgentService {
|
||||
session.harnessBootId() != null);
|
||||
harness.rename(tenantId, sessionId, effectiveTitle);
|
||||
} catch (RuntimeException error) {
|
||||
// A non-retryable refusal (e.g. the Workspace authority's)
|
||||
// is permanent: answer it with its own status and code
|
||||
// instead of a transient 503, which would invite a fresh-key
|
||||
// retry into session_operation_active on the still-PENDING
|
||||
// command.
|
||||
if (error instanceof RuntimeBrokerException refusal
|
||||
&& !refusal.isRetryable()) {
|
||||
HttpStatus status = HttpStatus.resolve(
|
||||
refusal.getStatusCode());
|
||||
throw new ApiException(
|
||||
status == null ? HttpStatus.CONFLICT : status,
|
||||
refusal.getCode(), refusal.getMessage());
|
||||
}
|
||||
throw dependencyUnavailable("hosted_harness_unavailable",
|
||||
"The Hosted Harness could not persist the Session title.");
|
||||
}
|
||||
@@ -524,7 +539,7 @@ public class ManagedAgentService {
|
||||
// Every Session serves its task list and detail; the tasks come from the
|
||||
// Stage H records its Session store holds (H0c).
|
||||
new WebShellSessionCapabilities(true, hasActions(session),
|
||||
maySubmitWorkspaceTurn(session, actorId)));
|
||||
maySubmitWorkspaceTurn(session, actorId, true)));
|
||||
}
|
||||
|
||||
private static WebShellWorkspace webShellWorkspace(SessionRecord session) {
|
||||
@@ -673,27 +688,60 @@ public class ManagedAgentService {
|
||||
// Workspace files enabled. Everyone else keeps the existing refusal.
|
||||
private void requireSubmitter(String tenantId, String actorId,
|
||||
String sessionId) {
|
||||
if (!maySubmitWorkspaceTurn(store.requireSession(tenantId, sessionId),
|
||||
actorId)) {
|
||||
requireLegacyWorkspace(tenantId, actorId, sessionId);
|
||||
SessionRecord session = store.requireSession(tenantId, sessionId);
|
||||
if (!maySubmitWorkspaceTurn(session, actorId)) {
|
||||
requireLegacyWorkspace(session, actorId);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean maySubmitWorkspaceTurn(SessionRecord session,
|
||||
String actorId) {
|
||||
return session.workspace() != null
|
||||
&& harness.isWorkspaceFilesAvailable()
|
||||
&& workspaces.canRead(session.tenantId(), actorId,
|
||||
session.workspace().getWorkspaceId())
|
||||
&& workspaces.createdSession(session.tenantId(), actorId,
|
||||
session.sessionId());
|
||||
return maySubmitWorkspaceTurn(session, actorId, false);
|
||||
}
|
||||
|
||||
// readGranted is true on the read paths (session get/list), where the
|
||||
// page query or requireReadGrant already established the caller's
|
||||
// can_read for a bound row, so the clause would re-ask a fixed true.
|
||||
private boolean maySubmitWorkspaceTurn(SessionRecord session,
|
||||
String actorId, boolean readGranted) {
|
||||
if (session.workspace() == null || !harness.isWorkspaceFilesAvailable()) {
|
||||
return false;
|
||||
}
|
||||
// The authority execution cites (WorkspaceExecutionStore
|
||||
// .authorizePassiveAttachment) fixes these at creation: a Session
|
||||
// that fails them can never execute, so admission must not certify
|
||||
// it. Empty bound creation skips that validation by design.
|
||||
if (!"ACTIVE".equals(session.status()) || session.deletedAt() != null
|
||||
|| !"qwen-code".equals(session.agentId())
|
||||
|| !WorkspaceExecutionProfile.CONTEXT_CONFIG_REF.equals(
|
||||
session.workspace().getContextConfigRef())) {
|
||||
return false;
|
||||
}
|
||||
if ((!readGranted && !workspaces.canRead(session.tenantId(), actorId,
|
||||
session.workspace().getWorkspaceId()))
|
||||
|| !workspaces.createdSession(session.tenantId(), actorId,
|
||||
session.sessionId())) {
|
||||
return false;
|
||||
}
|
||||
// The caller is the Session's creator, so this reads the creator's
|
||||
// grant row, as the execution authority's join does: can_create on a
|
||||
// registry whose state is ACTIVE.
|
||||
ManagedWorkspaceRegistry.WorkspaceSummary summary =
|
||||
workspaces.findReadable(session.tenantId(), actorId,
|
||||
session.workspace().getWorkspaceId());
|
||||
return summary != null && summary.canCreateSession();
|
||||
}
|
||||
|
||||
void requireLegacyWorkspace(String tenantId, String actorId,
|
||||
String sessionId) {
|
||||
SessionRecord session = store.requireSession(tenantId, sessionId);
|
||||
requireLegacyWorkspace(store.requireSession(tenantId, sessionId),
|
||||
actorId);
|
||||
}
|
||||
|
||||
private void requireLegacyWorkspace(SessionRecord session,
|
||||
String actorId) {
|
||||
if (session.workspace() != null) {
|
||||
if (!workspaces.canRead(tenantId, actorId,
|
||||
if (!workspaces.canRead(session.tenantId(), actorId,
|
||||
session.workspace().getWorkspaceId())) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND,
|
||||
"session_not_found", "The Session was not found.");
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Qwen Managed Agent Public and WebShell API",
|
||||
"version": "1.27.0",
|
||||
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Full tool-result descriptors from the v1.11 design are not yet included. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 admits later Turns of a Workspace-bound Session for the Session's creator under the deployment's Workspace files opt-in, including cancel and rename; Session capabilities advertise the per-caller workspaceTurns flag."
|
||||
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Full tool-result descriptors from the v1.11 design are not yet included. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 admits later Turns of a Workspace-bound Session for the Session's creator under the deployment's Workspace files opt-in, including cancel and rename; WebShell Session capabilities advertise the per-caller workspaceTurns flag."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -159,7 +159,7 @@
|
||||
"tags": ["Public Sessions"],
|
||||
"operationId": "createSession",
|
||||
"x-qwen-implementation-status": "implemented",
|
||||
"description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. Later Workspace submit, cancel and lifecycle operations remain gated. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.",
|
||||
"description": "Workspace selection is persisted with the original actor-scoped creation receipt. With the deployment's G0 file admission opt-in, qwen-code Sessions in an authorized, preregistered Workspace may include initial input under the fixed preapproved file-tool profile. Public callers cannot select the profile. Otherwise Workspace creation must have empty input. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Check replay before resolving defaults; a durable binding is not proof of Runtime readiness.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/IdempotencyKey"
|
||||
@@ -852,7 +852,7 @@
|
||||
"tags": ["WebShell"],
|
||||
"operationId": "webShellCreateSession",
|
||||
"x-qwen-implementation-status": "implemented",
|
||||
"description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. Later Workspace submit, cancel and lifecycle operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.",
|
||||
"description": "Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -3955,7 +3955,7 @@
|
||||
"workspaceTurns": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "True when the caller may submit and cancel later Turns of this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. 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, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it."
|
||||
},
|
||||
"tasks": {
|
||||
"type": "boolean"
|
||||
|
||||
+23
@@ -237,6 +237,29 @@ class HostedPublicWorkspaceIT {
|
||||
assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace,
|
||||
"actor", 200).path("metadata").path("title").asText()).isEqualTo("Renamed " + workspace);
|
||||
|
||||
// A creator whose can_create grant is revoked keeps read access but loses
|
||||
// admission: submit, cancel and rename all answer workspace_unavailable,
|
||||
// nothing new executes, and no PENDING command row is left behind.
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?",
|
||||
tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later,
|
||||
"nocreate-later-" + workspace, "actor", 409).path("error").path("code").asText())
|
||||
.isEqualTo("workspace_unavailable");
|
||||
assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", cancel,
|
||||
"nocreate-cancel-" + workspace, "actor", 409).path("error").path("code").asText())
|
||||
.isEqualTo("workspace_unavailable");
|
||||
assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename,
|
||||
"nocreate-rename-" + workspace, "actor", 409).path("error").path("code").asText())
|
||||
.isEqualTo("workspace_unavailable");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_tool_execution WHERE harness_session_id = ?",
|
||||
Long.class, session)).isEqualTo(executions * 2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_command"
|
||||
+ " WHERE tenant_id = ? AND command_status = 'PENDING'", Integer.class, tenant)).isZero();
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?",
|
||||
tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// With the running Turn settled, revoking the creator's read grant hides the
|
||||
// bound Session from every later-Turn path: submit, cancel and rename all fall
|
||||
// through to the legacy gate and answer session_not_found.
|
||||
|
||||
+72
-2
@@ -10,11 +10,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
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.ManagedWorkspaceRegistry;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind;
|
||||
@@ -578,8 +581,9 @@ class ManagedWorkspaceAdmissionTest {
|
||||
+ " managed_agent_turn WHERE tenant_id = ?"
|
||||
+ " AND session_id = ?",
|
||||
Integer.class, tenant, sessionId)).isEqualTo(1);
|
||||
// Unarchive stays gated for bound Sessions even under the opt-in;
|
||||
// the enabled store opens rename only.
|
||||
// Unarchive stays gated for bound Sessions even under the opt-in.
|
||||
// It throws before any command row is written, so the rename probe
|
||||
// after it cannot collide with a leftover PENDING operation.
|
||||
assertThatThrownBy(() -> transaction.execute(status ->
|
||||
gated.beginSessionMutation(tenant, "UNARCHIVE_SESSION",
|
||||
"unarchive-1", digest, sessionId,
|
||||
@@ -587,12 +591,78 @@ class ManagedWorkspaceAdmissionTest {
|
||||
.isInstanceOfSatisfying(ApiException.class, error ->
|
||||
assertThat(error.getCode())
|
||||
.isEqualTo("workspace_unavailable"));
|
||||
// Rename is the one lifecycle-adjacent mutation the enabled store
|
||||
// opens for a bound Session: it begins PENDING and completes with
|
||||
// the new title.
|
||||
var rename = transaction.execute(status ->
|
||||
gated.beginSessionMutation(tenant, "RENAME_SESSION",
|
||||
"rename-1", digest, sessionId,
|
||||
SessionMutationKind.RENAME));
|
||||
assertThat(rename.status()).isEqualTo("PENDING");
|
||||
assertThat(transaction.execute(status ->
|
||||
gated.completeSessionMutation(tenant, "RENAME_SESSION",
|
||||
"rename-1", sessionId, SessionMutationKind.RENAME,
|
||||
"renamed title", "boot")).title())
|
||||
.isEqualTo("renamed title");
|
||||
// Cancel stays open for a bound Session under the opt-in.
|
||||
assertThat(gated.insertCancelCommand(tenant, "CANCEL", "cancel-1",
|
||||
digest, sessionId, admission.turnId()).turnId())
|
||||
.isEqualTo(admission.turnId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBoundCreationOutsideTheProfileIsNotAdmittedForLaterTurns() {
|
||||
String tenant = "tenant-" + UUID.randomUUID();
|
||||
register(tenant, "ws-a", "storage-a",
|
||||
WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
grant(tenant, "ws-a", "actor-a", true);
|
||||
String digest = "sha256:" + "a".repeat(64);
|
||||
// Empty bound creation skips the execution-profile validation by
|
||||
// design, so a non qwen-code agent_id can be bound; the later-Turn
|
||||
// admission gate is what must refuse it.
|
||||
String sessionId = store.insertWorkspaceSessionCommand(tenant,
|
||||
"actor-a", "create", digest, "another-agent", null, null,
|
||||
List.of(), null, new WorkspaceSelection("ws-a", "."))
|
||||
.sessionId();
|
||||
// A Session whose snapshotted profile refs are not the frozen pair
|
||||
// is refused too, even with the qwen-code agent.
|
||||
register(tenant, "ws-drift", "storage-drift");
|
||||
grant(tenant, "ws-drift", "actor-a", true);
|
||||
String driftedId = store.insertWorkspaceSessionCommand(tenant,
|
||||
"actor-a", "create-drift", digest, "qwen-code", null, null,
|
||||
List.of(), null, new WorkspaceSelection("ws-drift", "."))
|
||||
.sessionId();
|
||||
String controlId = store.insertWorkspaceSessionCommand(tenant,
|
||||
"actor-a", "create-control", digest, "qwen-code", null, null,
|
||||
List.of(), null, new WorkspaceSelection("ws-a", "."))
|
||||
.sessionId();
|
||||
UnavailableHarnessConnector enabledHarness =
|
||||
new UnavailableHarnessConnector() {
|
||||
@Override
|
||||
public boolean isWorkspaceFilesAvailable() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
ManagedAgentService enabled = new ManagedAgentService(store,
|
||||
new RequestDigests(), null, enabledHarness, registry);
|
||||
|
||||
assertThat(enabled.getWebShellSession(tenant, "actor-a", sessionId)
|
||||
.capabilities().workspaceTurns()).isFalse();
|
||||
assertThat(enabled.getWebShellSession(tenant, "actor-a", driftedId)
|
||||
.capabilities().workspaceTurns()).isFalse();
|
||||
assertThatThrownBy(() -> enabled.submitTurn(tenant, "actor-a",
|
||||
"later", sessionId,
|
||||
List.of(new InputBlock("text", "go"))))
|
||||
.isInstanceOfSatisfying(ApiException.class, error ->
|
||||
assertThat(error.getCode())
|
||||
.isEqualTo("workspace_unavailable"));
|
||||
// The same shape on the frozen profile with the qwen-code agent
|
||||
// stays admitted.
|
||||
assertThat(enabled.getWebShellSession(tenant, "actor-a", controlId)
|
||||
.capabilities().workspaceTurns()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledCreationRefusesPolicyDriftAndAnotherTenantsMount() {
|
||||
String tenant = "tenant-" + UUID.randomUUID();
|
||||
|
||||
+4
-1
@@ -233,7 +233,9 @@ class HarnessCoordinatorTest {
|
||||
AgentStateStore store = boundCancellingStore();
|
||||
HarnessConnector harness = mock(HarnessConnector.class);
|
||||
when(harness.isWorkspaceFilesAvailable()).thenReturn(true);
|
||||
when(harness.createOrLoad("tenant", "session", true))
|
||||
// Cancellation attaches passively: an abort must not depend on the
|
||||
// physical mount still verifying.
|
||||
when(harness.createOrLoad("tenant", "session", true, true))
|
||||
.thenReturn(new Attachment("boot", null, null, null));
|
||||
when(store.bindHarness(eq("tenant"), eq("session"), eq("turn"),
|
||||
anyString(), eq("boot"))).thenReturn(true);
|
||||
@@ -243,6 +245,7 @@ class HarnessCoordinatorTest {
|
||||
new ManagedAgentProperties());
|
||||
try {
|
||||
coordinator.cancel("tenant", "session", "turn");
|
||||
verify(harness).createOrLoad("tenant", "session", true, true);
|
||||
verify(harness).cancel("tenant", "session");
|
||||
} finally {
|
||||
coordinator.close();
|
||||
|
||||
@@ -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 and fixed server-owned profile with public Session creation. Later Workspace submit, cancel and lifecycle operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */
|
||||
/** @description Maps workspaceId/cwdRelative to public Workspace selection without using environmentId or absolute cwd. Shares G0's opt-in initial file-tool Turn admission and fixed server-owned profile with public Session creation. The Session creator may submit later Turns and cancel its running Turns under the same opt-in, and may rename the Session; close, archive, delete, unarchive and cwd operations remain gated. Freeze selection with the original idempotency key; admission does not prove physical directory readiness. */
|
||||
post: operations["webShellCreateSession"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -408,7 +408,7 @@ export interface components {
|
||||
/** @default false */
|
||||
actions: boolean;
|
||||
/**
|
||||
* @description True when the caller may submit and cancel later Turns of this Workspace-bound Session: the deployment enables Workspace files and the caller created the Session. 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, cancel its running Turns and rename it: the deployment enables Workspace files, the caller created the Session and currently holds Workspace read and create grants, and the Session is an active qwen-code Session on the frozen execution profile. False for every other caller and for unbound Sessions, which do not use it.
|
||||
* @default false
|
||||
*/
|
||||
workspaceTurns?: boolean;
|
||||
|
||||
Reference in new issue
Block a user