mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(managed-agent): read bounded approval input previews (#13400)
* feat(managed-agent): read bounded approval input previews * fix(managed-agent): reject invalid approval input references Validate v2 input references against committed same-Session resources before accepting the Action journal, so dangling input cannot break recovery. Keep corrupted stored input fail-soft on reads and cover the ASCII byte bound. Co-authored-by: Qwen-Coder <[email protected]> * test(managed-agent): pin two input-preview guards, log swallowed faults Three review Suggestions on the reader stage, all inside existing carriers: - inputPreview discarded the exception it swallowed, so a damaged or dangling stored input was indistinguishable from a version 1 Action and there was nothing to grep. Log the action id plus the exception type and message at warn. Every branch still returns null, and neither the payload nor the preview text reaches the log line. - The null-bytes damage shape had no test: the loop covers inline_bytes = new byte[]{1} and state = UNREFERENCED but never NULL, so the resource.bytes() != null conjunct in readResource was unguarded. Add that arm to the same loop. - The UTF-8 round-trip guard had no test either: the existing utf8 fault writes 0xff into the wrapper bytes, which readJson rejects two steps earlier. Add a surrogate fault whose escape sits in the wrapper, so readJson decodes an unpaired surrogate into payloadJson. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuubxes937 * test(managed-agent): detach log capture per test, pin two preview guards Move the ManagedActionService ListAppender attach/detach into @BeforeEach/@AfterEach so a failed assertion inside the eleven-fault loop cannot leave the appender on the JVM-global logger for the rest of the surefire fork. Same house pattern as ManagedHookAdmissionIndexTest. Add two arms to omitsMalformedPayloadsAndInternalMcpInputs so the closed payload key set (payload.size() != 2) and the blank-runtimeSessionId conjunct are pinned. Neither was reachable before: "extra" pins the wrapper arity and "runtime" is rejected by the preceding isTextual(). Test-only, no production change. Local Java verification is unavailable here (no mvn, javac 1.8 vs module target 17); the sdk-java CI lanes are the verification for this delta. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuuki1wm69 --------- Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: yiliang114 <[email protected]>
This commit is contained in:
11 files changed
+590
-12
No files matched your search
@@ -148,7 +148,7 @@ call ID and the tool name are published as the Action's `optionsRef` resource
|
||||
before the request is committed, so Java can project the Action without asking
|
||||
the Harness. The authority records only `tool_call` as the source, so the
|
||||
function call ID lives in that resource; it names a call that the committed
|
||||
assistant message carries. The public Action shows no tool arguments.
|
||||
assistant message carries. Version 1 public Actions show no tool arguments. The [Java-first input preview rollout](2026-10-04-hosted-action-input-preview.md) accepts version 2 options with an input reference and exposes a requested native tool's exact input up to 8192 UTF-8 bytes, with truncation and full-length metadata. Deploy that reader before a Harness emits version 2. Native write content is visible within the bound to Session readers; MCP internal grants are not previewed. General redaction remains separate.
|
||||
|
||||
The approval timeout is part of the Session definition, set from the create
|
||||
request with a default of 10 minutes, from 1 second to 24 hours.
|
||||
@@ -274,7 +274,7 @@ the Hosted prompt ID when a matching Java Turn exists.
|
||||
Sessions default to `yolo`. Owner checks use the existing creator record
|
||||
without adding grants or owners. Unknown creators fail closed.
|
||||
- `allow` and `deny` are stable option IDs. Actions expose both revisions, the
|
||||
function call ID, tool name and expiry. Arguments come from Items. Only one
|
||||
function call ID, tool name and expiry. Version 1 arguments are not projected to Items; version 2 can supply the bounded Action input preview described above. Only one
|
||||
Hosted approval is pending per Turn; list returns requested Actions, newest
|
||||
first, without locally expiring them.
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ Workspace 每个 Turn 获取一次,在 Turn 等待期间保持占用,与模
|
||||
| 选项 | `allow`(Allow)与 `deny`(Deny) |
|
||||
| 创建与过期时间 | 由 Harness 设定;过期时间为创建时间加上 Session 的审批超时 |
|
||||
|
||||
选项、策略版本、两个时间、Turn、模型的函数调用 ID 与工具名在请求提交之前作为 Action 的 `optionsRef` 资源发布,因此 Java 无需询问 Harness 就能投影出 Action。authority 只把 `tool_call` 记为来源,因此函数调用 ID 放在该资源中;它指向已提交的助手消息中的一个调用。公共 Action 不展示工具参数。
|
||||
选项、策略版本、两个时间、Turn、模型的函数调用 ID 与工具名在请求提交之前作为 Action 的 `optionsRef` 资源发布,因此 Java 无需询问 Harness 就能投影出 Action。authority 只把 `tool_call` 记为来源,因此函数调用 ID 放在该资源中;它指向已提交的助手消息中的一个调用。版本 1 公共 Action 不展示工具参数。[Java 先行的输入预览步骤](2026-10-04-hosted-action-input-preview.zh-CN.md)接受带输入引用的版本 2 options,并提供 requested 原生工具的精确输入,最多 8192 UTF-8 字节,附截断标记与完整长度。在 Harness 写版本 2 前先部署读取端。原生写入内容会在上限内展示给 Session 读取者;MCP 内部 grant 不提供预览。通用脱敏作为独立后续工作。
|
||||
|
||||
审批超时是 Session 定义的一部分,由创建请求设定,默认 10 分钟,范围 1 秒到 24 小时。
|
||||
|
||||
@@ -132,7 +132,7 @@ Session Store 已经会读取每一行已提交的 journal 来投影 Stage H 记
|
||||
- 只有 Session 的 owner(记录为其创建者的可信 actor)可以回答。其他 actor 得到 `403 action_forbidden`。
|
||||
- 迟到的回答得到 `409 action_expired`、`409 action_cancelled` 或 `409 action_already_resolved`,未知的 Action 得到 `404 action_not_found`。契约会新增这些错误码。
|
||||
- 对于审批模式可能询问的 Session,Session 能力 `actions` 为 `true`。Java 在 Workspace Session 受理时固定模式;迁移前的 Session 默认为 `yolo`。owner 检查使用已有创建者记录,不新增授权或 owner;无法确定创建者时拒绝回答。
|
||||
- `allow` 与 `deny` 是稳定的选项 ID。Action 提供两个版本、function call ID、工具名称及过期时间;参数从 Items 读取。每个 Turn 同时最多有一个 Hosted 审批待处理。列表只返回 `requested` Action,按从新到旧排序,不在本地将它们标记为过期。
|
||||
- `allow` 与 `deny` 是稳定的选项 ID。Action 提供两个版本、function call ID、工具名称及过期时间;版本 1 参数不会投影到 Items,版本 2 可提供上述有界 Action 输入预览。每个 Turn 同时最多有一个 Hosted 审批待处理。列表只返回 `requested` Action,按从新到旧排序,不在本地将它们标记为过期。
|
||||
|
||||
Turn 在等待期间仍读作 `running`;让客户端知道它在等待的,是它未决的 Action。
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Hosted Action input preview: Java-first rollout
|
||||
|
||||
[English](2026-10-04-hosted-action-input-preview.md) | [简体中文](2026-10-04-hosted-action-input-preview.zh-CN.md)
|
||||
|
||||
## Problem and scope
|
||||
|
||||
#13160 gates public foreground Shell admission in #13271: an approver must be able to inspect the tool input. The Harness already publishes `managed-tool-input`, but only the approval checkpoint references it. Java projects Actions from a closed `managed-action-options` version 1 resource, and transcript Items deliberately omit arguments.
|
||||
|
||||
This is the first rollout step: make Java accept version 2 options and expose a bounded input preview on both Action API surfaces. The Harness still emits version 1, and the WebShell card still uses its current transcript fallback. A subsequent change can emit version 2 and consume the preview after this reader is deployed. That writer must include the referenced input in the first Action commit: the HTTP resource closure currently follows checkpoints and extension records, but not Action options, and the Action commits before its checkpoint. Neither issue is closed by this step, and public Shell remains disabled.
|
||||
|
||||
## Contract and compatibility
|
||||
|
||||
Version 1 options keep their exact existing shape. Version 2 adds one `inputRef` field pointing to the call's already published `managed-tool-input` resource. All other fields, the approval policy and decision bytes stay unchanged. The `action.changed` envelope is unchanged; the reference is in its opaque options resource. Unknown versions and extra options fields remain rejected. At journal ingestion, validate the reference's closed shape, kind, schema version, length and digest against a committed resource in the same Session. Reject dangling references before they enter the authority journal: Workspace recovery recursively follows references in Action options. The stored options JSON already persists this reference, so no database migration is needed.
|
||||
|
||||
Deploy the Java reader before enabling a version 2 producer. An older Java reader rejects version 2 and would reject the approval's entire journal transaction. There is no negotiated options-version feature today; this step does not add a switch or a negotiation protocol. Version 1 producers and historical version 1 Actions remain readable and answerable.
|
||||
|
||||
## Preview and trust boundary
|
||||
|
||||
The public permission Action gains optional `input_preview` with `text`, `truncated` and `byte_length`; the WebShell equivalent is `inputPreview` with `text`, `truncated` and `byteLength`. Preview text is the exact `payloadJson` string stored in the tool-input wrapper, containing `{toolName,input}` as sent to the Broker. Java does not reserialize it: key order, escapes and numeric spellings remain intact. The full byte length counts this string's UTF-8 bytes. Text is a prefix of at most 8192 bytes, ending before an incomplete UTF-8 code point; it need not remain valid JSON when truncated.
|
||||
|
||||
Only requested Actions for native `read_file`, `write_file`, `edit` and `run_shell_command` receive a preview. MCP inputs contain internal grants and identities; they remain unavailable in this step instead of publishing those credentials. Unknown tool kinds also receive no preview. This is not general secret redaction: native write content and Shell command text remain visible within the byte bound to actors already allowed to read the Session.
|
||||
|
||||
All four list/detail routes retain `requireReadableSession` before projection. Reuse the committed inline-resource reader to enforce the tenant and Session scope, referenced state, reference metadata, stored length and SHA-256. A Session has one immutable journal Workspace scope; a reference cannot select another Session or tenant. Check kind `managed-tool-input`, schema version 1, strict UTF-8/JSON, the wrapper's Session identity and shape, and the payload's tool name against the Action. Missing, corrupt, malformed or mismatched input yields no preview and does not fail the Action read. Approval options, creator-only answers and transcript projection remain unchanged.
|
||||
|
||||
The Harness writer supplies the immutable binding between an approval and its captured invocation. Java validates the referenced resource, Session and tool; it does not reconstruct that binding from private checkpoints or argument-free Items. The producer follow-up must reuse the same captured input reference in the Action and checkpoint, and test distinct calls to the same tool so their inputs cannot be mixed up.
|
||||
|
||||
## Changes and validation
|
||||
|
||||
Extend the Java options reader, the shared Action projection and the existing committed-resource reader. Add the optional preview schemas to OpenAPI and regenerate WebShell types. Update the D6 Actions design in both languages to record this rollout.
|
||||
|
||||
The regression commits real version 2 Action journals and tool-input resources through the Session store, then reads public and WebShell list/detail routes. Verify exact small input, exactly 8192 bytes, 8193 ASCII bytes, and large input ending near a multibyte boundary. Reject malformed, dangling, mismatched and cross-Session/tenant references at ingestion. Then damage already stored references or resources to verify that reads still omit an unavailable preview. Also cover malformed payloads, unsupported MCP input and version 1 compatibility. Existing allow/deny, terminal settlement and access-control tests must remain green. The version 2 regression must fail on the prior Java reader before any preview is returned.
|
||||
|
||||
API response evidence is the observable result of this reader step. Browser preview rendering, real Harness version 2 emission, restart outcomes and public Shell enablement belong to the following stages; this step must not claim them as verified.
|
||||
|
||||
## Acceptance and follow-up
|
||||
|
||||
- A requested native-tool Action with valid version 2 options exposes the same bounded preview on public and WebShell list/detail APIs.
|
||||
- Unavailable input never makes the Action unreadable. Unsupported options versions, shape changes and invalid input references still fail closed at ingestion.
|
||||
- Version 1 Actions continue to be read and answered under the existing policy.
|
||||
- Generated types match the contract; both languages describe the same limits and rollout order.
|
||||
- Follow up with Harness version 2 emission and the WebShell card after deploying the Java reader, then re-evaluate #13271's remaining approval, crash and enablement gates.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Hosted Action 输入预览:Java 先行上线
|
||||
|
||||
[English](2026-10-04-hosted-action-input-preview.md) | [简体中文](2026-10-04-hosted-action-input-preview.zh-CN.md)
|
||||
|
||||
## 问题与范围
|
||||
|
||||
#13160 是 #13271 公开前台 Shell 准入的门禁:审批者必须能查看工具输入。Harness 已发布 `managed-tool-input`,但只有审批 checkpoint 引用了它。Java 从字段集合封闭的版本 1 `managed-action-options` 资源投影 Action,而 transcript Items 刻意不包含参数。
|
||||
|
||||
这是上线的第一步:让 Java 接受版本 2 options,并在两个 Action API 入口提供有界输入预览。Harness 仍写版本 1,WebShell 审批卡仍沿用当前 transcript 回退。读取端部署后,后续改动才写版本 2 并使用预览。写入端必须把被引用的输入加入第一笔 Action 提交:目前 HTTP 资源闭包只跟随 checkpoint 和扩展记录,不跟随 Action options,且 Action 先于 checkpoint 提交。本步骤不关闭这两个 issue,公开 Shell 继续禁用。
|
||||
|
||||
## 契约与兼容性
|
||||
|
||||
版本 1 options 保持原有精确字段集合。版本 2 新增一个 `inputRef` 字段,指向该调用已发布的 `managed-tool-input` 资源。其余字段、审批策略和决定字节都不变。`action.changed` envelope 不变;引用位于其不透明 options 资源中。未知版本和额外 options 字段仍被拒绝。Journal 写入时,对照同一 Session 已提交的资源验证引用的封闭字段集合、kind、schema version、长度和 digest。Workspace 恢复会递归追踪 Action options 中的引用,因此必须在悬空引用进入 authority journal 之前拒绝它。存储的 options JSON 已能保存这个引用,因此无需数据库迁移。
|
||||
|
||||
先部署 Java 读取端,再启用版本 2 写入端。旧 Java 会拒绝版本 2,从而拒绝该审批的整笔 journal 事务。目前没有协商 options 版本的能力;本步骤不新增开关或协商协议。版本 1 写入端及历史版本 1 Action 仍可读取和作答。
|
||||
|
||||
## 预览与信任边界
|
||||
|
||||
公开 permission Action 新增可选 `input_preview`,含 `text`、`truncated` 和 `byte_length`;WebShell 对应字段为 `inputPreview`,含 `text`、`truncated` 和 `byteLength`。预览文本是工具输入包装体中保存的原始 `payloadJson` 字符串,包含发送给 Broker 的 `{toolName,input}`。Java 不重新序列化它,保留键顺序、转义和数字写法。完整字节长度按该字符串的 UTF-8 字节计算。文本为最多 8192 字节的前缀,在不完整的 UTF-8 码点之前结束;截断后不保证仍是有效 JSON。
|
||||
|
||||
仅处于 requested 状态的原生 `read_file`、`write_file`、`edit` 和 `run_shell_command` Action 提供预览。MCP 输入包含内部 grant 与身份信息,本步骤保留其不可用状态,避免公开这些凭据。未知工具类型也不提供预览。这不是通用秘密脱敏:原生写入内容和 Shell 命令文本仍会在字节上限内展示给已有 Session 读取权限的 actor。
|
||||
|
||||
四个列表和详情路由都在投影前保留 `requireReadableSession`。复用已提交 inline 资源读取端,验证租户及 Session 范围、referenced 状态、引用元数据、存储长度和 SHA-256。每个 Session 只有一个不可变的 journal Workspace 范围,引用不能选择其他 Session 或租户。校验种类 `managed-tool-input`、schema 版本 1、严格 UTF-8/JSON、包装体的 Session 身份和字段集合,以及 payload 工具名与 Action 是否匹配。输入缺失、损坏、格式错误或不匹配时不返回预览,且不让 Action 读取失败。审批选项、仅创建者可作答的规则以及 transcript 投影均保持不变。
|
||||
|
||||
Harness 写入端提供审批与已捕获调用之间的不可变绑定。Java 验证被引用资源、Session 和工具,不从私有 checkpoint 或不含参数的 Items 重建该绑定。后续写入端必须在 Action 与 checkpoint 中复用同一个已捕获输入引用,并测试同一工具的不同调用,保证不会混用输入。
|
||||
|
||||
## 改动与验证
|
||||
|
||||
扩展 Java options 读取端、共享 Action 投影和已有的已提交资源读取端。在 OpenAPI 增加可选预览 schema,重新生成 WebShell 类型。同步更新 D6 Actions 设计的中英文版本,记录本次上线步骤。
|
||||
|
||||
回归测试通过 Session store 提交真实版本 2 Action journal 与工具输入资源,然后读取公开及 WebShell 列表、详情路由。验证小输入精确相等、恰好 8192 字节、8193 个 ASCII 字节,以及在多字节边界附近结束的大输入。写入时拒绝格式错误、悬空、元数据不匹配和跨 Session/租户的引用,再损坏已存储的引用或资源,验证读取仍会省略不可用的预览。还需覆盖格式错误的 payload、不支持的 MCP 输入及版本 1 兼容性。已有 allow/deny、终态结算和访问控制测试必须保持通过。版本 2 回归在旧 Java 读取端应于返回任何预览之前失败。
|
||||
|
||||
API 响应证据是这一步读取端的可观察结果。浏览器预览渲染、真实 Harness 写版本 2、重启结果和公开 Shell 启用属于后续阶段;本步骤不得声称已验证它们。
|
||||
|
||||
## 验收与后续
|
||||
|
||||
- 带有效版本 2 options 的 requested 原生工具 Action 在公开与 WebShell 列表、详情 API 提供一致的有界预览。
|
||||
- 输入不可用不会让 Action 无法读取;未知 options 版本、字段集合变化和无效输入引用仍在写入时严格拒绝。
|
||||
- 版本 1 Action 继续按已有策略读取和作答。
|
||||
- 生成类型与契约一致;两种语言描述相同的上限与上线顺序。
|
||||
- Java 读取端部署后,接续 Harness 写版本 2 与 WebShell 审批卡,再重新核对 #13271 剩余的审批、崩溃和启用门禁。
|
||||
+78
@@ -7,12 +7,17 @@ import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellCommandOperation
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellPage;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedActionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedActionStore.Action;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecordStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationAdmission;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.ToolPublicationContract;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -22,11 +27,18 @@ import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class ManagedActionService {
|
||||
private static final Logger LOG =
|
||||
LoggerFactory.getLogger(ManagedActionService.class);
|
||||
private static final int INPUT_PREVIEW_BYTES = 8192;
|
||||
private static final Set<String> PREVIEW_TOOLS =
|
||||
Set.of("read_file", "write_file", "edit", "run_shell_command");
|
||||
private final ManagedAgentService sessions;
|
||||
private final ManagedActionStore actions;
|
||||
private final ManagedExtensionRecordStore resources;
|
||||
private final ActionResponseCoordinator coordinator;
|
||||
private final RequestDigests digests;
|
||||
private final Clock clock;
|
||||
@@ -35,12 +47,14 @@ public class ManagedActionService {
|
||||
public ManagedActionService(
|
||||
ManagedAgentService sessions,
|
||||
ManagedActionStore actions,
|
||||
ManagedExtensionRecordStore resources,
|
||||
ActionResponseCoordinator coordinator,
|
||||
RequestDigests digests,
|
||||
Clock clock,
|
||||
ObjectMapper json) {
|
||||
this.sessions = sessions;
|
||||
this.actions = actions;
|
||||
this.resources = resources;
|
||||
this.coordinator = coordinator;
|
||||
this.digests = digests;
|
||||
this.clock = clock;
|
||||
@@ -236,6 +250,10 @@ public class ManagedActionService {
|
||||
actions.publicTurnId(tenant, session, options.path("turnId").asText())
|
||||
.ifPresent(turn -> result.put(web ? "turnId" : "turn_id", turn));
|
||||
result.set("options", options.path("options"));
|
||||
JsonNode preview = inputPreview(tenant, session, action, web);
|
||||
if (preview != null) {
|
||||
result.set(web ? "inputPreview" : "input_preview", preview);
|
||||
}
|
||||
if (action.decisionReceiptId() != null) {
|
||||
result.put(
|
||||
web ? "decisionReceiptId" : "decision_receipt_id", action.decisionReceiptId());
|
||||
@@ -243,6 +261,66 @@ public class ManagedActionService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonNode inputPreview(String tenant, String session, Action action, boolean web) {
|
||||
JsonNode options = action.options();
|
||||
String tool = options.path("toolName").asText();
|
||||
if (!"requested".equals(action.state())
|
||||
|| options.path("v").asLong() != 2
|
||||
|| !PREVIEW_TOOLS.contains(tool)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonNode ref = options.path("inputRef");
|
||||
if (!"managed-tool-input".equals(ref.path("kind").asText())
|
||||
|| ref.path("schemaVersion").asLong() != 1
|
||||
|| ref.path("byteLength").asLong() < 1
|
||||
|| ref.path("byteLength").asLong()
|
||||
> ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
var resource = resources.readCommittedResource(tenant, session, ref);
|
||||
JsonNode wrapper = ToolPublicationContract.readJson(resource.bytes());
|
||||
if (wrapper == null || !wrapper.isObject()
|
||||
|| wrapper.size() != 3
|
||||
|| !session.equals(wrapper.path("harnessSessionId").asText())
|
||||
|| !wrapper.path("runtimeSessionId").isTextual()
|
||||
|| wrapper.path("runtimeSessionId").asText().isBlank()
|
||||
|| !wrapper.path("payloadJson").isTextual()) {
|
||||
return null;
|
||||
}
|
||||
String text = wrapper.path("payloadJson").asText();
|
||||
JsonNode payload = ManagedExtensionRecordStore.parse(text);
|
||||
if (payload == null
|
||||
|| payload.size() != 2
|
||||
|| !tool.equals(payload.path("toolName").asText())
|
||||
|| !payload.path("input").isObject()) {
|
||||
return null;
|
||||
}
|
||||
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
|
||||
if (!text.equals(new String(bytes, StandardCharsets.UTF_8))) {
|
||||
return null;
|
||||
}
|
||||
int end = Math.min(INPUT_PREVIEW_BYTES, bytes.length);
|
||||
while (end < bytes.length && (bytes[end] & 0xc0) == 0x80) {
|
||||
end--;
|
||||
}
|
||||
return json.createObjectNode()
|
||||
.put("text", new String(bytes, 0, end, StandardCharsets.UTF_8))
|
||||
.put("truncated", end < bytes.length)
|
||||
.put(web ? "byteLength" : "byte_length", bytes.length);
|
||||
} catch (ApiException | IllegalArgumentException error) {
|
||||
// Stored-input integrity failure, not the normal version 1 path.
|
||||
// The preview is still omitted; the reason must remain greppable
|
||||
// and must never carry payload or preview text.
|
||||
LOG.warn(
|
||||
"Omitting Managed Action {} input preview, stored input rejected: {}: {}",
|
||||
action.id(),
|
||||
error.getClass().getSimpleName(),
|
||||
error.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String lower(String value) {
|
||||
return value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
+11
-4
@@ -184,8 +184,7 @@ public class ManagedActionStore {
|
||||
JsonNode ref = payload.path("optionsRef");
|
||||
StoredResource resource = resource(ref, "managed-action-options", resources);
|
||||
JsonNode options = read(new String(resource.bytes(), StandardCharsets.UTF_8));
|
||||
closed(
|
||||
options,
|
||||
List<String> optionFields = new ArrayList<>(List.of(
|
||||
"v",
|
||||
"requestId",
|
||||
"turnId",
|
||||
@@ -195,10 +194,15 @@ public class ManagedActionStore {
|
||||
"inputRevision",
|
||||
"createdAt",
|
||||
"expiresAt",
|
||||
"options");
|
||||
"options"));
|
||||
if (options.path("v").asLong() == 2) {
|
||||
optionFields.add("inputRef");
|
||||
}
|
||||
closed(options, optionFields.toArray(String[]::new));
|
||||
require(
|
||||
safeNumber(options.path("v"))
|
||||
&& options.path("v").asLong() == 1
|
||||
&& (options.path("v").asLong() == 1
|
||||
|| options.path("v").asLong() == 2)
|
||||
&& id.equals(options.path("requestId").asText())
|
||||
&& safeNumber(options.path("inputRevision"))
|
||||
&& options.path("inputRevision").asLong()
|
||||
@@ -222,6 +226,9 @@ public class ManagedActionStore {
|
||||
closed(option, "id", "label");
|
||||
require(text(option.path("label")));
|
||||
}
|
||||
if (options.path("v").asLong() == 2) {
|
||||
resource(options.path("inputRef"), "managed-tool-input", resources);
|
||||
}
|
||||
Action previous = find(tenantId, sessionId, id).orElse(null);
|
||||
require(
|
||||
previous == null
|
||||
|
||||
+6
-1
@@ -144,11 +144,16 @@ public class ManagedExtensionRecordStore {
|
||||
/** Reads only a committed resource in this Session's scope. */
|
||||
public JsonNode readRecordResource(String tenantId, String sessionId,
|
||||
JsonNode ref) {
|
||||
return readBody(readCommittedResource(tenantId, sessionId, ref));
|
||||
}
|
||||
|
||||
public StoredResource readCommittedResource(String tenantId, String sessionId,
|
||||
JsonNode ref) {
|
||||
ManagedExtensionRecords.durableRef(ref, "recordResource");
|
||||
StoredResource resource = readResource(tenantId, sessionId,
|
||||
ref.get("resourceId").textValue());
|
||||
requireReference(resource, ref);
|
||||
return readBody(resource);
|
||||
return resource;
|
||||
}
|
||||
|
||||
private StoredResource readResource(String tenantId, String sessionId,
|
||||
|
||||
+56
-2
@@ -5999,6 +5999,9 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"input_preview": {
|
||||
"$ref": "#/components/schemas/ActionInputPreview"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
@@ -6016,7 +6019,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "At most one requested Hosted approval per Turn. allow and deny are stable option IDs. Tool arguments are read from Items by function_call_id (functionCallId in WebShell). Public timestamps are milliseconds, as recorded by the Harness. Decision receipts are opaque product IDs."
|
||||
"description": "At most one requested Hosted approval per Turn. allow and deny are stable option IDs. A requested native-tool Action may carry a bounded input preview when the producer supplies an input reference. Version 1 Actions and unavailable inputs omit it. Public timestamps are milliseconds, as recorded by the Harness. Decision receipts are opaque product IDs."
|
||||
},
|
||||
"QuestionAction": {
|
||||
"type": "object",
|
||||
@@ -7072,6 +7075,9 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"inputPreview": {
|
||||
"$ref": "#/components/schemas/WebShellActionInputPreview"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
@@ -7089,7 +7095,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "At most one requested Hosted approval per Turn. allow and deny are stable option IDs. Tool arguments are read from Items by function_call_id (functionCallId in WebShell). Public timestamps are milliseconds, as recorded by the Harness. Decision receipts are opaque product IDs."
|
||||
"description": "At most one requested Hosted approval per Turn. allow and deny are stable option IDs. A requested native-tool Action may carry a bounded input preview when the producer supplies an input reference. Version 1 Actions and unavailable inputs omit it. Public timestamps are milliseconds, as recorded by the Harness. Decision receipts are opaque product IDs."
|
||||
},
|
||||
"WebShellPermissionResponse": {
|
||||
"type": "object",
|
||||
@@ -8551,6 +8557,54 @@
|
||||
"default": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"ActionInputPreview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["text", "truncated", "byte_length"],
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 8192,
|
||||
"description": "Exact prefix of the stored payloadJson ({toolName,input}), at most 8192 UTF-8 bytes ending at a complete code point. Truncated text may not be valid JSON."
|
||||
},
|
||||
"truncated": {
|
||||
"type": "boolean",
|
||||
"description": "True when text is shorter than the complete UTF-8 input."
|
||||
},
|
||||
"byte_length": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0,
|
||||
"maximum": 65536,
|
||||
"description": "Byte length of the complete payloadJson in UTF-8, before truncation."
|
||||
}
|
||||
},
|
||||
"description": "Optional preview for requested native read_file, write_file, edit or run_shell_command approvals. Missing or invalid input, and internal MCP inputs, omit the preview. No general secret redaction is performed; visibility follows Session read permissions. Deploy the version 2 Java reader before a Harness producer starts writing version 2 options."
|
||||
},
|
||||
"WebShellActionInputPreview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["text", "truncated", "byteLength"],
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 8192,
|
||||
"description": "Exact prefix of the stored payloadJson ({toolName,input}), at most 8192 UTF-8 bytes ending at a complete code point. Truncated text may not be valid JSON."
|
||||
},
|
||||
"truncated": {
|
||||
"type": "boolean",
|
||||
"description": "True when text is shorter than the complete UTF-8 input."
|
||||
},
|
||||
"byteLength": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0,
|
||||
"maximum": 65536,
|
||||
"description": "Byte length of the complete payloadJson in UTF-8, before truncation."
|
||||
}
|
||||
},
|
||||
"description": "Optional preview for requested native read_file, write_file, edit or run_shell_command approvals. Missing or invalid input, and internal MCP inputs, omit the preview. No general secret redaction is performed; visibility follows Session read permissions. Deploy the version 2 Java reader before a Harness producer starts writing version 2 options."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -17,6 +17,7 @@ final class ActionJournal {
|
||||
final String id = "tool_approval_" + UUID.randomUUID().toString().replace("-", "");
|
||||
final ObjectNode options;
|
||||
private final ExtensionRecordJournal journal;
|
||||
private byte[] input;
|
||||
private int changes;
|
||||
|
||||
ActionJournal(
|
||||
@@ -48,6 +49,13 @@ final class ActionJournal {
|
||||
options.withArray("options").addObject().put("id", "deny").put("label", "Deny");
|
||||
}
|
||||
|
||||
ActionJournal withInput(byte[] bytes) {
|
||||
input = bytes;
|
||||
options.put("v", 2);
|
||||
options.set("inputRef", reference(bytes, "managed-tool-input"));
|
||||
return this;
|
||||
}
|
||||
|
||||
CommitTransactionRequest request(String state, JsonNode response) throws Exception {
|
||||
byte[] bytes = ExtensionRecordJournal.bytes(options);
|
||||
byte[] decision =
|
||||
@@ -97,6 +105,7 @@ final class ActionJournal {
|
||||
records -> records);
|
||||
List<CommitResource> resources = new ArrayList<>();
|
||||
resources.add(resource(bytes, "managed-action-options"));
|
||||
if (input != null) resources.add(resource(input, "managed-tool-input"));
|
||||
if (decision != null) resources.add(resource(decision, "managed-action-decision"));
|
||||
ObjectNode request = JSON.valueToTree(original);
|
||||
request.set("resources", JSON.valueToTree(resources));
|
||||
|
||||
+330
@@ -10,10 +10,15 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
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.harness.HarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.service.ManagedActionService;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedActionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
|
||||
@@ -22,8 +27,13 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.stubbing.Answer;
|
||||
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;
|
||||
@@ -34,7 +44,9 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -62,6 +74,23 @@ class ManagedActionsTest {
|
||||
@TestBean(methodName = "createHarness")
|
||||
private HarnessConnector harness;
|
||||
|
||||
// The logback context is JVM-wide, so a failed assertion must not leave the
|
||||
// capture attached for the rest of the surefire fork.
|
||||
private final Logger serviceLog = (Logger) LoggerFactory.getLogger(ManagedActionService.class);
|
||||
private final ListAppender<ILoggingEvent> logged = new ListAppender<>();
|
||||
|
||||
@BeforeEach
|
||||
void captureServiceLog() {
|
||||
logged.start();
|
||||
serviceLog.addAppender(logged);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void releaseServiceLog() {
|
||||
serviceLog.detachAppender(logged);
|
||||
logged.stop();
|
||||
}
|
||||
|
||||
private static final Map<String, Answer<Void>> responses = new ConcurrentHashMap<>();
|
||||
|
||||
static HarnessConnector createHarness() {
|
||||
@@ -76,6 +105,259 @@ class ManagedActionsTest {
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"read_file", "write_file", "edit", "run_shell_command"})
|
||||
void previewsExactInputOnPublicAndWebShellListsAndDetails(String tool) throws Exception {
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
String arguments = switch (tool) {
|
||||
case "read_file" -> "{ \"file_path\" : \"notes.md\", \"offset\" : 1e2 }";
|
||||
case "write_file" -> "{ \"file_path\" : \"notes.md\", \"content\" : \"\\u4e2d\\n\" }";
|
||||
case "edit" -> "{ \"file_path\" : \"notes.md\", \"old_string\" : \"a\", \"new_string\" : \"b\" }";
|
||||
case "run_shell_command" -> "{ \"command\" : \"printf 'text'\" }";
|
||||
default -> throw new AssertionError(tool);
|
||||
};
|
||||
String payload = " { \"toolName\" : \"" + tool + "\", \"input\" : " + arguments + " } ";
|
||||
ActionJournal journal = inputJournal(tenant, session, payload);
|
||||
journal.options.put("toolName", tool);
|
||||
journal.change("requested", null);
|
||||
for (JsonNode view : inputViews(tenant, session, journal.id)) {
|
||||
boolean web = view.has("actionId");
|
||||
JsonNode preview = view.path(web ? "inputPreview" : "input_preview");
|
||||
assertThat(preview.path("text").asText()).isEqualTo(payload);
|
||||
assertThat(preview.path("truncated").asBoolean()).isFalse();
|
||||
assertThat(preview.path(web ? "byteLength" : "byte_length").asLong())
|
||||
.isEqualTo(payload.getBytes(StandardCharsets.UTF_8).length);
|
||||
}
|
||||
mvc.perform(auth(get(path(session, journal.id)), tenant(), "owner"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundsInputByUtf8BytesWithoutSplittingCodePoints() throws Exception {
|
||||
String prefix = "{\"toolName\":\"write_file\",\"input\":{\"file_path\":\"notes.md\",\"content\":\"";
|
||||
String suffix = "\"}}";
|
||||
for (String boundary : List.of("exact", "ascii-over", "multibyte")) {
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
boolean multibyte = "multibyte".equals(boundary);
|
||||
int length = "ascii-over".equals(boundary) ? 8193 : 8192;
|
||||
int prefixBytes = multibyte ? 8191 : 8192;
|
||||
String payload = multibyte
|
||||
? prefix + "a".repeat(8191 - prefix.length()) + "😀tail" + suffix
|
||||
: prefix + "a".repeat(length - prefix.length() - suffix.length()) + suffix;
|
||||
ActionJournal journal = inputAction(tenant, session, payload);
|
||||
for (JsonNode view : inputViews(tenant, session, journal.id)) {
|
||||
boolean web = view.has("actionId");
|
||||
JsonNode preview = view.path(web ? "inputPreview" : "input_preview");
|
||||
String text = preview.path("text").asText();
|
||||
assertThat(text).isEqualTo(payload.substring(0, prefixBytes));
|
||||
assertThat(text.getBytes(StandardCharsets.UTF_8).length)
|
||||
.isEqualTo(prefixBytes);
|
||||
assertThat(preview.path("truncated").asBoolean())
|
||||
.isEqualTo(!"exact".equals(boundary));
|
||||
assertThat(preview.path(web ? "byteLength" : "byte_length").asLong())
|
||||
.isEqualTo(payload.getBytes(StandardCharsets.UTF_8).length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingInvalidAndCorruptInputNeverFailsTheActionRead() throws Exception {
|
||||
for (String fault : List.of("missing", "null", "scalar", "shape", "kind", "version", "length", "digest",
|
||||
"corrupt", "unreferenced", "null-bytes")) {
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
ActionJournal journal = inputJournal(tenant, session, "{\"toolName\":\"write_file\",\"input\":{}}");
|
||||
ObjectNode ref = (ObjectNode) journal.options.path("inputRef");
|
||||
String resourceId = ref.path("resourceId").asText();
|
||||
journal.change("requested", null);
|
||||
switch (fault) {
|
||||
case "missing" -> ref.put("resourceId", "missing-input");
|
||||
case "null" -> journal.options.putNull("inputRef");
|
||||
case "scalar" -> journal.options.put("inputRef", 1);
|
||||
case "shape" -> ref.remove("digest");
|
||||
case "kind" -> ref.put("kind", "managed-action-options");
|
||||
case "version" -> ref.put("schemaVersion", 2);
|
||||
case "length" -> ref.put("byteLength", 1);
|
||||
case "digest" -> ref.put("digest", "0".repeat(64));
|
||||
default -> {}
|
||||
}
|
||||
if ("corrupt".equals(fault)) {
|
||||
jdbc.update("UPDATE qwen_managed_session_resource SET inline_bytes = ?"
|
||||
+ " WHERE tenant_id = ? AND session_id = ? AND resource_id = ?",
|
||||
new byte[] {1}, tenant, session, resourceId);
|
||||
} else if ("null-bytes".equals(fault)) {
|
||||
// The damage shape the other two arms miss: the row is still
|
||||
// committed and REFERENCED, but its bytes are gone.
|
||||
jdbc.update("UPDATE qwen_managed_session_resource SET inline_bytes = NULL"
|
||||
+ " WHERE tenant_id = ? AND session_id = ? AND resource_id = ?",
|
||||
tenant, session, resourceId);
|
||||
} else if ("unreferenced".equals(fault)) {
|
||||
jdbc.update("UPDATE qwen_managed_session_resource SET state = 'UNREFERENCED'"
|
||||
+ " WHERE tenant_id = ? AND session_id = ? AND resource_id = ?",
|
||||
tenant, session, resourceId);
|
||||
} else {
|
||||
corruptOptions(tenant, session, journal);
|
||||
}
|
||||
logged.list.clear();
|
||||
assertNoInputPreview(tenant, session, journal.id);
|
||||
if (List.of("missing", "digest", "corrupt", "unreferenced", "null-bytes").contains(fault)) {
|
||||
// Omitting the preview is deliberate; its cause must still be
|
||||
// greppable, and must never carry the stored payload.
|
||||
assertThat(logged.list).anySatisfy(event ->
|
||||
assertThat(event.getFormattedMessage()).contains(journal.id));
|
||||
assertThat(logged.list).allSatisfy(event ->
|
||||
assertThat(event.getFormattedMessage()).doesNotContain("toolName"));
|
||||
}
|
||||
}
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
ActionJournal plain = action(tenant, session, 100, 1000);
|
||||
logged.list.clear();
|
||||
inputViews(tenant, session, plain.id);
|
||||
// A version 1 Action stops at the eligibility gate and must not warn.
|
||||
assertThat(logged.list).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesCrossSessionAndCrossTenantInputReferences() throws Exception {
|
||||
for (boolean otherTenant : List.of(false, true)) {
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
String foreignTenant = otherTenant ? tenant() : tenant;
|
||||
String foreignSession = session(foreignTenant);
|
||||
ActionJournal foreign = new ActionJournal(journals, foreignTenant, foreignSession, 100, 1000)
|
||||
.withInput(inputBytes(session, "{\"toolName\":\"write_file\",\"input\":{\"content\":\"foreign secret\"}}"));
|
||||
foreign.change("requested", null);
|
||||
ActionJournal local = new ActionJournal(journals, tenant, session, 100, 1000);
|
||||
local.options.put("v", 2);
|
||||
local.options.set("inputRef", foreign.options.path("inputRef").deepCopy());
|
||||
assertThatThrownBy(() -> local.change("requested", null))
|
||||
.isInstanceOfSatisfying(ApiException.class, error -> assertThat(error.getCode())
|
||||
.isEqualTo("managed_session_resource_missing"));
|
||||
assertThat(actions.find(tenant, session, local.id)).isEmpty();
|
||||
ActionJournal committed = new ActionJournal(local, 100, 1000)
|
||||
.withInput(inputBytes(session, "{\"toolName\":\"write_file\",\"input\":{\"content\":\"local input\"}}"));
|
||||
committed.change("requested", null);
|
||||
committed.options.set("inputRef", foreign.options.path("inputRef").deepCopy());
|
||||
corruptOptions(tenant, session, committed);
|
||||
assertNoInputPreview(tenant, session, committed.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsMalformedPayloadsAndInternalMcpInputs() throws Exception {
|
||||
for (String fault : List.of("session", "runtime", "extra", "json", "duplicate", "tool",
|
||||
"scalar", "mcp", "utf8", "empty", "surrogate", "payloadField", "blankRuntime")) {
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
ObjectNode wrapper = (ObjectNode) json.readTree(inputBytes(session, "{\"toolName\":\"write_file\",\"input\":{}}"));
|
||||
switch (fault) {
|
||||
case "session" -> wrapper.put("harnessSessionId", "other-session");
|
||||
case "runtime" -> wrapper.put("runtimeSessionId", 1);
|
||||
case "extra" -> wrapper.put("extra", "not part of the wrapper");
|
||||
case "json" -> wrapper.put("payloadJson", "{broken");
|
||||
case "duplicate" -> wrapper.put("payloadJson", "{\"toolName\":\"write_file\",\"toolName\":\"edit\",\"input\":{}}");
|
||||
case "tool" -> wrapper.put("payloadJson", "{\"toolName\":\"edit\",\"input\":{}}");
|
||||
case "scalar" -> wrapper.put("payloadJson", "{\"toolName\":\"write_file\",\"input\":\"not an object\"}");
|
||||
case "mcp" -> wrapper.put("payloadJson", "{\"toolName\":\"managed_mcp_call\",\"input\":{\"grant\":\"internal authorization\"}}");
|
||||
// The two guards no other arm reaches: a producer-added payload field, and a
|
||||
// blank (not merely non-textual) runtimeSessionId.
|
||||
case "payloadField" -> wrapper.put(
|
||||
"payloadJson", "{\"toolName\":\"write_file\",\"input\":{},\"grant\":\"internal authorization\"}");
|
||||
case "blankRuntime" -> wrapper.put("runtimeSessionId", "");
|
||||
default -> {}
|
||||
}
|
||||
byte[] bytes = json.writeValueAsBytes(wrapper);
|
||||
if ("empty".equals(fault)) {
|
||||
bytes = new byte[] {32};
|
||||
} else if ("utf8".equals(fault)) {
|
||||
int offset = new String(bytes, StandardCharsets.UTF_8).indexOf("runtime-actions");
|
||||
bytes[offset] = (byte) 0xff;
|
||||
} else if ("surrogate".equals(fault)) {
|
||||
// The escape has to sit in the wrapper bytes, so that readJson
|
||||
// decodes it into an unpaired surrogate inside payloadJson: the
|
||||
// wrapper stays valid UTF-8 while the payload text no longer
|
||||
// round-trips through it, which is what the reader must reject.
|
||||
bytes = ("{\"harnessSessionId\":\"" + session + "\",\"runtimeSessionId\":\"runtime-actions\","
|
||||
+ "\"payloadJson\":\"{\\\"toolName\\\":\\\"write_file\\\","
|
||||
+ "\\\"input\\\":{\\\"content\\\":\\\"\\uD800\\\"}}\"}")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
ActionJournal journal = new ActionJournal(journals, tenant, session, 100, 1000).withInput(bytes);
|
||||
if ("mcp".equals(fault)) {
|
||||
journal.options.put("toolName", "managed_mcp_call");
|
||||
}
|
||||
journal.change("requested", null);
|
||||
assertNoInputPreview(tenant, session, journal.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void stillRejectsUnknownOptionsVersionsAndFields() throws Exception {
|
||||
for (String fault : List.of("oldExtra", "missingRef", "version", "extra", "danglingRef",
|
||||
"nullRef", "scalarRef", "missingRefField", "extraRefField", "refKind", "refVersion",
|
||||
"refLength", "refDigest")) {
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
ActionJournal journal = new ActionJournal(journals, tenant, session, 100, 1000);
|
||||
switch (fault) {
|
||||
case "oldExtra" -> journal.options.putNull("inputRef");
|
||||
case "missingRef" -> journal.options.put("v", 2);
|
||||
case "version" -> journal.options.put("v", 3);
|
||||
case "extra" -> {
|
||||
journal.withInput(inputBytes(session, "{\"toolName\":\"write_file\",\"input\":{}}"));
|
||||
journal.options.put("extra", true);
|
||||
}
|
||||
default -> {
|
||||
journal.withInput(inputBytes(session, "{\"toolName\":\"write_file\",\"input\":{}}"));
|
||||
ObjectNode ref = (ObjectNode) journal.options.path("inputRef");
|
||||
switch (fault) {
|
||||
case "danglingRef" -> ref.put("resourceId", "missing-input");
|
||||
case "nullRef" -> journal.options.putNull("inputRef");
|
||||
case "scalarRef" -> journal.options.put("inputRef", 1);
|
||||
case "missingRefField" -> ref.remove("digest");
|
||||
case "extraRefField" -> ref.put("extra", true);
|
||||
case "refKind" -> ref.put("kind", "managed-action-options");
|
||||
case "refVersion" -> ref.put("schemaVersion", 2);
|
||||
case "refLength" -> ref.put("byteLength", 1);
|
||||
case "refDigest" -> ref.put("digest", "0".repeat(64));
|
||||
default -> throw new AssertionError(fault);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThatThrownBy(() -> journal.change("requested", null), "invalid inputRef (%s)", fault)
|
||||
.isInstanceOfSatisfying(ApiException.class, error -> assertThat(error.getCode())
|
||||
.isEqualTo("danglingRef".equals(fault)
|
||||
? "managed_session_resource_missing" : "managed_session_action_rejected"));
|
||||
assertThat(actions.find(tenant, session, journal.id)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"allow", "deny"})
|
||||
void answersV2WithTheExistingPolicyAndKeepsTerminalDetail(String option) throws Exception {
|
||||
String tenant = tenant();
|
||||
String session = session(tenant);
|
||||
ActionJournal journal = inputAction(tenant, session, "{\"toolName\":\"write_file\",\"input\":{}}");
|
||||
responses.put(journal.id, call -> {
|
||||
journal.change("decided", call.getArgument(3));
|
||||
return null;
|
||||
});
|
||||
JsonNode operation = readAccepted(auth(post(path(session, journal.id) + "/responses"), tenant, "owner")
|
||||
.header("Idempotency-Key", "v2-answer")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(response(option)));
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(
|
||||
sessions.findOperation(tenant, session, operation.path("id").asText()).orElseThrow().state())
|
||||
.isEqualTo("COMPLETED"));
|
||||
JsonNode terminal = read(auth(get(path(session, journal.id)), tenant, "owner"));
|
||||
assertThat(terminal.path("state").asText()).isEqualTo("decided");
|
||||
assertThat(terminal.has("input_preview")).isFalse();
|
||||
assertThat(terminal.path("decision_receipt_id").asText()).startsWith("decision_");
|
||||
assertThat(OpenApiContract.load().validate("/components/schemas/PublicAction", terminal)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedActionJournalWithoutProjectingIt() throws Exception {
|
||||
for (String field :
|
||||
@@ -170,6 +452,7 @@ class ManagedActionsTest {
|
||||
.isEmpty();
|
||||
assertThat(publicView.path("function_call_id").asText()).isEqualTo("call-actions");
|
||||
assertThat(publicView.path("tool_name").asText()).isEqualTo("write_file");
|
||||
assertThat(publicView.has("input_preview")).isFalse();
|
||||
JsonNode web =
|
||||
read(
|
||||
auth(post("/api/agent/web-shell/v1/actions/get"), tenant, "owner")
|
||||
@@ -184,6 +467,7 @@ class ManagedActionsTest {
|
||||
assertThat(OpenApiContract.load().validate("/components/schemas/WebShellAction", web))
|
||||
.isEmpty();
|
||||
assertThat(web.path("inputRevision").asLong()).isEqualTo(1);
|
||||
assertThat(web.has("inputPreview")).isFalse();
|
||||
mvc.perform(auth(get("/v1/agents/sessions/{session}/actions", session), tenant, "owner"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.length()").value(1));
|
||||
@@ -544,6 +828,52 @@ class ManagedActionsTest {
|
||||
.isZero();
|
||||
}
|
||||
|
||||
private byte[] inputBytes(String session, String payload) throws Exception {
|
||||
return json.writeValueAsBytes(Map.of("harnessSessionId", session,
|
||||
"runtimeSessionId", "runtime-actions", "payloadJson", payload));
|
||||
}
|
||||
|
||||
private ActionJournal inputJournal(String tenant, String session, String payload) throws Exception {
|
||||
return new ActionJournal(journals, tenant, session, 100, 9007199254740991L)
|
||||
.withInput(inputBytes(session, payload));
|
||||
}
|
||||
|
||||
private ActionJournal inputAction(String tenant, String session, String payload) throws Exception {
|
||||
ActionJournal journal = inputJournal(tenant, session, payload);
|
||||
journal.change("requested", null);
|
||||
return journal;
|
||||
}
|
||||
|
||||
private void corruptOptions(String tenant, String session, ActionJournal journal) throws Exception {
|
||||
assertThat(jdbc.update("UPDATE managed_agent_action SET options_json = ?"
|
||||
+ " WHERE tenant_id = ? AND session_id = ? AND action_id = ?",
|
||||
json.writeValueAsString(journal.options), tenant, session, journal.id)).isEqualTo(1);
|
||||
}
|
||||
|
||||
private List<JsonNode> inputViews(String tenant, String session, String id) throws Exception {
|
||||
List<JsonNode> views = List.of(
|
||||
read(auth(get(path(session, id)), tenant, "owner")),
|
||||
read(auth(post("/api/agent/web-shell/v1/actions/get"), tenant, "owner")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(json.writeValueAsString(Map.of("sessionId", session, "actionId", id)))),
|
||||
read(auth(get("/v1/agents/sessions/{session}/actions", session), tenant, "owner")).path("data").get(0),
|
||||
read(auth(post("/api/agent/web-shell/v1/actions/query"), tenant, "owner")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(json.writeValueAsString(Map.of("sessionId", session)))).path("data").get(0));
|
||||
for (JsonNode view : views) {
|
||||
assertThat(OpenApiContract.load().validate(view.has("actionId")
|
||||
? "/components/schemas/WebShellAction" : "/components/schemas/PublicAction", view)).isEmpty();
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
private void assertNoInputPreview(String tenant, String session, String id) throws Exception {
|
||||
for (JsonNode view : inputViews(tenant, session, id)) {
|
||||
assertThat(view.has("input_preview") || view.has("inputPreview")).isFalse();
|
||||
assertThat(view.path("state").asText()).isEqualTo("requested");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void hostedSessionsAnswerThroughTheirRecordedCreator() throws Exception {
|
||||
String tenant = tenant();
|
||||
|
||||
@@ -746,7 +746,7 @@ export interface components {
|
||||
decisionReceiptId?: string;
|
||||
questions: components["schemas"]["WebShellActionQuestion"][];
|
||||
} & unknown;
|
||||
/** @description At most one requested Hosted approval per Turn. allow and deny are stable option IDs. Tool arguments are read from Items by function_call_id (functionCallId in WebShell). Public timestamps are milliseconds, as recorded by the Harness. Decision receipts are opaque product IDs. */
|
||||
/** @description At most one requested Hosted approval per Turn. allow and deny are stable option IDs. A requested native-tool Action may carry a bounded input preview when the producer supplies an input reference. Version 1 Actions and unavailable inputs omit it. Public timestamps are milliseconds, as recorded by the Harness. Decision receipts are opaque product IDs. */
|
||||
WebShellPermissionAction: {
|
||||
actionId: string;
|
||||
/** Format: uuid */
|
||||
@@ -768,6 +768,7 @@ export interface components {
|
||||
turnId?: string;
|
||||
functionCallId: string;
|
||||
toolName: string;
|
||||
inputPreview?: components["schemas"]["WebShellActionInputPreview"];
|
||||
} & unknown;
|
||||
WebShellPermissionResponse: {
|
||||
/** @constant */
|
||||
@@ -1004,6 +1005,18 @@ export interface components {
|
||||
/** @default 20 */
|
||||
limit?: number;
|
||||
};
|
||||
/** @description Optional preview for requested native read_file, write_file, edit or run_shell_command approvals. Missing or invalid input, and internal MCP inputs, omit the preview. No general secret redaction is performed; visibility follows Session read permissions. Deploy the version 2 Java reader before a Harness producer starts writing version 2 options. */
|
||||
WebShellActionInputPreview: {
|
||||
/** @description Exact prefix of the stored payloadJson ({toolName,input}), at most 8192 UTF-8 bytes ending at a complete code point. Truncated text may not be valid JSON. */
|
||||
text: string;
|
||||
/** @description True when text is shorter than the complete UTF-8 input. */
|
||||
truncated: boolean;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description Byte length of the complete payloadJson in UTF-8, before truncation.
|
||||
*/
|
||||
byteLength: number;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description The authenticated actor belongs to another tenant than X-Qwen-Tenant-Id or has an invalid ID (actor_scope_mismatch, which the tenant filter answers on every /v1/agents/ and WebShell route), or the resource is readable but the actor lacks this operation or the original Action responder permission. Artifact content reads may also return artifact_content_forbidden when the actor cannot read the original bytes. */
|
||||
|
||||
Reference in new issue
Block a user