* fix(core): reject '..' in file-history keys on both separators PR #12691 R2-1/R1-13: the traversal guard split on path.sep, which passes '../outside' on Windows while the suite's own rejection case expects a throw. Split on both separators and add the backslash case so the guard is pinned on POSIX lanes too. Also replace the duplicated snapshot-cap literal with the shared MAX_SNAPSHOTS constant. * fix(cli): harden managed worker lifecycle and env sanitization PR #12691 follow-ups: - close() drains the registry even when a worker stop rejects (Promise.allSettled + always shutdown, first failure rethrown). - stop() frees the capacity slot in a finally so a failed cleanup no longer wedges same-key activation forever. - managedWorkerEnvironment matches the env allowlist case-insensitively so Windows workers still receive PATH/SystemRoot. - Mark LocalProcessRuntimeActivator as the provisional surface landed ahead of its daemon-side consumer. - Tests: end-to-end secret-leak observation through a real spawned worker, table-driven handshake rejection coverage (forged lease/ epoch/incarnation, non-http, non-loopback, credentialed/query URLs), startup timeout, and any-case allowlist coverage. * refactor(cli): drop production-dead v1 managed-runtime scaffolding PR #12691 R1-9: the v1 route prefix, three request parsers, the protocol error class and three request/response types had zero production read sites (the live wire contract is v2), and the only tests exercising them tested dead exports. * fix(cli): repair managed-runtime provider release and retry invariants PR #12691 follow-ups: - RemoteManagedRuntimeProvider.getToolV2Client rolls back the v2 marker and evicts the entry when cold-start preparation fails, so one failed prepare no longer converts the session into a permanently closed one (R1-1, Critical). - LocalManagedRuntimeProvider.finishRelease drops a transport-failed warmup from the release record so the retried release takes the resume-then-close path instead of rethrowing the cached rejection forever (R2-4, Critical). - prepareRetryWindowMs: 0 no longer passes validation; it now falls back to the default window instead of arming a 0 ms abort deadline (R2-6, Critical). - waitForLocalSession surfaces the cleanup failure of a post-abort invalid attachment through the debug log instead of dropping it on a settled promise (R1-10). - Drop the dead second retryWindowMs parameter of Remote prepare(); the constructor option is the single configuration path (R1-12). - Tests: provider-level cancel identity forwarding, mismatched-expected rejection and empty-state false (R1-11); failed v2 cold start re-prepare; zero retry window fallback; release retry past a failed warmup. Also removes the dead-parser test block from the previous commit's scaffolding deletion. * fix(cli): honor broker retry semantics and release confirmation PR #12691 follow-ups: - submitExecution rethrows when the broker declares the failure non-retryable instead of re-sending by status class alone (R1-2). - A rejected execution reservation is evicted from the entry cache so status/execute/cancel re-prepare under the stable idempotency key instead of rethrowing forever (R1-3). - release() checks the provider lifetime before its map lookups, matching both siblings' throw-after-dispose contract (R1-5). - A broker release answering released:false now rejects (matching the remote provider) instead of silently returning false; the entry stays retryable (R2-5). - Tests pin all four behaviors. * fix(core): keep managed tool outcomes cloneable and normalized PR #12691 follow-ups: - ManagedToolRuntime.run() no longer wedges on a non-cloneable hook payload: the final structuredClone falls back to dropping hook payloads with a recorded hookError while keeping the terminal outcome (R2-8, Critical). - runtimeLocalOutcome normalizes ProceedOnceAndSwitchToDefault to ProceedOnce like its five siblings, so the tool callback never sees a mode-changing decision (R1-16). - Tests: media-context binding whitelist and undefined-param projection (R1-24), switch-to-default normalization, non-cloneable hook recovery. * fix(acp-bridge): guard session-store URL and correct sendPrompt contract PR #12691 follow-ups: - parseBridgeManagedSessionStore rejects cleartext non-loopback baseUrl like every sibling URL validator, via a local loopback predicate (acp-bridge cannot import the CLI helper). - The sendPrompt JSDoc no longer advertises PromptIdConflictError and same-promptId dedup as live behavior; both are now marked as a reserved contract the follow-up admission change must implement. - New collocated test pins every rejection branch of the store parser, the trailing-slash normalization, and the loopback exception. * fix(runtime-broker): close observe() guards and pin broker wire contract PR #12691 follow-ups: - observe() now rejects handle schema versions other than 1, matching the sibling ensureResource() guard instead of attesting READY under a version this provider never interprets (R1-17). - observe() reporting NOT_FOUND for a provably dead worker now reaps its ownership entry instead of leaking it for the broker's lifetime; a repeat read observes UNKNOWN (R2-3, Critical). - The issued tombstone set stores the SHA-256 of the canonical identity encoding instead of the full bearer-token material already released (R1-18). - The broker HTTP javadoc now documents that progress streaming is unavailable: afterSeq is validation-only and every status answers an empty progress list with firstAvailableSeq equal to lastSeq (R1-20). - Tests: observe() READY / NOT_FOUND(+reap) / CONFLICT arms against the real provisioner with the fake attestation worker (R1-19); settled execution full-envelope assertion, invalid afterSeq and duplicate query-field 400s, and the 413 branch over HTTP (R1-21). * test(cli): use an HTTPS store fixture on the parsed session-store route The PR #12691 P2 guard rejects cleartext non-loopback session-store URLs, and hosted-harness-session.ts already parses the request field through it (wired by the later hosted harness work). The fixture's http://store.test baseUrl therefore 400s at session create on the real route; switch it to https://store.test, which is the same fake identity and never fetched. * test(runtime-broker): drive the 413 branch over a raw header-only socket Streaming a 9 MiB body races the server's Content-Length fast reject: on Windows the in-flight write aborts (WSAECONNABORTED) before the 413 can be read, and a half-open client lets the server drain forever. Send headers only, EOF the write side, and assert the status line — the branch is pinned deterministically on every OS. * fix(cli,core): close round-3 review regressions on the 12691 follow-ups - waitForLocalSession: an abort landing after the success handler detached onAbort left no settler; log the bridge failure with its real error and reject with the cleanup wrapper instead of returning un-settled (round-3 Critical R1-1, plus the dropped-cause log call). - broker executions: evict a transiently rejected start as well as the reservation (started half of the original finding), and tombstone the failed digest so a changed-args retry still fails the identity guard on an empty cache (R1-4/R1-5). - activator admission: count the registry's committed children too, so an unreclaimed child keeps holding its slot after the map entry is freed by the R2-9 finally (R1-6). - managed tool clone fallback: replace only the hook that actually ran and preserve its shouldStop directive (R1-3). - Tests: abort-during-cleanup settlement race with logger spy; start- failure re-drive and changed-args-conflict broker cases; admission accounting witness; hook narrowing/payload/command survivors; canonicalized plus confusable loopback hosts; positive delivery half of the env leak test; 9 more handshake clauses incl. dedicated non-string case; derived modality view assertion. * fix(runtime-broker): make the reaped verdict reproducible and map store failure to retryable - observe(): keep the reaped worker's pid under the tombstone digest so a repeat reconcile re-derives the same NOT_FOUND with the same evidenceId after a discarded observation, instead of one-shot-ting into UNKNOWN retries that can never reach LOST (round-3 Critical R1-2); stop()/close() keep the map bounded. - createExecutionReceipt: wrap the stage in mapFailure so an unmapped store outage surfaces as 503 retryable:true instead of the unclassified 500 catch-all (R1-7). - Tests: store-failure 503->retryable->200 round trip via an injected repository proxy; repeat NOT_FOUND with identical evidenceId plus stop()-then-UNKNOWN; value assertions for the settled wire envelope; conflict-must-not-touch-the-owner positive control. * fix(cli,runtime-broker): answer the second round-3 review batch - activator stop(): the map delete moves out of finally to land after terminate has proven the tree down, so an unproven terminate failure keeps the entry (retiring, capacity, workspaceActivity) instead of silently admitting a second worker onto the same cwd (R1-29); a cleanup failure is recorded on instance state and close() aggregates it after the registry drain, since a pre-settled failure is no longer in the map (R1-25). - broker eviction is gated on the protocol-level retryability flag ( only): a broker-declared refusal stays cached across execute/status/cancel instead of re-POSTing (R1-24). - media test asserts the bound instance's derived modality view directly (R1-17). - LocalProcessRuntimeProvisioner.tombstone is pinned package-visible: 64-hex, deterministic, carries no token/endpoint, and stays a function of every ownership-key component (R1-26). - HTTP tests: non-degenerate (lastSeq=7) settled envelope pins firstAvailableSeq == lastSeq + progressGap on the gap arm (R1-18), and afterSeq=0/5 are accepted with the full settled status (R1-20). Mutation arms run on this tree: delete-before-terminate reddens the admission test; the shared-definition refutation keeps the managed close() wire-order invariant. * fix(cli): close the round-4 review follow-ups on the 12691 follow-ups - Gate the failed-cold-start eviction on the in-flight release promise instead of the never-cleared `releasing` flag, so a failed release of a failed cold start no longer turns the release retry terminal (R2-1). - Simplify the post-abort cleanup branch to its observable effect (the log); the chained catch already settles the promise identically (R2-8). - Pin the start-arm refusal cache, the registry-committed admission term, and the close() registry drain with dedicated regression tests (R2-2, R2-4, R2-5); skip the permission-based cleanup test as root (R2-3). - runtime-broker: pin the reaped NOT_FOUND verdict against a foreign seed and the tombstone digest against every ownership-key component (R1-10, R2-9). - acp-bridge: document and pin the deliberate refusal of IPv4-mapped loopback without HTTPS (R1-11). - Fix the cleartext store fixture in the issue-13328 test brought in by the main merge, matching the daemon's HTTPS-outside-loopback guard. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): close the round-3/4 review follow-ups on the 12691 follow-ups - Broker executions: a transiently evicted start failure now tombstones the resolved executionCallId, and status()/cancel() resolve read-only — they surface the recorded failure or address the reserved call id instead of re-POSTing executions:prepare, so a draining cancel can terminalize the broker-side PREPARED receipt and unblock the release. - Eviction is restricted to failures a retry can outrun (retryable 5xx, broker-declared retryable 4xx, transport aborts/timeouts); declared refusals, unclassified 4xx, and client-side protocol failures stay cached instead of being re-driven forever. - A release the broker does not confirm now rejects with a classified, retryable ManagedRuntimeProviderError, and the test pins that the retry re-POSTs rather than short-circuiting on a phantom seal. - A failed cold start no longer drops an entry's recorded terminal intent (a failed terminal release re-sends as protocolVersion 2 and seals), while the v2 marker is dropped even when a release is in flight so a doomed cold start cannot convert the next release into a terminal one. - close() now tracks in-flight stops past the map delete so a hanging rm surfaces through close() instead of being lost, and keeps only the first cleanup failure instead of an unbounded list it only read [0] of. - The admission count drops its registry-term: the registry-counted-but- unmapped state it defended is unreachable (terminate() releases the child before the generation can leave the map), and the surviving unreclaimed-child test now genuinely pins retention. - Java: the dead-worker tombstone pin asserts the repeat read carries the live read's pid-keyed hostDomain, the cursor test asserts the full settled status content, and the afterSeq rejections pin their client-facing messages. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli,acp-bridge,runtime-broker): close the round-5/6 review follow-ups on the 12691 follow-ups * fix(runtime-broker): align the cancel route's store-outage mapping with its siblings cancelExecution returned a bare safeStage while createExecutionReceipt, prepareExecution, startExecution and getExecution map an execution-store outage to a retryable 503 runtime_broker_store_unavailable, so the same IllegalStateException escaped cancel as a 500 runtime_broker_internal_error with retryable:false. Wrap the cancel stage in the same mapFailure call; existing RuntimeBrokerException verdicts (404/409/503 reconciliation) pass through unchanged. Extend answersAStoreFailureAsRetryableServiceUnavailable so the injected repository gate now witnesses every store-reading route: prepare, read, start (both payload variants) and cancel each answer 503 retryable, the failed prepare leaves no reservation, the replayed record is asserted after the healed retry, the stored row is untouched by the failed legs, and the gate records exactly the intended interception per route and no other repository call. Co-authored-by: Qwen-Coder <[email protected]> * fix(cli,runtime-broker): answer the post-merge review round on the 12691 follow-ups Blockers: - RuntimeBrokerHttpServerTest: give the start-body List.of an explicit witness type; the enhanced-for header had no target type, so the mixed Integer/String Map.of arguments inferred an intersection that is not Map<String, Object> and every Java lane red at testCompile. - hosted-harness-session: switch the two store-identity fixtures to https:// so they reach the 409 identity check; the new cleartext non-loopback transport guard (surfaced by the main merge) answers 400 first. Both fail pre-fix, pass post-fix. Review batch (broker provider): - ensureExecution regains its leading assertEntry: a cached execution no longer resolves after dispose() while every sibling surface refuses. - A non-abandoned 409 execution_unknown is reconcilable, so it re-drives instead of freezing for the session; the abandoned (runtime_lost) variant stays cached. Both halves pinned. - BrokerWireError splits: an undecodable body stays transient, an over-limit body (declared or streamed) is a deterministic property of the response and stays cached instead of re-driving a doomed cycle. - The start-path eviction moves inside the settling continuation, so a retry begun in the rejection's own continuation re-prepares instead of replaying the stale rejection. - Witnesses added: request-timeout (DOMException) re-drive, and the live-execution-over-tombstone read precedence after a successful re-drive. Activator tests: - Pin that a failed rm still frees the capacity slot (the delete/rm reorder): a second workspace is admitted where the old order refused with managed_runtime_capacity_exhausted. - Reset the armed rm stub in place so the close()-window test survives vitest --retry re-invoking the same registered function. Each new guard and witness was mutation-probed red before this commit. * fix(cli): honor the over-limit classification on every Broker path (#13297) Review round on #13297 found the retryableByRedrive classification honored only on the success path: - An over-limit body on a 5xx was folded into an unclassified BrokerResponseError, graded transient, and re-prepared — re-streaming up to 8 MiB — on every later execute; requestJson now lets the BrokerWireError propagate out of the !response.ok branch, while an undecodable error body keeps its deliberate transient swallow. - submitExecution's in-band retry gate read the bit only for BrokerResponseError, re-transferring a doomed body once per execute; the gate now also throws on BrokerWireError with retryableByRedrive false. - The DOMException fixture claimed a request-timeout shape the real path cannot produce (a real timeout aborts the composite signal first); re-labeled as a transport fault on a live signal, with a new dispose-aborted witness pinning the gate's signal.aborted disjunct. Each new guard and witness was mutation-probed red before this commit. * fix(cli): keep cancel/status reachable on every Broker reservation state (#13297) Review round on #13297 found the read-only paths wedged behind the cached reservation and the retry taxonomy restated per consumer: - resolveReadOnlyExecution returned a cached execution unconditionally, so once a re-drive left a reservation whose prepare was refused (or hung), cancel and status replayed the refusal and never reached the receipt the Broker still counts as active — release then stayed refused for the life of the process. A digest-checked tombstone fallback now supplies the call id when the live reservation rejects, and a hop-balanced race keeps an already-settled reservation ahead while never awaiting a pending one. - The retry classification lived at three sites with opposite failure directions (the submitExecution skip-list gate, the isTransientBrokerFailure allow-list, the error-body catch whitelist); one brokerRetryPolicy(error) now derives both axes — none | in-band | redrive | both — beside the error classes, and every consumer asks it. - The declared content-length guard only covered the ok arm, so an over-limit error body was buffered up to 8 MiB and re-driven instead of classified like its sibling; the guard is hoisted above the status split, and the error-body read moved out of the classification try so a typed error can no longer be erased into an unclassified refusal. - Witnesses: the refused and hung re-drive cancel/status cases, a settled re-driven reservation winning over the stale tombstone, the 503 declared-length matrix cell, and the undecodable 502 drain-page re-drive. Each new guard and witness was mutation-probed red before this commit. * fix(cli,runtime-broker): close the round-7 review Criticals on the 12691 follow-ups - hosted-harness-session.test.ts: convert the one cleartext store fixture the https sweep missed to https://another-store.test; the off-loopback guard rejects it during parsing, so the load answered 400 before the store-identity comparison could answer 409 (R4-1). - LocalProcessRuntimeProvisioner: reap a dead worker under stop()'s monitor and only when the owned.remove lands, so a release racing a reconcile can no longer resurrect a tombstone stop() already cleared and permanently certify a deliberately released lease as JOURNAL_LOST (R5-1). - RuntimeBrokerService/JdbcRepositorySupport: classify store outages at the JDBC source with a StoreFailureException subtype and map only that subtype to a retryable 503 on the execution routes, so client-input errors, permanent invariant violations, and JVM errors keep the non-retryable 400/500 mapping they had before (R7-1). * test(core): stub getFileSystemService in the managed-tool-runtime media test (#13297) * fix(runtime-broker): map the :acknowledge and :publisher store outage as a retryable 503 (#13297) * fix(runtime-broker): map the private CSI prepare and read-batch store outage as a retryable 503 (#13297) prepareCsiExecution and readCsiBatch returned their stages unwrapped, so a JDBC outage on POST /executions:prepare (the inputBytesBase64 branch) or POST /executions:read-batch reached sendError as a raw StoreFailureException and answered 500 runtime_broker_internal_error with retryable=false, while a plain prepare over the same outage answered 503 runtime_broker_store_unavailable with retryable=true. Wrap both stages in mapStoreFailure, keeping the gate on StoreFailureException so a permanent invariant violation still answers the non-retryable 500. The new HTTP test drives both routes against a JdbcRuntimeBindingRepository whose DataSource refuses every connection with a real SQLException, then with a non-SQL IllegalStateException. Removing either wrapper, or widening it to mapFailure, turns it red. * fix(runtime-broker): map the private-CSI prepare and read-batch store outage as a retryable 503 (#13297) Co-authored-by: Qwen-Coder <[email protected]> * fix(cli,core,acp-bridge,runtime-broker): land the queued round-1 review suggestions on the 12691 follow-ups (#13297) AutoFix accepted these on 2026-10-10 but never landed them. - acp-bridge: type-check allowInsecureHttp before the HTTPS transport gate, so a mistyped opt-in is reported as invalid rather than as a missing one (R1-20). - core: move MAX_SNAPSHOTS into a dependency-free leaf, so the managed-tool file-history protocol no longer pulls in fileHistoryService, diff and the git utilities (R1-12). Keep a hook's string fields in the non-cloneable fallback (R1-13). - cli: log the after-abort cleanup failure only when the abort actually settled the caller's promise, and stop calling that debug-only log the only surviving trace (R1-9, R1-10). Witness the cold-start entry eviction without an intervening release (R1-11). Cover mixed-case proxy keys and a relative trusted-folders path in the worker env allowlist (R1-18, R1-19). - runtime-broker tests: fail the prepare and cancel write legs and the :publisher route under a store outage, and unwrap the gate's InvocationTargetException (R1-24). Count the reconcile attestation round trip (R1-26). Build the tombstone lease directly instead of spawning a worker (R1-27). - docs: describe all three session-store transport gates, their loopback sets and the IPv4-mapped decision, in both languages (R1-22). * chore(runtime-broker): re-arm deterministic verification with a clean tree (#13297) The previous round's commit was rejected only because mvn clean verify left the gitignored JaCoCo HTML report (target/site/jacoco/jacoco-resources/*.js) in the working tree, and the root eslint flat config lints those generated files (139 errors: no-useless-escape, no-undef, no-redeclare). The generated output has been removed; the source fix mapping the private-CSI prepare and read-batch store outage to a retryable 503 is unchanged from the rejected commit. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: qwen-code-autofix <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]>
23 KiB
Managed Agent Broker:已认证主体与 Broker 下发的凭证
状态:已在本地实现,2026-10-02;验证结果记录在
.qwen/issues/issue-13180.md。源码基线:de2612434a。
跟踪 #13180。
1. 问题陈述
Managed Agent Runtime Broker(packages/managed-agent-server)
目前信任客户端自证的身份和客户端自铸的凭证。当 broker 在没有网关的
情况下可达时,五个缺口组合成对任意 hosted 会话持久 transcript 的
跨租户读写:
- 租户/操作者身份来自自证头。
TenantContextFilter原样接受X-Qwen-Tenant-Id,仅当 principal 已存在时才交叉校验 actor。 OpenAPI 契约中 trusted-actor 方案标为x-qwen-implementation-status: planned——它所假设的网关并未随 仓库交付,而已交付的 dev/E2E 拓扑(Web Shell 的?tenant=、 vite dev 代理、trusted-actor-header配置)恰好都是无网关形态。 - writer 凭证是自铸的。 harness 自己生成 writer token
(
packages/core/src/managed-runtime/http-managed-session-store.ts中的randomBytes(32)),broker 在首次 acquire 时登记其哈希 (ManagedSessionStore.acquireWriter,首写者赢 TOFU)。在任何 租约空窗(最长 300 秒)内,能触达端口且知道(tenantId, workspaceId, sessionId)的人都能夺取 writer 身份 并重写会话的持久历史。 - 内部面与公网面共用端口。
/internal/**(Managed Session store 与 tool publications)与/v1/agents/**由同一连接器 提供服务;没有独立监听器,也没有对监听地址的部署守卫。 - 审批属主不含 hosted 会话。
ManagedActionService.respond→ManagedActionStore.requireOwner只匹配managed_workspace_create_command行,因此 hosted(非 workspace) 会话没有持久属主、HTTP 应答永远走不通;而 workspace 会话的校验 只是把可猜测的 actor-id 值与自证头身份做比较。 - TypeScript 客户端静默允许明文传输。
createHttpManagedSessionStores对任意主机都接受http://base URL,因此非回环部署下 writer token 明文过网且无任何警告。
2. 目标与非目标
目标:
- G1. 公网面上的租户/操作者身份来自 broker 自身可验证的已认证主体 (不依赖外部网关)。
- G2. writer 凭证由 broker 在会话/运行时创建时下发,并绑定
(tenantId, workspaceId, sessionId);配置绑定密钥后自铸 token 不再可用。 - G3.
/internal/**可绑定到独立的仅回环监听器,且 broker 在启动时 拒绝不安全的监听地址组合。 - G4. 每个会话都有持久的创建者记录;审批应答校验该记录,hosted 会话可通过 HTTP 应答。
- G5. TypeScript 客户端拒绝非回环主机的明文
http://broker 地址, 除非显式 opt-in。
非目标:
- runtime-broker 自身的单 token HTTP server(由另一 issue 单独跟踪)。
- 真正的 OIDC/SSO 网关、mTLS、密钥轮换工具,以及超出现有 actor 校验的多租户 ACL 建模。
- 改变已交付的回环 dev/E2E 拓扑:它们无需新配置即可继续工作。
3. 信任模型
两个 HTTP 面有不同的调用方和不同的保护方式:
| 面 | 路由 | 调用方 | 本次变更后的保护 |
|---|---|---|---|
| 公网面 | /v1/agents/**、/api/agent/web-shell/v1/** |
浏览器、运维工具、代理 | 签名主体(G1)或仅回环的 open 模式 |
| 内部面 | /internal/managed-session-store/v1/**、/internal/managed-tool-publications/v1/** |
Hosted harness(Qwen CLI) | writer 绑定凭证(G2);监听器默认仅回环(G3) |
内部面不需要请求签名:writer 绑定凭证已绑定
(tenantId, workspaceId, sessionId),在内部调用上伪造租户头只会
推导出一个不同的期望凭证而失败。因此签名密钥永远不会分发给
harness。
4. 设计
4.1 认证模式与签名主体(G1)
新增配置组 qwen.managed-agent.auth:
| 键 | 环境变量 | 默认值 | 含义 |
|---|---|---|---|
mode |
QWEN_MANAGED_AGENT_AUTH_MODE |
auto |
auto、open 或 signed |
signing-key |
QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY |
空 | signed 模式的 HMAC 密钥,>= 32 字节 |
allowed-drift |
QWEN_MANAGED_AGENT_AUTH_ALLOWED_DRIFT |
5m |
签名时间戳容差 |
allow-insecure-bind |
QWEN_MANAGED_AGENT_AUTH_ALLOW_INSECURE_BIND |
false |
文档明确标注为危险的逃生门 |
启动时的模式解析:
-
open:现状行为——按自证接受X-Qwen-Tenant-Id,可选的trusted-actor-header替身提供 actor。 -
signed:新的SignatureAuthFilter(排在TrustedActorHeaderFilter之前)要求公网面每个请求携带三个头:X-Qwen-Actor-Id(actor 身份,必需)、X-Qwen-Signature-Timestamp(epoch 秒,须在allowed-drift内),以及X-Qwen-Signature, 计算方式为:X-Qwen-Signature: v1=<小写 hex HMAC-SHA256>( "qwen-broker-auth-v1\n" + METHOD + "\n" + requestURI + "\n" + queryString + "\n" + tenantId + "\n" + actorId + "\n" + timestamp + "\n" + 原始请求体的 SHA-256(小写 hex)+ "\n" + idempotencyKey)requestURI是未解码的路径,不含查询串;queryString是原始查询串 (缺省为空);idempotencyKey是Idempotency-Key头的值(缺省为 空)。对请求体和幂等键签名意味着捕获的签名无法转移到其他方法、 路径、查询串、租户、actor 或请求体;配方之外的头(如Last-Event-ID、Accept)在漂移窗口内仍可变,因此捕获的读签名 可在这些头选择的游标值上重放。验证成功后过滤器安装
AuthenticatedTenantActorprincipal (租户与 actor 来自现已认证的头);失败则以标准错误信封应答401 authentication_required或401 invalid_signature。随后TenantContextFilter按现状交叉校验 principal,因此无需改动 任何 controller。两个 fail-closed 细节:覆盖判定使用路由后的路径 (
UrlPathHelper.getPathWithinApplication,经PublicSurface与租户过滤器共享),因此裸集合路由POST /v1/agents与归一化 拼写(百分号编码、路径参数)都无法绕过,而规范串仍对原始请求 URI 签名;重复的Idempotency-Key头以400 invalid_request拒绝,因为 Servlet 契约会签第一个值而 controller 绑定的是逗号 拼接值。缓冲的请求体按请求受qwen.managed-agent.auth.max-signed-body-bytes(默认 10 MiB) 限制——超限请求在签名比对之前应答413 payload_too_large。 认证前的聚合缓冲上限为该值乘以 Servlet 工作线程数 (server.tomcat.threads.max,默认 200),再乘每请求的缓冲增长 系数(声明长度的请求体约 2 倍,chunked 约 3 倍);内存紧张的 环境可调低两者之一。最后,过滤器转发的请求把 JSON 媒体类型 钉在 UTF-8:Content-Type的 charset 参数不在签名内,非 Unicode 字符集本可引导 JSON 解码器持久化出与已签字节不同的 内容(JSON 按 RFC 8259 定义即 UTF-8),因此无论声明何种 charset,链路观察到的都是application/json;charset=UTF-8。 -
auto:当server.address为回环地址(交付默认值127.0.0.1)时解析为open,否则启动失败并点名需要signed模式。这使所有已交付的 dev/E2E 拓扑继续可用,同时生产监听地址 不可能在无认证的情况下启动。
启动守卫(全部快速失败并点名属性):
mode=auto或mode=open+ 非回环server.address+ 未设allow-insecure-bind。mode=signed+signing-key缺失或过短。- 已解析模式为
signed时配置了trusted-actor-header(配置矛盾: 头替身不得能在签名过滤器之后注入 principal)。 - 配置了非空的
server.servlet.context-path或非根的spring.mvc.servlet.path(路径前缀过滤器假设根挂载,否则会被 静默绕过)。 allowed-drift低于 1 秒(会被截断为零窗口)。- 设置了
internal-server.port时,回环的session-store.base-url指向其他端口(harness 的 store 调用会全部 404)。 harness.enabled且harness.base-url为非回环明文 http(attach 载荷携带下发的 writer 凭证)。tool-publication.enabled且service-base-url为非回环明文 http(runtime 携带 writer 凭证访问发布面)。session-store.enabled且session-store.base-url的主机落在纯 字面量回环集(§4.5)之外、又未设session-store.allow-insecure-http的明文 http——所有 harness 都会在 attach 时拒绝该 URL,因此 broker 在启动时直接拒绝。
allow-insecure-bind 会同时停用其中四项守卫(公网绑定、内部面
binding key、harness 传输、发布面传输);启动 posture 行以
skipped=... 列出被跳过的守卫,使其爆炸半径在日志中可见。
OpenAPI 契约新增 qwenSignature apiKey 方案(X-Qwen-Signature),
其描述固定规范串与 401 错误码;trustedActor 方案对外部网关
形态保持 planned 状态,并注明 signed 模式认证的是同一对头。
4.2 Broker 下发的 writer 绑定凭证(G2)
新增配置 qwen.managed-agent.session-store.binding-key(环境变量
QWEN_MANAGED_AGENT_SESSION_STORE_BINDING_KEY),默认空;设置时至少
32 字节(更弱的密钥可被一条观察到的 token 离线爆破)。
配置后,会话的 writer token 是推导出来的,而不是自选的:
writerToken = "qwt1_" + base64url-nopad(HMAC-SHA256(bindingKey,
"qwen-managed-writer/v1\0" + tenantId + "\0" + workspaceId
+ "\0" + sessionId))
共 48 个字符,落在两侧现有的 ^[A-Za-z0-9_-]{32,512}$ 契约内。
token 刻意不含 writer id:以新 boot id 重启的 harness 可在旧租约
到期后用同一凭证重新 acquire,恢复路径继续可用。
- 新的
WriterCredentialPolicybean 负责issue(tenant, workspace, session)与require(tenant, workspace, session, token)。当绑定密钥已配置且 出示的 token 不匹配时,require抛出403 writer_credential_invalid(常数时间比较)。密钥为空时保持 现有 TOFU 行为不变。 ManagedSessionStore中所有接收 writer token 的入口 (acquireWriter、renewWriter、sealWriter、blockRecovery、commit、restore、transactions、readResource、publishToolResult,以及ToolPublicationStore的 publication-writer 路径)都经由该策略校验 token。每个调用点 都已携带 workspace id,因此 API 形状不变。- 下发经由现有 attach 载荷:
QwenHostedHarnessConnector.managedSessionStore(...)把签发的 token 放入ManagedSessionStoreConnection(新增可选writerToken字段,序列化为managedSessionStore.writerToken),TS 桥解析器parseBridgeManagedSessionStore接受该可选字段,hosted-harness-session.ts将其传给createHttpManagedSessionStores({ writerToken })(该方法本来就 接受显式 token)。未收到下发 token 的客户端继续自铸;只要 绑定密钥已配置,broker 就会拒绝自铸 token,两侧不会静默失配。
已存储的 lease_token_hash 列职责不变(renew/seal 一致性);绑定
只决定哪个 token 可以被出示。
4.3 独立内部监听器与监听地址守卫(G3)
新增配置 qwen.managed-agent.internal-server:
| 键 | 环境变量 | 默认值 | 含义 |
|---|---|---|---|
port |
QWEN_MANAGED_AGENT_INTERNAL_SERVER_PORT |
0(禁用) |
独立监听端口 |
address |
QWEN_MANAGED_AGENT_INTERNAL_SERVER_ADDRESS |
127.0.0.1 |
监听绑定地址 |
- 当
port > 0时,一个WebServerFactoryCustomizer添加第二个 Tomcat 连接器,InternalSurfaceConfiguration.RoutingFilter(最高优先级)按request.getLocalPort()路由:公网连接器上的/internal/**应答404,内部连接器上非/internal/**的 请求应答404。面分类基于路由后的路径 (PublicSurface.pathWithinApplication,即路由器自身解析出的 段),因此/%69nternal/...、/internal;/...等被 Spring 映射到 内部 handler 的拼写无法跨越监听器边界——Content-Type中的字符集 也无法扭曲分类,因为这些段来自路由器固定的 UTF-8 解码。 - 当
port = 0时,保留现有单端口形态。 - 启动守卫:任何非回环监听地址(公网或内部)都要求相应保护——
公网面要求
signed模式,内部面要求已配置binding-key—— 除非设置了allow-insecure-bind。因此未配置绑定密钥时,内部面 在构造上就是仅回环的。 - 另有两条快速失败守卫:
internal-server.port必须与server.port不同(路由过滤器按本地端口分类,同号不同址会让公网地址服务/internal/**);签名密钥必须与 binding-key 不同(持有签名密钥者 不得能铸造 journal 写入凭证)。
4.4 审批的持久会话属主(G4)
-
Flyway
V40__managed_session_creator.sql:ALTER TABLE managed_agent_session ADD COLUMN creator_actor_key VARBINARY(2048) NULL;(与managed_workspace_create_command.actor_id同类型)。 -
ManagedAgentStore.insertSession已接收actorId,且 hosted 与 workspace 会话共用同一个插入路径;现在它在 actor 存在时一并写入creator_actor_key。hosted 的createSession服务方法及其 controller 新增来自TenantContext的可空actorId,使 hosted 会话也记录创建者。 -
ManagedActionStore.requireOwner按序解析属主:- 会话行的
creator_actor_key——存在时必须与调用方的 actor key 相等; - 遗留的
managed_workspace_create_command行(迁移前会话的 回退,其 creator 列为 NULL); - 两者都没有(open 模式下的匿名创建,或迁移前的 hosted 会话)—— 放行租户内任意调用方,与非 workspace 会话现有的租户级读语义 一致。
这使 hosted 审批应答能通过 HTTP 走通,并把 workspace 审批绑定到 已认证 actor(G1)而非自证头身份,堵住“可猜测 actor”缺口。
- 会话行的
4.5 Session store 传输守卫(G5)
协议为 http: 且主机名非回环的 session store base URL 一律被拒绝,
除非显式 opt-in 明文传输。三道关卡执行这一规则:
- 路由边界。
parseBridgeManagedSessionStore(acp-bridge)解析 hostedPOST /session与POST /session/:id/load路由的managedSessionStore描述符。它先校验allowInsecureHttp的类型, 非布尔值以managedSessionStore.allowInsecureHttp is invalid拒绝; 再拒绝非回环的明文 base URL,除非allowInsecureHttp为true。 路由在打开会话之前应答这两种拒绝:状态码400、错误码invalid_managed_session_store,并附带错误信息。 - Store 客户端。由
createHttpManagedSessionStores构造的ManagedSessionStoreHttpClient对其baseUrl与allowInsecureHttp选项执行同一规则,因此守卫对导出工厂的任何直接调用方都成立, 而不只是 hosted 路由;在 hosted 路由上,它的拒绝同样以400和该 错误码应答。 - Broker 启动。启用的 session store 若对外通告回环集之外的明文 base
URL、且未设
session-store.allow-insecure-http,BrokerSecurity拒绝启动(§4.1),因为所有 harness 都会在 attach 时拒绝该 URL。
两道 TypeScript 关卡都在 WHATWG URL 解析之后对主机名分类;解析会把
IPv4 简写与十六进制形式(127.1、0x7f.1)折叠为 127.0.0.1:
回环为 localhost、*.localhost、127.0.0.0/8 内的点分四段 IPv4
字面量,或 [::1]。仅以 127. 开头的 DNS 名(127.example.com、
127.foo.example.test)不是回环。Broker 按原样匹配主机文本,不做
规范化,也不查询解析器:localhost、*.localhost、[::1],或
127 后跟一到三段以点分隔的十进制数字。每道关卡各自维护分类函数
(core isLoopbackHostname、acp-bridge
isManagedSessionStoreLoopback、broker isLiteralLoopbackHost),
因此修改回环集必须同时更新这三处。
决策:IPv4 映射回环([::ffff:127.0.0.1],WHATWG URL 将其规范化为
[::ffff:7f00:1])不在三道关卡任何一道的回环集内;此类 URL 必须使用
HTTPS 或 opt-in。
BridgeManagedSessionStore 新增对应可选布尔字段
allowInsecureHttp,由 broker 的
qwen.managed-agent.session-store.allow-insecure-http 配置
(环境变量 QWEN_MANAGED_AGENT_SESSION_STORE_ALLOW_INSECURE_HTTP)
产生,经 ManagedSessionStoreConnection 序列化进 attach 载荷,
使有意在受信网络内提供明文服务的 broker 可以声明;字段缺省视为
false。报错信息点名该选项,使补救方式可被发现。客户端
writerToken 自铸路径保留,供未配置绑定密钥的回环部署使用。
4.6 加固开发替身
TrustedActorHeaderFilter仅在已解析认证模式为open时激活 (signed模式下未签名请求根本到不了它,且启动守卫禁止该配置)。 其 javadoc 中的警告变成强制规则。- 匿名幂等域折叠:
signed模式下 actor 总是已认证的,因为SignatureAuthFilter要求公网每个请求都携带X-Qwen-Actor-Id头 (否则应答401 authentication_required),因此SessionLifecycleService.actorDigest与ManagedActionService.respond现有的匿名折叠只会发生在open模式——即它所服务的本地部署。
5. 兼容性与发布
- 已交付的回环拓扑零配置兼容:
auto解析为open;绑定密钥为空 保持 TOFU;内部端口禁用;匿名创建仍不记录属主。 - 启用
binding-key对每个部署是一次性切换:harness 必须经 broker attach(由 broker 下发 token);直连 store 的客户端须被授予推导 token。服务器启动时记录解析出的模式与守卫结果。 - 在公网面启用
signed模式需要在浏览器流量前放置持有签名密钥的 代理或 sidecar;签名配方即 §4.1 固定的三头形式。 - 现有数据库经 V40 迁移;迁移前的会话通过遗留属主回退继续工作。
6. 风险与缓解
- 时间戳窗口内的重放。 HMAC 覆盖方法、路径、查询串、租户、
actor、时间戳、请求体摘要与幂等键,因此捕获的签名无法转移到
其他方法、路径、查询串、租户、actor 或请求体。残余风险是窗口
(5 分钟)内的重放,包括携带未签名头(
Last-Event-ID、Accept、Range)重放同一路由的不同分页:一个被捕获的读签名 在过期前可遍历该路由的游标空间。内部写操作另有幂等键。对无网关 部署而言可接受;nonce 缓存与对游标头签名列为后续工作。 - 绑定凭证对每个会话是静态的。 单个 token 泄露只影响单个会话;
轮换方式 = 轮换
binding-key(一次性切换)。创建时存储每会话 随机密钥是被否决的替代方案,原因是额外的表和恢复复杂度。 - 守卫对罕见但合法的绑定(如 Pod 网络)误伤。
allow-insecure-bind是文档化的逃生门;启动报错会点名它。 - 第二连接器漂移(之后新增的内部路由必须落在
/internal/前缀下)。路由过滤器只匹配/internal/前缀,并有契约测试 覆盖双向断言。
7. 验证计划
- 按项编写 Java 单元/集成测试:签名过滤器的接受/拒绝与排序;模式
解析与每个启动守卫;绑定凭证的签发/校验,包括重启后重新
acquire 与跨作用域拒绝;内部监听器的双向路由;属主回退链
(已迁移、遗留、匿名)与 hosted
respond成功;signed模式下 拒绝匿名折叠。 - TypeScript 测试:回环地址分类表、未 opt-in 时拒绝、opt-in 后
接受、桥载荷携带
writerToken的往返。 - OpenAPI 契约以散文形式(方案 + 共享
Unauthorized组件描述)记录 新的qwenSignature方案与 401 错误码;web-shell 生成客户端按契约 重新生成,其一致性测试(managed-agent-api.test.ts)对漂移失败。 - E2E:现有 dev 拓扑(
qwenCLI + 回环 Web Shell)零新配置可用; 针对非回环地址做一次 signed 模式冒烟。
8. 验收标准
mode=signed时,无有效签名的公网请求得到401;有有效签名时, actor principal 驱动 ACL 与审批校验。- 配置
binding-key后,自铸 writer token 无法 acquire 任何会话的 writer——包括租约空窗期——而 broker 下发的 token 可以。 - 设置
internal-server.port后,公网端口上/internal/**不可达; 公网地址非回环且为 open 模式时,broker 拒绝启动。 - hosted 会话的审批可由其创建者经 HTTP 应答,且其他 actor 不能; 无属主记录的会话(open 模式匿名创建,或 V40 之前创建)允许租户内 任意调用方应答,与其读 ACL 一致;迁移前的 workspace 会话保持现有 行为。
parseBridgeManagedSessionStore({ baseUrl: 'http://<非回环>' })与createHttpManagedSessionStores({ baseUrl: 'http://<非回环>' })抛出异常,除非allowInsecureHttp: true;hostedPOST /session路由以状态码400、错误码invalid_managed_session_store应答解析器 的拒绝;解析器以 invalid 拒绝非布尔的allowInsecureHttp;IPv4 映射回环按非回环处理。
9. 待决问题
- 签名请求是否需要 nonce 缓存(超出 5 分钟窗口的防护)?
binding-key轮换采用上一密钥校验窗口,还是一次性切换?- 当 harness 能持有密钥后,
signed模式是否也应覆盖内部面,还是 绑定凭证长期足够?