feat(managed): add experimental Kubernetes deployment (#15492)

* feat(managed): add experimental Kubernetes Helm deployment

* feat(managed): add trusted-node tools and dedicated WebShell gateway

* test(web-shell): isolate Managed gateway in boot tests

* docs(managed): clarify Kubernetes gateway requirements

* fix(managed): align Helm workspace identity with runtime broker

* fix(web-shell): keep managed permission rules key distinct

* fix(managed): align Helm lint and web test boundaries

* codex: address PR review feedback (#15492)
This commit is contained in:
jinye authored and GitHub committed 2026-10-30 18:26:43 +00:00
1 parent 0c1b42fa31
commit 9ebd2de171
38 files changed
+3826 -5

No files matched your search

+3
View File
@@ -23,6 +23,9 @@ junit.xml
# so formatting and `pnpm install` fight over the file. .yamllint.yml # so formatting and `pnpm install` fight over the file. .yamllint.yml
# already ignores it for the same reason. # already ignores it for the same reason.
pnpm-lock.yaml pnpm-lock.yaml
# Helm templates are validated through Helm rendering.
deploy/kubernetes/chart/templates/
Thumbs.db Thumbs.db
packages/vscode-ide-companion/schemas/settings.schema.json packages/vscode-ide-companion/schemas/settings.schema.json
packages/cli/src/services/insight/templates/insightTemplate.ts packages/cli/src/services/insight/templates/insightTemplate.ts
+1
View File
@@ -84,6 +84,7 @@ rules:
ignore: ignore:
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
- 'deploy/kubernetes/chart/templates/'
- 'thirdparty/' - 'thirdparty/'
- 'third_party/' - 'third_party/'
- 'vendor/' - 'vendor/'
+321
View File
@@ -0,0 +1,321 @@
# Managed Agent on Kubernetes
[English](README.md) | [简体中文](README.zh-CN.md)
This experimental Helm chart installs a signed Managed Agent API, a private
Hosted Harness in the same Pod, and MySQL 8.4 with a persistent volume. One Helm
command installs the stack after you provide images and model configuration.
The default supports text Sessions. Optional settings add trusted single-node
Workspace files and foreground Shell, and a Managed WebShell gateway. K2 CSI
provisioning remains a separate deployment.
## Prerequisites
- Kubernetes 1.28 or newer, Helm 3.18 or newer, kubectl, and a namespace in which
you may create workloads, Services, ConfigMaps, Secrets and PVCs.
- A default StorageClass supporting ReadWriteOnce, or an explicit
`mysql.storage.className`. The default database volume requests 10 GiB.
- A container builder and registry reachable from the cluster. Both application
images must come from the same reviewed source revision and match the nodes'
CPU architecture. Publicly published Managed Agent images are not assumed.
- An OpenAI-compatible model endpoint, model ID and API key.
The default application Pod requests 500m CPU and 1 GiB memory in total; MySQL requests
another 250m CPU and 512 MiB. Resource requests and limits are configurable in
[values.yaml](chart/values.yaml). The application requires no Kubernetes API
permissions and does not mount a service-account token.
## Build the images
Run from the repository root. Replace the registry and select the platform of
your cluster; the example builds for Linux amd64:
```bash
QWEN_K8S_REV=$(git rev-parse HEAD)
QWEN_K8S_REGISTRY=registry.example.com/team
docker buildx build --platform linux/amd64 --push \
-f packages/managed-agent-server/Dockerfile \
-t "$QWEN_K8S_REGISTRY/managed-agent:$QWEN_K8S_REV" .
docker buildx build --platform linux/amd64 --push \
-f Dockerfile \
-t "$QWEN_K8S_REGISTRY/qwen-cli:$QWEN_K8S_REV" .
```
Prepare a deployment values file outside the checkout. Use the actual image
references above, preferably immutable registry digests:
```yaml
server:
image: registry.example.com/team/managed-agent@sha256:<server-digest>
harness:
image: registry.example.com/team/qwen-cli@sha256:<cli-digest>
model:
id: <provider-model-id>
baseUrl: https://provider.example/v1
```
For a private registry, add `imagePullSecrets: [{name: registry-credentials}]`
and create that registry Secret in the deployment namespace before installation.
Use the same source revision for both images on every upgrade.
For LOCAL Workspace tools, build a third image from those exact two images. It
combines the Java JAR/JRE with the CLI image's Node.js, bundled worker and Linux
filesystem tools. Use matching CPU architectures for all three:
```bash
docker buildx build --platform linux/amd64 --push \
-f deploy/kubernetes/runtime.Dockerfile \
--build-arg SERVER_IMAGE="$QWEN_K8S_REGISTRY/managed-agent:$QWEN_K8S_REV" \
--build-arg HARNESS_IMAGE="$QWEN_K8S_REGISTRY/qwen-cli:$QWEN_K8S_REV" \
-t "$QWEN_K8S_REGISTRY/managed-agent-runtime:$QWEN_K8S_REV" .
```
The runtime image checks `java`, `node`, the bundled CLI entry and native `stat`
during its build. `server.image` selects this combined image when tools are
enabled. The default text-only mode continues to use the smaller Java image.
WebShell uses the matching CLI image, including its built static assets and
`qwen serve --managed-web-config` command.
## Install
Store the model API key in a private file, without shell quotes, and restrict its
permissions to 0600. Then run from the repository root:
```bash
helm upgrade --install managed-agent deploy/kubernetes/chart \
--namespace qwen-managed-agent --create-namespace \
--values /private/managed-agent-values.yaml \
--set-file model.apiKey=/private/model-api-key \
--wait --timeout 15m
```
The chart creates independent random signing, Session Store, Harness and database
credentials on the first install. Their Secret is retained on uninstall and
reused on subsequent installs and upgrades. Enabling an optional component adds
only its missing keys; disabling it retains every existing key. An upgrade with a missing managed
Secret fails, as does a reinstall with a retained database PVC but no Secret;
restore the original Secret instead of generating new database
passwords or journal keys. On an existing release, `model.apiKey` is ignored:
rotate the Secret's `model-api-key` explicitly and restart the application Pod.
Helm stores managed Secret data in its release metadata. To manage credentials
separately, set `credentials.existingSecret` and omit `model.apiKey`. The Secret
must already exist in the release namespace and contain all of these keys:
| Key | Value |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `auth-signing-key` | At least 32 UTF-8 bytes |
| `session-store-binding-key` | Independent key, at least 32 UTF-8 bytes |
| `harness-token` | Private bearer token shared by Java and Harness |
| `capability-digest` | `sha256:` followed by 64 lowercase hexadecimal digits |
| `mysql-password` | Application database password |
| `mysql-root-password` | MySQL initialization root password |
| `model-api-key` | Provider API key |
| `runtime-broker-token` | Required with `runtime.enabled`; private Broker bearer token |
| `runtime-credential-key` | Required with `runtime.enabled`; base64 encoding of 32 random bytes |
| `web-token` | Required with `web.enabled`; independent browser bearer token of at least 32 bytes, different from `auth-signing-key` |
The Runtime credential key ID is fixed to `kubernetes-v1`; preserve that key for
every retained binding. When connected to the cluster, Helm validates an existing
external Secret's required keys. Offline `helm template` cannot inspect that
Secret; Kubernetes still requires every referenced key before starting a
container. Never replace a missing credential on a retained deployment with a
new random value.
If a retained Runtime PVC exists but its Runtime keys are missing, enabling the
managed Runtime also refuses generation and requires the original Secret.
Changing database Secret values alone does not change MySQL accounts in an
existing volume. Coordinate database password rotation separately. MySQL's
initialization grants the application user its database privileges and SELECT on
`performance_schema.user_variables_by_thread`, required by Flyway's boot probe.
Only the signed public API is exposed on the ClusterIP Service's port 8080.
Harness port 4170 and the Session Store listener on port 8081 bind to loopback.
There is no unauthenticated public bind, LoadBalancer or Ingress. Remote clients
use a separately configured TLS ingress or trusted gateway while retaining API
signatures; tenant and actor headers alone do not authenticate a caller.
## Enable trusted single-node Workspace tools
This option runs the current LOCAL process Runtime on one explicitly trusted
node. The node must provide its real, nonempty `/etc/machine-id`, Linux process
and namespace identity, and persistent storage with reliable directory birth
times. The chart mounts that machine ID read-only into the server. It does not
manufacture host identity, request privileged containers, or delegate cgroups.
Clusters whose policy rejects this hostPath must keep this option disabled.
Add these values, using the combined image above and the exact Kubernetes Node
name from your approved execution host:
```yaml
server:
image: registry.example.com/team/managed-agent-runtime@sha256:<runtime-digest>
runtime:
enabled: true
nodeName: <trusted-node-name>
storage:
workspaceSize: 10Gi
stateSize: 1Gi
className: <qualified-storage-class>
```
Node affinity pins scheduling to that exact node while allowing the scheduler to
bind WaitForFirstConsumer volumes. The chart retains two PVCs: `/workspaces` for
created Workspaces, and `/runtime-data` for private state and Runtime home. The
legacy cwd remains `/workspace`. The launcher creates owner-private state and
home child directories, restores their 0700 directory and 0600 file permissions,
and refuses symlinks, unsupported entries and different owners. Kubelet uses
`fsGroupChangePolicy: OnRootMismatch` to avoid widening retained records on every
mount. SQL, both PVCs, the credential Secret, node identity and original file
identities form one deployment; copying PVC contents to another host is not
takeover authority.
After installation, send a signed
`POST /api/agent/web-shell/v1/workspaces/create` with
`{"displayName":"Project","idempotencyKey":"<unique-key>"}`. The existing API
creates a directory, verifies and registers its physical storage, and grants
OWNER to the authenticated tenant/actor. Use the returned `workspaceId` in a
Session creation request's `workspace.workspace_id`; do not manually populate
registration or ownership rows. The WebShell option below offers this same flow.
This mode enables files, foreground Shell, durable Session isolation and verified
Workspace recovery with `default` asking approval. Under that policy, Shell commands require an
approval response; child agents and tool publication remain off. Background
Shell, Monitor, command Hooks and arbitrary escaped processes are outside this
mode's acceptance. The tools execute with the server account's filesystem
permissions and can reach paths outside the Workspace; use trusted workloads.
No object-storage account is needed for the foreground Shell receipt path.
Close every tool Session and confirm physical close, sealed journals and clear
storage/Runtime holders before any planned Pod replacement or Helm upgrade.
Keep those checks as an explicit operational gate; the chart does not drain
Sessions automatically. A new Pod has different PID/time namespaces and cannot
adopt the old LOCAL worker merely because its node and PVC are unchanged. After
a clean rollout, create a new Session on the retained Workspace. An unexpected
worker or Pod loss must retain its uncertain effects and admission fences;
neither retries nor removing SQL rows authorizes replay or fabricated cleanup.
## Enable Managed WebShell
The optional gateway serves only the Managed UI and signs its Java requests as
one fixed trusted tenant/actor. It does not create a multi-user authentication
system. Enable it independently, or together with LOCAL tools:
```yaml
web:
enabled: true
tenantId: <trusted-tenant-id>
actorId: <trusted-actor-id>
```
An empty `web.image` reuses `harness.image`. The gateway has its own home and
temporary directory, receives only the browser token and a read-only signing-key
file, and reads no model API key or Runtime/Workspace volume. Its configuration
contains the loopback Java URL, fixed tenant/actor and signing-key file path;
browser-supplied identity headers do not select the caller. Ordinary daemon
Sessions, filesystem, Shell, settings, MCP, ACP and WebSocket routes are closed.
Forward the browser port:
```bash
kubectl -n qwen-managed-agent port-forward service/managed-agent 4171:4171
```
Open [the local WebShell](http://127.0.0.1:4171/) and authenticate with the
Secret's independent `web-token`. Keep the signing key server-side. When LOCAL
tools are enabled, create/select a Workspace and complete approvals through the
Managed UI. For an external TLS ingress, configure the exact browser origin in
`web.allowOrigins`, for example `[https://agent.example.com]`; the chart itself
does not create an ingress.
## Verify a Session
Forward the API in one terminal:
```bash
kubectl -n qwen-managed-agent port-forward service/managed-agent 8080:8080
```
In another terminal, export the signing key to a private temporary file and run
the signed smoke client. With an operator-managed Secret, replace the Secret
name below:
```bash
umask 077
QWEN_K8S_KEY_FILE=$(mktemp)
kubectl -n qwen-managed-agent get secret managed-agent-keys \
-o jsonpath='{.data.auth-signing-key}' | openssl base64 -d -A > "$QWEN_K8S_KEY_FILE"
node deploy/kubernetes/smoke.mjs --key-file "$QWEN_K8S_KEY_FILE"
```
The smoke requires Node.js 22 or newer. It checks rejection of unsigned requests,
creates a Session, submits a unique text marker, waits for a completed model Turn,
verifies assistant history and closes the Session. Success prints Session and
Turn IDs as JSON. It makes a real provider call using the configured account.
On failure it reports the Session ID and leaves that Session for inspection; it
does not replay an uncertain input. Logs can be read with
`kubectl -n qwen-managed-agent logs deployment/managed-agent -c server` or
`-c harness`. The default timeout is 120 seconds; `--timeout-ms` overrides it.
To check idle persistence, first run the smoke with `--keep-session`. Record its
Session ID, restart the application, then resume the smoke using that ID:
```bash
kubectl -n qwen-managed-agent rollout restart deployment/managed-agent
kubectl -n qwen-managed-agent rollout status deployment/managed-agent --timeout=10m
node deploy/kubernetes/smoke.mjs --key-file "$QWEN_K8S_KEY_FILE" --session-id <session-id>
```
After a restart, a new Turn may wait for the previous 60-second writer lease and
automatic retries before it can run. Increase `--timeout-ms` if the model also
needs a longer response window. This verifies
retained history and a new Turn after an idle restart. It does not verify
arbitrary interrupted-Turn recovery, multi-replica HA or CSI ownership recovery.
After the final smoke, remove the temporary key with `rm "$QWEN_K8S_KEY_FILE"`.
## Upgrade and uninstall
Stop new admissions, settle active work, and back up MySQL plus the complete
credential Secret before changing versions. With LOCAL tools, first close every
tool Session and confirm all physical holders are clear as described above.
Retain the namespace, release name,
MySQL and optional Runtime/Workspace PVCs and credentials. Repeat the installation command with matching
new images and run the Session smoke before restoring traffic. The application
uses one replica and Recreate, so upgrades have downtime. Do not scale it to
multiple replicas.
Do not use `--atomic` or automatically roll back a schema-changing upgrade.
Flyway migrations run before readiness; an older binary is not guaranteed to
accept the new schema. Fix forward, or restore the matching database and
application backup before accepting writes. Current pre-release development
builds may change the canonical V1 baseline in place; those incompatible catalogs
require a fresh database, not an in-place Helm upgrade. Preserve historical data
with its matching application version.
```bash
helm uninstall managed-agent --namespace qwen-managed-agent
```
Uninstall retains the managed Secret, StatefulSet PVC and optional Runtime/Workspace PVCs. Delete none while
history is needed; permanent data removal is a separate explicit operation.
Changing the namespace or release name creates a different deployment.
## Validation boundary
Run `helm lint` with deployment inputs and
`node --test deploy/kubernetes/*.test.mjs` from the repository root.
`HELM_BIN=/absolute/path/to/helm` selects a Helm executable outside PATH.
The tests render the chart and exercise credential reuse with a controlled
Kubernetes API peer, plus the signed smoke protocol with a controlled HTTP peer.
These are not real-cluster acceptance.
Record image builds, installation, real MySQL, provider calls and restart results
for each target cluster. ACK, node failure, CSI, physical retirement and production
acceptance remain separate gates. The optional trusted LOCAL mode is separate
from [K2 resident deployment](../k2-resident/README.md), which requires qualified
CSI identities and does not support Shell. The chart does not enable K2. Design:
[English](../../docs/design/2026-10-24-managed-agent-kubernetes.md) |
[简体中文](../../docs/design/2026-10-24-managed-agent-kubernetes.zh-CN.md);
tools and WebShell:
[English](../../docs/design/2026-10-25-managed-agent-kubernetes-tools.md) |
[简体中文](../../docs/design/2026-10-25-managed-agent-kubernetes-tools.zh-CN.md).
+280
View File
@@ -0,0 +1,280 @@
# 在 Kubernetes 部署 Managed Agent
[English](README.md) | [简体中文](README.zh-CN.md)
这个实验性 Helm chart 安装签名认证的 Managed Agent API、同 Pod 的私有 Hosted
Harness,以及使用持久卷的 MySQL 8.4。提供镜像和模型配置后,一条 Helm 命令即可安装
整套服务。默认支持文本 Session;可选配置增加可信单节点 Workspace 文件、前台
Shell 和 Managed WebShell 网关。K2 CSI provisioning 仍是独立部署。
## 前置条件
- Kubernetes 1.28 或更新版本、Helm 3.18 或更新版本、kubectl,以及可创建 workload、
Service、ConfigMap、Secret 和 PVC 的 namespace。
- 支持 ReadWriteOnce 的默认 StorageClass,或显式指定
`mysql.storage.className`。数据库卷默认申请 10 GiB。
- 容器构建工具和集群可访问的镜像仓库。两个应用镜像必须来自同一经过评审的源码
revision,并匹配节点 CPU 架构。不假设已有公开发布的 Managed Agent 镜像。
- OpenAI 兼容模型 endpoint、模型 ID 和 API key。
默认应用 Pod 总计申请 500m CPU 和 1 GiB 内存;MySQL 额外申请 250m CPU 和 512 MiB
内存。资源 requests 和 limits 可在 [values.yaml](chart/values.yaml) 配置。
应用无需 Kubernetes API 权限,不挂载 service-account token。
## 构建镜像
在仓库根目录执行。替换镜像仓库并选择集群平台;示例构建 Linux amd64 镜像:
```bash
QWEN_K8S_REV=$(git rev-parse HEAD)
QWEN_K8S_REGISTRY=registry.example.com/team
docker buildx build --platform linux/amd64 --push \
-f packages/managed-agent-server/Dockerfile \
-t "$QWEN_K8S_REGISTRY/managed-agent:$QWEN_K8S_REV" .
docker buildx build --platform linux/amd64 --push \
-f Dockerfile \
-t "$QWEN_K8S_REGISTRY/qwen-cli:$QWEN_K8S_REV" .
```
在 checkout 外准备部署 values 文件。使用上面的真实镜像地址,优先采用不可变镜像
摘要:
```yaml
server:
image: registry.example.com/team/managed-agent@sha256:<server-digest>
harness:
image: registry.example.com/team/qwen-cli@sha256:<cli-digest>
model:
id: <provider-model-id>
baseUrl: https://provider.example/v1
```
私有仓库增加 `imagePullSecrets: [{name: registry-credentials}]`,并在安装前在部署
namespace 创建对应仓库 Secret。每次升级的两个镜像都应来自同一源码 revision。
启用 LOCAL Workspace 工具时,从上述精确的两个镜像构建第三个镜像。它将 Java
JAR/JRE 与 CLI 镜像中的 Node.js、bundled worker 和 Linux 文件系统工具组合。
三个镜像的 CPU 架构必须相同:
```bash
docker buildx build --platform linux/amd64 --push \
-f deploy/kubernetes/runtime.Dockerfile \
--build-arg SERVER_IMAGE="$QWEN_K8S_REGISTRY/managed-agent:$QWEN_K8S_REV" \
--build-arg HARNESS_IMAGE="$QWEN_K8S_REGISTRY/qwen-cli:$QWEN_K8S_REV" \
-t "$QWEN_K8S_REGISTRY/managed-agent-runtime:$QWEN_K8S_REV" .
```
Runtime 镜像在构建时检查 `java`、`node`、bundled CLI 入口和原生 `stat`。
启用工具时,将 `server.image` 指向此组合镜像。默认文本模式继续使用较小的 Java
镜像。WebShell 使用匹配的 CLI 镜像,其中需包含构建后的静态资源和
`qwen serve --managed-web-config` 命令。
## 安装
将模型 API key 写入私有文件,不包含 shell 引号,权限限制为 0600。然后在仓库根目录
执行:
```bash
helm upgrade --install managed-agent deploy/kubernetes/chart \
--namespace qwen-managed-agent --create-namespace \
--values /private/managed-agent-values.yaml \
--set-file model.apiKey=/private/model-api-key \
--wait --timeout 15m
```
首次安装时,chart 生成相互独立的签名、Session Store、Harness 和数据库随机凭据。
卸载时保留对应 Secret,后续安装和升级复用它。开启可选组件时仅补齐其缺失的键,
关闭组件仍保留已有全部键。升级时缺少受管 Secret 会失败;请恢复
原 Secret;保留数据库 PVC 却缺少 Secret 时,重新安装也会拒绝。不要重新生成数据库
密码或 journal 密钥。已有部署忽略 `model.apiKey`:
更新模型凭据需显式轮换 Secret 的 `model-api-key`,然后重启应用 Pod。
Helm 会将受管 Secret 内容保存到 release 元数据。若要独立管理凭据,设置
`credentials.existingSecret` 并省略 `model.apiKey`。该 Secret 必须已存在于 release
namespace,包含以下全部键:
| 键 | 值 |
| --------------------------- | ---------------------------------------------------------------------------------------- |
| `auth-signing-key` | 至少 32 个 UTF-8 字节 |
| `session-store-binding-key` | 独立密钥,至少 32 个 UTF-8 字节 |
| `harness-token` | Java 与 Harness 共享的私有 bearer token |
| `capability-digest` | `sha256:` 后跟 64 位小写十六进制字符 |
| `mysql-password` | 应用数据库密码 |
| `mysql-root-password` | MySQL 初始化 root 密码 |
| `model-api-key` | Provider API key |
| `runtime-broker-token` | `runtime.enabled` 时必需;私有 Broker bearer token |
| `runtime-credential-key` | `runtime.enabled` 时必需;32 个随机字节的 base64 编码 |
| `web-token` | `web.enabled` 时必需;至少 32 字节的独立浏览器 bearer token,不能等于 `auth-signing-key` |
Runtime credential key ID 固定为 `kubernetes-v1`;存在保留的 binding 时必须保留此
密钥。连接集群时,Helm 会检查已有自管 Secret 的必需键;离线 `helm template` 无法
读取它,Kubernetes 仍会在容器启动前要求所有引用的键存在。已有部署缺少凭据时,
不能用新随机值替换。
保留 Runtime PVC 却缺少 Runtime 密钥时,开启受管 Runtime 也会拒绝生成新值,要求
恢复原 Secret。
仅更改数据库 Secret 不会更改已有卷中的 MySQL 账号,数据库密码轮换需单独协调。
MySQL 初始化时授予应用用户数据库权限,以及 Flyway 启动探针所需的
`performance_schema.user_variables_by_thread` SELECT 权限。
ClusterIP Service 仅在 8080 暴露签名认证的公开 API。Harness 4170 和 Session Store
8081 都绑定 loopback。不启用无认证公开监听、LoadBalancer 或 Ingress。远程客户端
使用独立配置的 TLS ingress 或可信网关,并保留 API 签名;tenant 和 actor header
本身不能认证调用者。
## 启用可信单节点 Workspace 工具
此选项在一个显式信任的节点上运行现有 LOCAL 进程 Runtime。节点必须提供真实且非空
的 `/etc/machine-id`、Linux 进程和 namespace 身份,以及支持可靠目录 birth time
的持久存储。chart 将节点 machine ID 只读挂入服务端,不伪造主机身份,不申请
privileged 容器,也不委派 cgroup。集群策略拒绝此 hostPath 时,请保持关闭。
增加以下 values,使用上述组合镜像,并填写已批准执行主机的精确 Kubernetes Node
名称:
```yaml
server:
image: registry.example.com/team/managed-agent-runtime@sha256:<runtime-digest>
runtime:
enabled: true
nodeName: <trusted-node-name>
storage:
workspaceSize: 10Gi
stateSize: 1Gi
className: <qualified-storage-class>
```
Node affinity 将调度固定到该节点,同时保留 scheduler 绑定 WaitForFirstConsumer
卷的流程。chart 保留两个 PVC:`/workspaces` 保存创建的 Workspace,
`/runtime-data` 保存私有 state 和 Runtime home;旧式 cwd 仍为 `/workspace`。
启动器创建 owner 私有的 state/home 子目录,将目录和文件分别恢复为 0700/0600,
拒绝符号链接、非普通条目和不同 owner。Kubelet 使用
`fsGroupChangePolicy: OnRootMismatch`,避免每次挂载都扩宽已有记录权限。SQL、
两个 PVC、凭据 Secret、节点身份和原文件身份共同构成一个部署;复制 PVC 内容到
其他主机不能授权接管。
安装后,以签名请求调用 `POST /api/agent/web-shell/v1/workspaces/create`,body 为
`{"displayName":"Project","idempotencyKey":"<unique-key>"}`。现有 API 创建目录、
验证并注册物理存储,再授予已认证 tenant/actor OWNER 权限。将返回的 `workspaceId`
用于 Session 创建请求的 `workspace.workspace_id`,不要手工写入注册或权限数据行。
下方 WebShell 选项提供同一流程。
该模式启用文件、前台 Shell、持久 Session 隔离和 verified Workspace recovery,
使用 `default` 询问式审批。在该策略下,Shell 命令需要审批响应;child agents 和 tool publication
保持关闭。后台 Shell、Monitor、command Hooks 和任意逃逸进程不在该模式的验收范围。
工具使用服务端账号的文件系统权限,可访问 Workspace 之外的路径,请用于可信工作负载。
前台 Shell receipt 路径不需要对象存储账号。
计划替换 Pod 或 Helm 升级前,必须关闭所有工具 Session,确认物理 close、journal
sealed,以及 storage/Runtime holder 已清空。请保留这一显式运维门槛;chart 不自动
drain Session。新 Pod 的 PID/time namespace 已改变,不能因节点和 PVC 不变就接管旧
LOCAL worker。干净升级后,应对保留的 Workspace 创建新 Session。意外 worker/Pod
丢失必须保留原来的不确定 effect 和准入 fence;重试或删除 SQL 行都不能授权重放或
伪造清理结果。
## 启用 Managed WebShell
可选网关只提供 Managed UI,并以一个固定可信 tenant/actor 对 Java 请求签名,不构成
多用户认证系统。可以独立开启,也可以与 LOCAL 工具一起开启:
```yaml
web:
enabled: true
tenantId: <trusted-tenant-id>
actorId: <trusted-actor-id>
```
`web.image` 留空时复用 `harness.image`。网关使用独立 home 和临时目录,仅接收浏览器
token 和只读签名密钥文件,不读取模型 API key 或 Runtime/Workspace 卷。其配置只包含
loopback Java URL、固定 tenant/actor 和签名密钥文件路径;浏览器身份 header 不决定
调用者。普通 daemon Session、文件系统、Shell、设置、MCP、ACP 和 WebSocket 路由
均关闭。
转发浏览器端口:
```bash
kubectl -n qwen-managed-agent port-forward service/managed-agent 4171:4171
```
打开[本地 WebShell](http://127.0.0.1:4171/),使用 Secret 中独立的 `web-token` 完成
认证。签名密钥留在服务端。启用 LOCAL 工具后,可通过 Managed UI 创建/选择
Workspace 和完成审批。使用外部 TLS ingress 时,在 `web.allowOrigins` 配置精确的
浏览器 origin,例如 `[https://agent.example.com]`;chart 本身不创建 ingress。
## 验证 Session
在一个终端转发 API:
```bash
kubectl -n qwen-managed-agent port-forward service/managed-agent 8080:8080
```
在另一个终端将签名密钥导出到私有临时文件,然后运行签名 smoke 客户端。
使用自管 Secret 时替换下方 Secret 名:
```bash
umask 077
QWEN_K8S_KEY_FILE=$(mktemp)
kubectl -n qwen-managed-agent get secret managed-agent-keys \
-o jsonpath='{.data.auth-signing-key}' | openssl base64 -d -A > "$QWEN_K8S_KEY_FILE"
node deploy/kubernetes/smoke.mjs --key-file "$QWEN_K8S_KEY_FILE"
```
smoke 需要 Node.js 22 或更新版本。它验证无签名请求被拒绝、创建 Session、提交唯一
文本标记、等待模型 Turn 完成、核对 assistant 历史并关闭 Session。成功时以 JSON
输出 Session 和 Turn ID。它会使用已配置账号真实调用 provider。失败时报告 Session
ID,保留 Session 供检查,不重放结果不确定的输入。可使用
`kubectl -n qwen-managed-agent logs deployment/managed-agent -c server` 或
`-c harness` 查看日志。默认超时 120 秒,可用 `--timeout-ms` 调整。
验证空闲持久化时,第一次为 smoke 增加 `--keep-session`。记录输出的 Session ID,
重启应用,再使用该 ID 继续 smoke:
```bash
kubectl -n qwen-managed-agent rollout restart deployment/managed-agent
kubectl -n qwen-managed-agent rollout status deployment/managed-agent --timeout=10m
node deploy/kubernetes/smoke.mjs --key-file "$QWEN_K8S_KEY_FILE" --session-id <session-id>
```
重启后的新 Turn 可能需要等待旧的 60 秒 writer lease 和自动重试后才能执行;模型
响应也较慢时,请增加 `--timeout-ms`。此流程验证空闲重启后保留历史并完成新 Turn,
不代表任意在途 Turn 恢复、多副本 HA 或 CSI ownership 恢复已验收。
最后一次 smoke 结束后,使用 `rm "$QWEN_K8S_KEY_FILE"` 删除临时密钥。
## 升级与卸载
升级前停止新准入、结算活动工作,并备份 MySQL 和完整凭据 Secret。启用 LOCAL 工具
时,先按上述流程关闭全部工具 Session,确认所有物理 holder 已清空。保持 namespace、
release 名、MySQL 和可选 Runtime/Workspace PVC,以及凭据不变。使用匹配的新镜像
重复安装命令,恢复流量前执行
Session smoke。应用固定单副本并使用 Recreate,升级存在停机时间,请勿扩为多副本。
不要使用 `--atomic`,也不要自动回滚改变 schema 的升级。Flyway 在 readiness 前执行
migration,旧 binary 不保证接受新 schema。请向前修复,或在接受写入前恢复匹配的
数据库和应用备份。当前预发布开发版本可能原地更新 canonical V1 baseline;不兼容的
catalog 需要新数据库,不能直接 Helm 升级。需要历史数据时,保留与其匹配的应用版本。
```bash
helm uninstall managed-agent --namespace qwen-managed-agent
```
卸载保留受管 Secret、StatefulSet PVC 和可选 Runtime/Workspace PVC。需要历史时
不能删除;永久清理数据是独立的
显式操作。修改 namespace 或 release 名会创建另一个部署。
## 验证边界
在仓库根目录使用部署输入执行 `helm lint`,并执行
`node --test deploy/kubernetes/*.test.mjs`。可通过
`HELM_BIN=/absolute/path/to/helm` 指定 PATH 外的 Helm。测试渲染 chart,并通过受控
Kubernetes API peer 验证凭据复用,通过受控 HTTP peer 验证签名 smoke 协议。
这些测试不等于真实集群验收。
每个目标集群应独立记录镜像构建、安装、真实 MySQL、provider 调用和重启结果。
ACK、节点故障、CSI、物理退役与生产验收仍是独立门槛。可选可信 LOCAL 模式与
[K2 常驻部署](../k2-resident/README.zh-CN.md) 独立;后者需要经过资格验证的 CSI
身份,且不支持 Shell。chart 不开启 K2。
设计:[English](../../docs/design/2026-10-24-managed-agent-kubernetes.md) |
[简体中文](../../docs/design/2026-10-24-managed-agent-kubernetes.zh-CN.md);工具与 WebShell:
[English](../../docs/design/2026-10-25-managed-agent-kubernetes-tools.md) |
[简体中文](../../docs/design/2026-10-25-managed-agent-kubernetes-tools.zh-CN.md)。
+624
View File
@@ -0,0 +1,624 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { fileURLToPath, URL } from 'node:url';
import { promisify } from 'node:util';
import { parseAllDocuments } from 'yaml';
const execute = promisify(execFile);
const helm = process.env.HELM_BIN || 'helm';
const chart = fileURLToPath(new URL('./chart', import.meta.url));
const inputs = [
'--namespace',
'test-space',
'--set',
'server.image=server:test',
'--set',
'harness.image=harness:test',
'--set',
'model.id=test-model',
'--set',
'model.baseUrl=https://model.example/v1',
'--set',
'model.apiKey=fixture-key',
];
const render = async (args = [], baseInputs = inputs) => {
const { stdout } = await execute(helm, [
'template',
'fixture',
chart,
...baseInputs,
...args,
]);
return parseAllDocuments(stdout).map((document) => {
assert.deepEqual(document.errors, []);
return document.toJSON();
});
};
const find = (objects, kind) => objects.find((object) => object.kind === kind);
const env = (container) =>
Object.fromEntries(container.env.map((item) => [item.name, item]));
test('renders isolated signed API and private Harness/Store with safe probes', async () => {
const objects = await render();
const deployment = find(objects, 'Deployment');
assert.equal(deployment.spec.replicas, 1);
assert.equal(deployment.spec.strategy.type, 'Recreate');
const pod = deployment.spec.template.spec;
assert.equal(pod.automountServiceAccountToken, false);
const [server, harness] = pod.containers;
const serverEnv = env(server);
const harnessEnv = env(harness);
assert.equal(serverEnv.QWEN_MANAGED_AGENT_AUTH_MODE.value, 'signed');
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_ID.value,
createHash('sha256')
.update(serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_CWD.value)
.digest('hex')
.slice(0, 16),
);
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_INTERNAL_SERVER_ADDRESS.value,
'127.0.0.1',
);
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL.value,
'http://127.0.0.1:8081',
);
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED.value,
'false',
);
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED.value,
'false',
);
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED.value,
'false',
);
assert.deepEqual(
serverEnv.QWEN_MANAGED_AGENT_HARNESS_TOKEN.valueFrom,
harnessEnv.QWEN_SERVER_TOKEN.valueFrom,
);
assert.deepEqual(
serverEnv.QWEN_MANAGED_AGENT_CAPABILITY_DIGEST.valueFrom,
harnessEnv.QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST.valueFrom,
);
assert.equal(server.livenessProbe.httpGet.path, '/actuator/health/liveness');
assert.equal(
server.readinessProbe.httpGet.path,
'/actuator/health/readiness',
);
assert.equal(
serverEnv.MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE.value,
'readinessState,db',
);
assert.match(
harness.readinessProbe.exec.command[2],
/127\.0\.0\.1:4170\/health/,
);
assert.match(
harness.readinessProbe.exec.command[2],
/Bearer.*QWEN_SERVER_TOKEN/,
);
assert.deepEqual(harness.startupProbe.exec, harness.livenessProbe.exec);
for (const container of pod.containers) {
assert.equal(container.securityContext.runAsNonRoot, true);
assert.equal(container.securityContext.readOnlyRootFilesystem, true);
assert.equal(container.securityContext.allowPrivilegeEscalation, false);
assert.deepEqual(container.securityContext.capabilities.drop, ['ALL']);
}
const api = objects.find(
(object) => object.kind === 'Service' && object.metadata.name === 'fixture',
);
assert.deepEqual(
api.spec.ports.map((port) => port.port),
[8080],
);
assert.deepEqual(api.spec.selector, deployment.spec.selector.matchLabels);
});
test('secrets are distinct, retained and absent from model ConfigMap and argv', async () => {
const objects = await render();
const secret = find(objects, 'Secret');
const decode = (name) => Buffer.from(secret.data[name], 'base64').toString();
assert.equal(secret.metadata.annotations['helm.sh/resource-policy'], 'keep');
assert(decode('auth-signing-key').length >= 32);
assert.notEqual(
decode('auth-signing-key'),
decode('session-store-binding-key'),
);
assert.match(decode('capability-digest'), /^sha256:[0-9a-f]{64}$/);
assert.equal(decode('model-api-key'), 'fixture-key');
const config = find(objects, 'ConfigMap');
assert(!JSON.stringify(config).includes('fixture-key'));
const settings = JSON.parse(config.data['settings.json']);
assert.equal(settings.security.auth.selectedType, 'openai');
assert.equal(settings.model.name, settings.modelProviders.openai[0].id);
assert.equal(settings.modelProviders.openai[0].envKey, 'OPENAI_API_KEY');
const containers = find(objects, 'Deployment').spec.template.spec.containers;
for (const container of containers) {
assert(!JSON.stringify(container.args ?? []).includes('fixture-key'));
}
});
test('bundled MySQL persists data and grants the actual Flyway boot probe', async () => {
const objects = await render([
'--set',
'mysql.storage.size=20Gi',
'--set',
'mysql.storage.className=fast',
'--set',
'imagePullSecrets[0].name=private-registry',
]);
const mysql = find(objects, 'StatefulSet');
assert.equal(
mysql.spec.volumeClaimTemplates[0].spec.storageClassName,
'fast',
);
assert.equal(
mysql.spec.volumeClaimTemplates[0].spec.resources.requests.storage,
'20Gi',
);
assert.match(
find(objects, 'ConfigMap').data['mysql-init.sql'],
/GRANT SELECT ON performance_schema\.user_variables_by_thread TO 'qwen'@'%'/,
);
const pod = mysql.spec.template.spec;
assert.equal(pod.automountServiceAccountToken, false);
assert.match(pod.containers[0].readinessProbe.exec.command[2], /--user=qwen/);
assert.match(
pod.containers[0].readinessProbe.exec.command[2],
/performance_schema\.user_variables_by_thread/,
);
assert.equal(
pod.containers[0].volumeMounts.find((mount) => mount.name === 'data')
.subPath,
'mysql',
);
for (const object of objects.filter((item) =>
['Deployment', 'StatefulSet'].includes(item.kind),
)) {
assert.deepEqual(object.spec.template.spec.imagePullSecrets, [
{ name: 'private-registry' },
]);
}
});
test('LOCAL tools require a fixed node and persist private state outside Workspaces', async () => {
await assert.rejects(
render(['--set', 'runtime.enabled=true']),
/Set runtime.nodeName/,
);
const objects = await render([
'--set',
'runtime.enabled=true',
'--set',
'runtime.nodeName=trusted-node',
'--set',
'runtime.storage.className=local',
]);
const pod = find(objects, 'Deployment').spec.template.spec;
assert.equal(pod.nodeName, undefined);
assert.deepEqual(
pod.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution
.nodeSelectorTerms[0].matchFields,
[{ key: 'metadata.name', operator: 'In', values: ['trusted-node'] }],
);
assert.equal(pod.securityContext.fsGroupChangePolicy, 'OnRootMismatch');
const [server, harness] = pod.containers;
const serverEnv = env(server);
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_ID.value,
createHash('sha256')
.update(serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_CWD.value)
.digest('hex')
.slice(0, 16),
);
for (const key of [
'QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED',
'QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED',
'QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED',
'QWEN_MANAGED_AGENT_RUNTIME_VERIFIED_WORKSPACE_RECOVERY_ENABLED',
'QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS',
]) {
assert.equal(serverEnv[key].value, 'true');
}
for (const key of [
'QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY',
'QWEN_MANAGED_AGENT_WORKSPACE_CHILD_AGENTS_ENABLED',
'QWEN_MANAGED_AGENT_TOOL_PUBLICATION_ENABLED',
]) {
assert.equal(serverEnv[key].value, 'false');
}
assert.equal(serverEnv.QWEN_MANAGED_AGENT_APPROVAL_MODE.value, 'default');
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY.value,
'/runtime-data/state',
);
assert.equal(serverEnv.QWEN_HOME.value, '/runtime-data/home');
assert.equal(
serverEnv.QWEN_MANAGED_AGENT_WORKSPACE_CREATION_ROOT.value,
'/workspaces',
);
assert.deepEqual(
serverEnv.QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN.valueFrom,
env(harness).QWEN_MANAGED_RUNTIME_BROKER_TOKEN.valueFrom,
);
assert.deepEqual(
pod.volumes.find((volume) => volume.name === 'machine-id').hostPath,
{ path: '/etc/machine-id', type: 'File' },
);
assert.equal(
server.volumeMounts.find((mount) => mount.name === 'machine-id').readOnly,
true,
);
assert(!harness.volumeMounts.some((mount) => mount.name === 'runtime-data'));
const claims = objects.filter(
(object) => object.kind === 'PersistentVolumeClaim',
);
assert.equal(claims.length, 2);
for (const claim of claims) {
assert.equal(claim.metadata.annotations['helm.sh/resource-policy'], 'keep');
assert.equal(claim.spec.storageClassName, 'local');
}
const key = find(objects, 'Secret').data['runtime-credential-key'];
assert.equal(
Buffer.from(Buffer.from(key, 'base64').toString(), 'base64').length,
32,
);
});
test('WebShell keeps its fixed identity and signing key server-side', async () => {
await assert.rejects(
render(['--set', 'web.enabled=true']),
/Set web.tenantId/,
);
const objects = await render([
'--set',
'web.enabled=true',
'--set',
'web.tenantId=tenant-a',
'--set',
'web.actorId=actor-a',
'--set',
'web.allowOrigins[0]=https://agent.example',
]);
const pod = find(objects, 'Deployment').spec.template.spec;
const web = pod.containers.find((container) => container.name === 'web');
assert.equal(web.image, 'harness:test');
assert.deepEqual(web.args, [
'serve',
'--managed-web-config',
'/etc/qwen-web/config.json',
'--hostname',
'0.0.0.0',
'--port',
'4171',
'--allow-origin',
'https://agent.example',
]);
const webEnv = env(web);
assert.equal(
webEnv.QWEN_SERVER_TOKEN.valueFrom.secretKeyRef.key,
'web-token',
);
assert(!webEnv.OPENAI_API_KEY);
assert(!webEnv.QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY);
assert(
!web.volumeMounts.some((mount) =>
['workspace', 'workspaces', 'runtime-data'].includes(mount.name),
),
);
const signingVolume = pod.volumes.find(
(volume) => volume.name === 'web-signing-key',
);
assert.deepEqual(signingVolume.secret.items, [
{ key: 'auth-signing-key', path: 'auth-signing-key' },
]);
const config = find(objects, 'ConfigMap');
assert.deepEqual(JSON.parse(config.data['managed-web.json']), {
apiUrl: 'http://127.0.0.1:8080',
tenantId: 'tenant-a',
actorId: 'actor-a',
signingKeyFile: '/run/qwen-web/auth-signing-key',
});
assert(!JSON.stringify(config).includes('fixture-key'));
const secret = find(objects, 'Secret');
assert.notEqual(secret.data['web-token'], secret.data['auth-signing-key']);
assert.match(web.readinessProbe.exec.command[2], /4171\/health/);
const service = objects.find(
(object) => object.kind === 'Service' && object.metadata.name === 'fixture',
);
assert.deepEqual(
service.spec.ports.map((port) => port.port),
[8080, 4171],
);
});
test('numeric browser identities and model keys render as strings', async (t) => {
const temporary = await mkdtemp(join(tmpdir(), 'qwen-helm-values-'));
t.after(() => rm(temporary, { recursive: true, force: true }));
const valuesFile = join(temporary, 'values.yaml');
for (const value of [0, 123]) {
await writeFile(
valuesFile,
`web:\n enabled: true\n tenantId: ${value}\n actorId: ${value}\nmodel:\n apiKey: ${value}\n`,
);
for (const args of [
['--values', valuesFile],
[
'--set',
'web.enabled=true',
'--set',
`web.tenantId=${value}`,
'--set',
`web.actorId=${value}`,
'--set',
`model.apiKey=${value}`,
],
]) {
const objects = await render(args, inputs.slice(0, -2));
const config = JSON.parse(
find(objects, 'ConfigMap').data['managed-web.json'],
);
assert.equal(config.tenantId, String(value));
assert.equal(config.actorId, String(value));
assert.equal(
Buffer.from(
find(objects, 'Secret').data['model-api-key'],
'base64',
).toString(),
String(value),
);
}
}
});
test('missing, null or whitespace browser identities and keys fail closed', async () => {
const web = [
'--set',
'web.enabled=true',
'--set',
'web.tenantId=tenant-a',
'--set',
'web.actorId=actor-a',
];
for (const key of ['web.tenantId', 'web.actorId', 'model.apiKey']) {
for (const [flag, value] of [
['--set', ''],
['--set', 'null'],
['--set-string', ' '],
]) {
await assert.rejects(render([...web, flag, `${key}=${value}`]), (error) =>
error.message.includes(`Set ${key}`),
);
}
}
});
test('missing installation inputs or missing upgrade credentials fail closed', async () => {
for (const key of [
'server.image',
'harness.image',
'model.id',
'model.baseUrl',
'model.apiKey',
]) {
await assert.rejects(render(['--set', `${key}=`]), /Set /);
}
await assert.rejects(render(['--is-upgrade']), /restore it before upgrading/);
const external = await render([
'--is-upgrade',
'--set',
'model.apiKey=',
'--set',
'credentials.existingSecret=operator-keys',
]);
assert(!find(external, 'Secret'));
assert.equal(
env(find(external, 'Deployment').spec.template.spec.containers[0])
.SPRING_DATASOURCE_PASSWORD.valueFrom.secretKeyRef.name,
'operator-keys',
);
});
test('server-side Helm rendering preserves every existing credential on upgrade', async (t) => {
const original = find(await render(), 'Secret');
let storedSecret = original;
let retainedClaim;
let retainedRuntime;
const visited = [];
const peer = createServer((request, response) => {
visited.push(request.url);
const routes = {
'/version': { major: '1', minor: '32', gitVersion: 'v1.32.0' },
'/api': { kind: 'APIVersions', versions: ['v1'] },
'/apis': { kind: 'APIGroupList', groups: [] },
'/api/v1': {
kind: 'APIResourceList',
groupVersion: 'v1',
resources: [
{
name: 'secrets',
kind: 'Secret',
namespaced: true,
verbs: ['get', 'list'],
},
{
name: 'persistentvolumeclaims',
kind: 'PersistentVolumeClaim',
namespaced: true,
verbs: ['get', 'list'],
},
],
},
'/api/v1/namespaces/test-space/secrets/fixture-keys': storedSecret,
'/api/v1/namespaces/test-space/persistentvolumeclaims/data-fixture-mysql-0':
retainedClaim,
'/api/v1/namespaces/test-space/persistentvolumeclaims/fixture-runtime':
retainedRuntime,
};
const value = routes[request.url?.split('?')[0]];
response.writeHead(value ? 200 : 404, {
'Content-Type': 'application/json',
});
response.end(
JSON.stringify(
value ?? {
kind: 'Status',
status: 'Failure',
reason: 'NotFound',
code: 404,
},
),
);
});
await new Promise((ready) => peer.listen(0, '127.0.0.1', ready));
const temporary = await mkdtemp(join(tmpdir(), 'qwen-helm-lookup-'));
t.after(async () => {
peer.closeAllConnections();
await new Promise((done) => peer.close(done));
await rm(temporary, { recursive: true, force: true });
});
const kubeconfig = join(temporary, 'config');
await writeFile(
kubeconfig,
JSON.stringify({
apiVersion: 'v1',
kind: 'Config',
'current-context': 'fixture',
clusters: [
{
name: 'fixture',
cluster: { server: `http://127.0.0.1:${peer.address().port}` },
},
],
contexts: [
{ name: 'fixture', context: { cluster: 'fixture', user: 'fixture' } },
],
users: [{ name: 'fixture', user: {} }],
}),
{ mode: 0o600 },
);
const upgraded = find(
await render([
'--is-upgrade',
'--dry-run=server',
'--kubeconfig',
kubeconfig,
'--set',
'model.apiKey=must-not-rotate',
]),
'Secret',
);
assert(
visited.includes('/api/v1/namespaces/test-space/secrets/fixture-keys'),
);
assert.deepEqual(upgraded.data, original.data);
storedSecret.data['operator-note'] =
Buffer.from('preserve-extra-key').toString('base64');
const optional = [
'--set',
'runtime.enabled=true',
'--set',
'runtime.nodeName=trusted-node',
'--set',
'web.enabled=true',
'--set',
'web.tenantId=tenant-a',
'--set',
'web.actorId=actor-a',
];
const enabled = find(
await render([
'--is-upgrade',
'--dry-run=server',
'--kubeconfig',
kubeconfig,
...optional,
]),
'Secret',
);
for (const [key, value] of Object.entries(storedSecret.data)) {
assert.equal(enabled.data[key], value);
}
assert(enabled.data['runtime-broker-token']);
assert(enabled.data['runtime-credential-key']);
assert(enabled.data['web-token']);
storedSecret = enabled;
const disabled = find(
await render([
'--is-upgrade',
'--dry-run=server',
'--kubeconfig',
kubeconfig,
]),
'Secret',
);
assert.deepEqual(disabled.data, enabled.data);
const external = ['--set', 'credentials.existingSecret=fixture-keys'];
delete storedSecret.data['runtime-credential-key'];
retainedRuntime = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: 'fixture-runtime' },
};
await assert.rejects(
render([
'--is-upgrade',
'--dry-run=server',
'--kubeconfig',
kubeconfig,
...optional,
]),
/retained Runtime PVC is missing its credentials/,
);
await assert.rejects(
render([
'--dry-run=server',
'--kubeconfig',
kubeconfig,
...optional,
...external,
]),
/missing runtime-credential-key/,
);
storedSecret.data['runtime-credential-key'] =
enabled.data['runtime-broker-token'];
storedSecret.data['web-token'] = storedSecret.data['auth-signing-key'];
await assert.rejects(
render([
'--dry-run=server',
'--kubeconfig',
kubeconfig,
...optional,
...external,
]),
/web-token must differ/,
);
storedSecret = undefined;
retainedClaim = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: 'data-fixture-mysql-0' },
};
await assert.rejects(
render(['--dry-run=server', '--kubeconfig', kubeconfig]),
/retained database PVC exists without its Secret/,
);
});
+1
View File
@@ -0,0 +1 @@
*.tgz
+6
View File
@@ -0,0 +1,6 @@
apiVersion: 'v2'
name: 'qwen-managed-agent'
description: 'Single-replica Managed Agent API with Hosted Harness and MySQL'
type: 'application'
version: '0.1.0'
kubeVersion: '>=1.28.0-0'
@@ -0,0 +1,23 @@
Managed Agent exposes a signed API.
{{ if .Values.runtime.enabled }}
Trusted single-node Workspace files and foreground Shell are enabled.
Close every tool Session and confirm physical holders are clear before replacing the Pod.
{{ else }}
Workspace tools are disabled.
{{ end }}
Connect locally:
kubectl -n {{ .Release.Namespace }} port-forward service/{{ .Release.Name }} 8080:8080
Run deploy/kubernetes/smoke.mjs with the signing key from Secret {{ include "managed-agent.secret" . }}.
See deploy/kubernetes/README.md for the command and upgrade procedure.
{{ if .Values.web.enabled }}
Connect to WebShell:
kubectl -n {{ .Release.Namespace }} port-forward service/{{ .Release.Name }} 4171:4171
Authenticate using web-token from Secret {{ include "managed-agent.secret" . }}.
The fixed browser identity is {{ .Values.web.tenantId }}/{{ .Values.web.actorId }}.
{{ end }}
Uninstall retains the managed Secret, MySQL PVC and any Runtime/Workspace PVCs. Back them up before upgrades.
Do not use automatic Helm rollback across database migrations.
@@ -0,0 +1,9 @@
{{- define "managed-agent.labels" -}}
app.kubernetes.io/name: qwen-managed-agent
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "managed-agent.secret" -}}
{{- default (printf "%s-keys" .Release.Name) .Values.credentials.existingSecret -}}
{{- end -}}
@@ -0,0 +1,21 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
labels:
{{- include "managed-agent.labels" . | nindent 4 }}
data:
settings.json: |
{{- $model := required "Set model.id to an OpenAI-compatible model ID." .Values.model.id }}
{{- $baseUrl := required "Set model.baseUrl to the OpenAI-compatible endpoint." .Values.model.baseUrl }}
{{- dict "security" (dict "auth" (dict "selectedType" "openai")) "model" (dict "name" $model) "modelProviders" (dict "openai" (list (dict "id" $model "baseUrl" $baseUrl "envKey" "OPENAI_API_KEY"))) "telemetry" (dict "enabled" false) | toPrettyJson | nindent 4 }}
mysql-init.sql: |
GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'%';
{{- if .Values.web.enabled }}
managed-web.json: |
{{- $tenantError := "Set web.tenantId for the fixed browser identity." }}
{{- $actorError := "Set web.actorId for the fixed browser identity." }}
{{- $tenant := required $tenantError (required $tenantError .Values.web.tenantId | toString | trim) }}
{{- $actor := required $actorError (required $actorError .Values.web.actorId | toString | trim) }}
{{- dict "apiUrl" "http://127.0.0.1:8080" "tenantId" $tenant "actorId" $actor "signingKeyFile" "/run/qwen-web/auth-signing-key" | toPrettyJson | nindent 4 }}
{{- end }}
@@ -0,0 +1,346 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels:
{{- include "managed-agent.labels" . | nindent 4 }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: application
template:
metadata:
labels:
{{- include "managed-agent.labels" . | nindent 8 }}
app.kubernetes.io/component: application
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
{{- if .Values.runtime.enabled }}
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchFields:
- key: metadata.name
operator: In
values:
- {{ required "Set runtime.nodeName to the trusted Node name." .Values.runtime.nodeName | quote }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
terminationGracePeriodSeconds: 60
securityContext:
fsGroup: 1000
{{- if .Values.runtime.enabled }}
fsGroupChangePolicy: OnRootMismatch
{{- end }}
seccompProfile:
type: RuntimeDefault
containers:
- name: server
image: {{ required "Set server.image to the built Java image." .Values.server.image | quote }}
imagePullPolicy: {{ .Values.imagePullPolicy }}
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
ports:
- name: api
containerPort: 8080
env:
- name: SPRING_DATASOURCE_URL
value: {{ printf "jdbc:mysql://%s-mysql:3306/qwen_managed_agent" .Release.Name | quote }}
- name: SPRING_DATASOURCE_USERNAME
value: qwen
{{- $secret := include "managed-agent.secret" . }}
{{- range $env, $key := dict "SPRING_DATASOURCE_PASSWORD" "mysql-password" "QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY" "auth-signing-key" "QWEN_MANAGED_AGENT_SESSION_STORE_BINDING_KEY" "session-store-binding-key" "QWEN_MANAGED_AGENT_HARNESS_TOKEN" "harness-token" "QWEN_MANAGED_AGENT_CAPABILITY_DIGEST" "capability-digest" }}
- name: {{ $env }}
valueFrom:
secretKeyRef:
name: {{ $secret }}
key: {{ $key }}
{{- end }}
- name: QWEN_MANAGED_AGENT_SERVER_ADDRESS
value: '0.0.0.0'
- name: QWEN_MANAGED_AGENT_AUTH_MODE
value: signed
- name: QWEN_MANAGED_AGENT_INTERNAL_SERVER_ADDRESS
value: '127.0.0.1'
- name: QWEN_MANAGED_AGENT_INTERNAL_SERVER_PORT
value: '8081'
- name: QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL
value: 'http://127.0.0.1:8081'
- name: QWEN_MANAGED_AGENT_WORKSPACE_ID
value: {{ "/workspace" | sha256sum | trunc 16 | quote }}
- name: QWEN_MANAGED_AGENT_WORKSPACE_CWD
value: /workspace
- name: QWEN_MANAGED_AGENT_HARNESS_ENABLED
value: 'true'
- name: QWEN_MANAGED_AGENT_HARNESS_BASE_URL
value: 'http://127.0.0.1:4170'
- name: QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED
value: {{ .Values.runtime.enabled | quote }}
- name: QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED
value: {{ .Values.runtime.enabled | quote }}
- name: QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED
value: {{ .Values.runtime.enabled | quote }}
{{- if .Values.runtime.enabled }}
{{- range $env, $key := dict "QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN" "runtime-broker-token" "QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY" "runtime-credential-key" }}
- name: {{ $env }}
valueFrom:
secretKeyRef:
name: {{ $secret }}
key: {{ $key }}
{{- end }}
- name: QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY_ID
value: kubernetes-v1
- name: QWEN_MANAGED_AGENT_RUNTIME_PROVISIONER
value: local-process
- name: QWEN_MANAGED_AGENT_RUNTIME_ISOLATION
value: session
- name: QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY
value: /runtime-data/state
- name: QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS
value: 'true'
- name: QWEN_MANAGED_AGENT_RUNTIME_VERIFIED_WORKSPACE_RECOVERY_ENABLED
value: 'true'
- name: QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY
value: 'false'
- name: QWEN_MANAGED_AGENT_WORKSPACE_CREATION_ROOT
value: /workspaces
- name: QWEN_MANAGED_AGENT_NODE_EXECUTABLE
value: /usr/local/bin/node
- name: QWEN_MANAGED_AGENT_RUNTIME_WORKER_ENTRY
value: /usr/local/share/npm-global/lib/node_modules/@qwen-code/qwen-code/cli.js
- name: QWEN_MANAGED_AGENT_APPROVAL_MODE
value: default
- name: QWEN_MANAGED_AGENT_WORKSPACE_CHILD_AGENTS_ENABLED
value: 'false'
- name: QWEN_MANAGED_AGENT_TOOL_PUBLICATION_ENABLED
value: 'false'
- name: QWEN_HOME
value: /runtime-data/home
- name: QWEN_RUNTIME_DIR
value: /runtime-data/home/runtime
{{- end }}
- name: MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED
value: 'true'
- name: MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE
value: readinessState,db
startupProbe:
httpGet:
path: /actuator/health/liveness
port: api
periodSeconds: 5
failureThreshold: 120
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: api
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: api
timeoutSeconds: 3
resources:
{{- toYaml .Values.server.resources | nindent 12 }}
volumeMounts:
- name: workspace
mountPath: /workspace
- name: server-tmp
mountPath: /tmp
{{- if .Values.runtime.enabled }}
- name: workspaces
mountPath: /workspaces
- name: runtime-data
mountPath: /runtime-data
- name: machine-id
mountPath: /etc/machine-id
readOnly: true
{{- end }}
- name: harness
image: {{ required "Set harness.image to the matching built Qwen CLI image." .Values.harness.image | quote }}
imagePullPolicy: {{ .Values.imagePullPolicy }}
command: [qwen]
args: [serve, --profile, hosted-harness, --hostname, '127.0.0.1', --port, '4170', --no-web]
workingDir: /workspace
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
env:
- name: QWEN_HOME
value: /home/node/.qwen
- name: QWEN_RUNTIME_DIR
value: /home/node/.qwen/runtime
{{- if .Values.runtime.enabled }}
- name: QWEN_MANAGED_RUNTIME_BROKER_URL
value: 'http://127.0.0.1:4182'
- name: QWEN_MANAGED_RUNTIME_BROKER_TOKEN
valueFrom:
secretKeyRef:
name: {{ $secret }}
key: runtime-broker-token
{{- end }}
{{- range $env, $key := dict "QWEN_SERVER_TOKEN" "harness-token" "QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST" "capability-digest" "OPENAI_API_KEY" "model-api-key" }}
- name: {{ $env }}
valueFrom:
secretKeyRef:
name: {{ $secret }}
key: {{ $key }}
{{- end }}
startupProbe:
exec:
command: &harness-health
- node
- -e
- "fetch('http://127.0.0.1:4170/health',{headers:{authorization:'Bearer '+process.env.QWEN_SERVER_TOKEN},signal:AbortSignal.timeout(2000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
livenessProbe:
exec:
command: *harness-health
timeoutSeconds: 3
readinessProbe:
exec:
command: *harness-health
timeoutSeconds: 3
resources:
{{- toYaml .Values.harness.resources | nindent 12 }}
volumeMounts:
- name: harness-home
mountPath: /home/node/.qwen
- name: config
mountPath: /home/node/.qwen/settings.json
subPath: settings.json
readOnly: true
- name: workspace
mountPath: /workspace
- name: harness-tmp
mountPath: /tmp
{{- if .Values.web.enabled }}
- name: web
image: {{ default .Values.harness.image .Values.web.image | quote }}
imagePullPolicy: {{ .Values.imagePullPolicy }}
command: [qwen]
args:
- serve
- --managed-web-config
- /etc/qwen-web/config.json
- --hostname
- '0.0.0.0'
- --port
- '4171'
{{- range .Values.web.allowOrigins }}
- --allow-origin
- {{ . | quote }}
{{- end }}
workingDir: /home/node/.qwen
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
ports:
- name: web
containerPort: 4171
env:
- name: QWEN_HOME
value: /home/node/.qwen
- name: QWEN_RUNTIME_DIR
value: /home/node/.qwen/runtime
- name: QWEN_SERVER_TOKEN
valueFrom:
secretKeyRef:
name: {{ $secret }}
key: web-token
startupProbe:
exec:
command: &web-health
- node
- -e
- "fetch('http://127.0.0.1:4171/health',{headers:{authorization:'Bearer '+process.env.QWEN_SERVER_TOKEN},signal:AbortSignal.timeout(2000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
livenessProbe:
exec:
command: *web-health
timeoutSeconds: 3
readinessProbe:
exec:
command: *web-health
timeoutSeconds: 3
resources:
{{- toYaml .Values.web.resources | nindent 12 }}
volumeMounts:
- name: web-home
mountPath: /home/node/.qwen
- name: web-tmp
mountPath: /tmp
- name: config
mountPath: /etc/qwen-web/config.json
subPath: managed-web.json
readOnly: true
- name: web-signing-key
mountPath: /run/qwen-web
readOnly: true
{{- end }}
volumes:
- name: config
configMap:
name: {{ .Release.Name }}-config
- name: harness-home
emptyDir: {}
- name: workspace
emptyDir: {}
- name: server-tmp
emptyDir: {}
- name: harness-tmp
emptyDir: {}
{{- if .Values.runtime.enabled }}
- name: workspaces
persistentVolumeClaim:
claimName: {{ .Release.Name }}-workspaces
- name: runtime-data
persistentVolumeClaim:
claimName: {{ .Release.Name }}-runtime
- name: machine-id
hostPath:
path: /etc/machine-id
type: File
{{- end }}
{{- if .Values.web.enabled }}
- name: web-home
emptyDir: {}
- name: web-tmp
emptyDir: {}
- name: web-signing-key
secret:
secretName: {{ $secret }}
defaultMode: 0440
items:
- key: auth-signing-key
path: auth-signing-key
{{- end }}
@@ -0,0 +1,102 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-mysql
labels:
{{- include "managed-agent.labels" . | nindent 4 }}
spec:
clusterIP: None
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: mysql
ports:
- name: mysql
port: 3306
targetPort: mysql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-mysql
labels:
{{- include "managed-agent.labels" . | nindent 4 }}
spec:
serviceName: {{ .Release.Name }}-mysql
replicas: 1
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: mysql
template:
metadata:
labels:
{{- include "managed-agent.labels" . | nindent 8 }}
app.kubernetes.io/component: mysql
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: mysql
image: {{ .Values.mysql.image | quote }}
imagePullPolicy: {{ .Values.imagePullPolicy }}
ports:
- name: mysql
containerPort: 3306
env:
- name: MYSQL_DATABASE
value: qwen_managed_agent
- name: MYSQL_USER
value: qwen
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "managed-agent.secret" . }}
key: mysql-password
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "managed-agent.secret" . }}
key: mysql-root-password
startupProbe:
tcpSocket:
port: mysql
periodSeconds: 5
failureThreshold: 120
livenessProbe:
tcpSocket:
port: mysql
readinessProbe:
exec:
command:
- sh
- -c
- 'MYSQL_PWD="$MYSQL_PASSWORD" mysql --host=127.0.0.1 --user=qwen --database=qwen_managed_agent --execute="SELECT 1; SELECT 1 FROM performance_schema.user_variables_by_thread LIMIT 0"'
timeoutSeconds: 5
resources:
{{- toYaml .Values.mysql.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: config
mountPath: /docker-entrypoint-initdb.d/qwen.sql
subPath: mysql-init.sql
readOnly: true
volumes:
- name: config
configMap:
name: {{ .Release.Name }}-config
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
{{- with .Values.mysql.storage.className }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.mysql.storage.size | quote }}
@@ -0,0 +1,21 @@
{{- if .Values.runtime.enabled }}
{{- range $suffix, $size := dict "workspaces" .Values.runtime.storage.workspaceSize "runtime" .Values.runtime.storage.stateSize }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ $.Release.Name }}-{{ $suffix }}
labels:
{{- include "managed-agent.labels" $ | nindent 4 }}
annotations:
helm.sh/resource-policy: keep
spec:
accessModes: [ReadWriteOnce]
{{- with $.Values.runtime.storage.className }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ $size | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,72 @@
{{- $name := include "managed-agent.secret" . }}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }}
{{- $required := list "auth-signing-key" "session-store-binding-key" "harness-token" "capability-digest" "mysql-password" "mysql-root-password" "model-api-key" }}
{{- if .Values.credentials.existingSecret }}
{{- if .Values.runtime.enabled }}
{{- $required = concat $required (list "runtime-broker-token" "runtime-credential-key") }}
{{- end }}
{{- if .Values.web.enabled }}
{{- $required = append $required "web-token" }}
{{- end }}
{{- if $existing }}
{{- range $key := $required }}
{{- $_ := required (printf "Existing Secret %s is missing %s" $name $key) (get $existing.data $key) }}
{{- end }}
{{- if and .Values.web.enabled (eq (get $existing.data "web-token") (get $existing.data "auth-signing-key")) }}
{{- fail "web-token must differ from auth-signing-key." }}
{{- end }}
{{- end }}
{{- else }}
{{- if and .Release.IsUpgrade (not $existing) }}
{{- fail "The deployment Secret is missing; restore it before upgrading an existing database." }}
{{- end }}
{{- if and (not $existing) (lookup "v1" "PersistentVolumeClaim" .Release.Namespace (printf "data-%s-mysql-0" .Release.Name)) }}
{{- fail "A retained database PVC exists without its Secret; restore the original credentials before installing." }}
{{- end }}
{{- $data := dict }}
{{- if $existing }}
{{- $data = deepCopy $existing.data }}
{{- range $key := $required }}
{{- $_ := required (printf "Existing Secret %s is missing %s" $name $key) (get $data $key) }}
{{- end }}
{{- else }}
{{- $_ := set $data "auth-signing-key" (randAlphaNum 64 | b64enc) }}
{{- $_ := set $data "session-store-binding-key" (randAlphaNum 64 | b64enc) }}
{{- $_ := set $data "harness-token" (randAlphaNum 64 | b64enc) }}
{{- $_ := set $data "capability-digest" (printf "sha256:%s" (randAlphaNum 64 | sha256sum) | b64enc) }}
{{- $_ := set $data "mysql-password" (randAlphaNum 32 | b64enc) }}
{{- $_ := set $data "mysql-root-password" (randAlphaNum 32 | b64enc) }}
{{- $keyError := "Set model.apiKey with --set-file, or supply credentials.existingSecret." }}
{{- $_ := set $data "model-api-key" (required $keyError (required $keyError .Values.model.apiKey | toString | trim) | b64enc) }}
{{- end }}
{{- if .Values.runtime.enabled }}
{{- if and (or (not (hasKey $data "runtime-broker-token")) (not (hasKey $data "runtime-credential-key"))) (lookup "v1" "PersistentVolumeClaim" .Release.Namespace (printf "%s-runtime" .Release.Name)) }}
{{- fail "A retained Runtime PVC is missing its credentials; restore the original Secret before enabling Runtime." }}
{{- end }}
{{- if not (hasKey $data "runtime-broker-token") }}
{{- $_ := set $data "runtime-broker-token" (randAlphaNum 64 | b64enc) }}
{{- end }}
{{- if not (hasKey $data "runtime-credential-key") }}
{{- $_ := set $data "runtime-credential-key" (randBytes 32 | b64enc) }}
{{- end }}
{{- end }}
{{- if .Values.web.enabled }}
{{- if not (hasKey $data "web-token") }}
{{- $_ := set $data "web-token" (randAlphaNum 64 | b64enc) }}
{{- end }}
{{- if eq (get $data "web-token") (get $data "auth-signing-key") }}
{{- fail "web-token must differ from auth-signing-key." }}
{{- end }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $name }}
labels:
{{- include "managed-agent.labels" . | nindent 4 }}
annotations:
helm.sh/resource-policy: keep
type: Opaque
data:
{{- toYaml $data | nindent 2 }}
{{- end }}
@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
labels:
{{- include "managed-agent.labels" . | nindent 4 }}
spec:
type: ClusterIP
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: application
ports:
- name: api
port: 8080
targetPort: api
{{- if .Values.web.enabled }}
- name: web
port: 4171
targetPort: web
{{- end }}
+66
View File
@@ -0,0 +1,66 @@
server:
image: ''
resources:
requests:
cpu: '250m'
memory: '512Mi'
limits:
memory: '2Gi'
harness:
image: ''
resources:
requests:
cpu: '250m'
memory: '512Mi'
limits:
memory: '2Gi'
runtime:
enabled: false
# Required when enabled: the exact trusted Kubernetes Node name.
nodeName: ''
storage:
workspaceSize: '10Gi'
stateSize: '1Gi'
className: ''
web:
enabled: false
# Empty reuses harness.image; it must include --managed-web-config.
image: ''
tenantId: ''
actorId: ''
allowOrigins: []
resources:
requests:
cpu: '100m'
memory: '128Mi'
limits:
memory: '512Mi'
model:
id: ''
baseUrl: ''
# Only used when creating the managed Secret; prefer --set-file.
apiKey: ''
credentials:
# See the README for required keys. Empty means generate and retain a Secret.
existingSecret: ''
mysql:
image: 'mysql:8.4'
storage:
size: '10Gi'
# Empty uses the cluster's default StorageClass.
className: ''
resources:
requests:
cpu: '250m'
memory: '512Mi'
limits:
memory: '2Gi'
imagePullPolicy: 'IfNotPresent'
imagePullSecrets: []
+22
View File
@@ -0,0 +1,22 @@
ARG SERVER_IMAGE
ARG HARNESS_IMAGE
FROM ${SERVER_IMAGE} AS server
FROM ${HARNESS_IMAGE}
USER root
COPY --from=server /opt/java/openjdk /opt/java/openjdk
COPY --from=server /app/qwen-managed-agent-server.jar /app/qwen-managed-agent-server.jar
COPY --chmod=755 deploy/kubernetes/start-server.sh /opt/qwen/bin/start-server
ENV JAVA_HOME=/opt/java/openjdk
ENV PATH=/opt/java/openjdk/bin:$PATH
RUN useradd --uid 10001 --gid 1000 --home-dir /runtime-data/home --no-create-home qwen-managed \
&& node --version \
&& java -version \
&& test -r /usr/local/share/npm-global/lib/node_modules/@qwen-code/qwen-code/cli.js \
&& test -x /usr/bin/stat
USER 10001
ENTRYPOINT ["/opt/qwen/bin/start-server"]
CMD []
+142
View File
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { fileURLToPath, URL } from 'node:url';
import { promisify } from 'node:util';
const execute = promisify(execFile);
const script = fileURLToPath(new URL('./start-server.sh', import.meta.url));
test(
'combined launcher accepts explicitly disabled Runtime and rejects partial configuration',
{ skip: process.platform === 'win32' },
async (t) => {
const root = await mkdtemp(join(tmpdir(), 'qwen-runtime-disabled-'));
t.after(() => rm(root, { recursive: true, force: true }));
const launched = join(root, 'launched');
await mkdir(join(root, 'bin'));
await writeFile(
join(root, 'bin', 'java'),
'#!/bin/sh\nprintf "%s\\n" "$@" > "$QWEN_TEST_LAUNCHED"\n',
{ mode: 0o755 },
);
const environment = {
...process.env,
JAVA_HOME: root,
QWEN_TEST_LAUNCHED: launched,
};
delete environment.QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED;
delete environment.QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY;
delete environment.QWEN_HOME;
await execute('/bin/sh', [script, '--spring.profiles.active=test'], {
env: {
...environment,
QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'false',
},
});
assert.equal(
await readFile(launched, 'utf8'),
'-jar\n/app/qwen-managed-agent-server.jar\n--spring.profiles.active=test\n',
);
await rm(launched);
for (const overrides of [
{},
{ QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'true' },
{ QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'invalid' },
{
QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'false',
QWEN_HOME: join(root, 'home'),
},
{
QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'false',
QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY: join(root, 'state'),
},
]) {
await assert.rejects(
execute('/bin/sh', [script], {
env: { ...environment, ...overrides },
}),
);
await assert.rejects(stat(launched), { code: 'ENOENT' });
}
},
);
test(
'LOCAL launcher restores private modes and rejects symlinks or unowned entries',
{ skip: process.platform === 'win32' },
async (t) => {
const root = await mkdtemp(join(tmpdir(), 'qwen-runtime-launcher-'));
t.after(() => rm(root, { recursive: true, force: true }));
const state = join(root, 'state');
const home = join(root, 'home');
const bin = join(root, 'bin');
const launched = join(root, 'launched');
await mkdir(state);
await mkdir(bin);
const record = join(state, 'record.json');
await writeFile(record, '{}');
await chmod(state, 0o750);
await chmod(record, 0o640);
await writeFile(
join(bin, 'java'),
'#!/bin/sh\nprintf "%s\\n" "$@" > "$QWEN_TEST_LAUNCHED"\n',
{ mode: 0o755 },
);
const environment = {
...process.env,
JAVA_HOME: root,
QWEN_HOME: home,
QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY: state,
QWEN_TEST_LAUNCHED: launched,
};
await execute('/bin/sh', [script], { env: environment });
assert.equal((await stat(state)).mode & 0o777, 0o700);
assert.equal((await stat(record)).mode & 0o777, 0o600);
assert.equal((await stat(home)).mode & 0o777, 0o700);
assert.equal(
await readFile(launched, 'utf8'),
'-jar\n/app/qwen-managed-agent-server.jar\n',
);
await rm(launched);
const link = join(state, 'link');
await symlink(record, link);
await assert.rejects(
execute('/bin/sh', [script], { env: environment }),
/unsupported entry/,
);
await assert.rejects(stat(launched), { code: 'ENOENT' });
await rm(link);
await chmod(record, 0o640);
await writeFile(join(bin, 'id'), '#!/bin/sh\necho 4294967294\n', {
mode: 0o755,
});
await assert.rejects(
execute('/bin/sh', [script], {
env: { ...environment, PATH: `${bin}:${process.env.PATH}` },
}),
/unowned/,
);
assert.equal((await stat(record)).mode & 0o777, 0o640);
},
);
+222
View File
@@ -0,0 +1,222 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* global fetch, AbortSignal */
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { createHash, createHmac, randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import process from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { pathToFileURL, URL, URLSearchParams } from 'node:url';
import { parseArgs } from 'node:util';
export function signedHeaders(
method,
url,
body,
key,
tenant,
actor,
requestKey,
) {
const timestamp = String(Math.floor(Date.now() / 1000));
const canonical = [
'qwen-broker-auth-v1',
method,
url.pathname,
url.search.slice(1),
tenant,
actor,
timestamp,
createHash('sha256').update(body).digest('hex'),
requestKey ?? '',
].join('\n');
return {
'Content-Type': 'application/json',
'X-Qwen-Tenant-Id': tenant,
'X-Qwen-Actor-Id': actor,
'X-Qwen-Signature-Timestamp': timestamp,
'X-Qwen-Signature': `v1=${createHmac('sha256', key).update(canonical).digest('hex')}`,
...(requestKey ? { 'Idempotency-Key': requestKey } : {}),
};
}
export async function runSmoke({
baseUrl,
signingKey,
tenant = 'k8s-smoke',
actor = 'operator',
sessionId,
keepSession = false,
timeoutMs = 120_000,
}) {
const base = new URL(baseUrl);
assert(
['http:', 'https:'].includes(base.protocol),
'Use an HTTP(S) API URL.',
);
assert(
!base.username && !base.password,
'URL credentials are not supported.',
);
assert(
Buffer.byteLength(signingKey) >= 32,
'The signing key must have at least 32 bytes.',
);
assert(
Number.isSafeInteger(timeoutMs) && timeoutMs > 0,
'Timeout must be a positive integer.',
);
const deadline = Date.now() + timeoutMs;
const signal = () => {
assert(Date.now() < deadline, 'Smoke timed out.');
return AbortSignal.timeout(Math.min(10_000, deadline - Date.now()));
};
const request = async (method, path, value) => {
const url = new URL(path, base);
const body = value === undefined ? '' : JSON.stringify(value);
const key = method === 'GET' ? undefined : randomUUID();
const response = await fetch(url, {
method,
headers: signedHeaders(method, url, body, signingKey, tenant, actor, key),
...(body ? { body } : {}),
redirect: 'error',
signal: signal(),
});
assert(response.ok, `${method} ${path}: HTTP ${response.status}`);
return response.json();
};
const wait = async (path) => {
while (true) {
const result = await request('GET', path);
if (result.status === 'completed') return result;
assert(
['accepted', 'pending', 'running', 'cancelling'].includes(
result.status,
),
`Operation ${result.status}: ${result.error_code ?? result.failure_code ?? 'no error code'}`,
);
await setTimeout(Math.min(1000, Math.max(0, deadline - Date.now())));
}
};
const historyContains = async (path, predicate) => {
let cursor;
do {
const query = new URLSearchParams({ limit: '100' });
if (cursor) query.set('cursor', cursor);
const items = await request('GET', `${path}/items?${query}`);
if (items.data.some(predicate)) return true;
cursor = items.has_more ? items.next_cursor : undefined;
} while (cursor);
return false;
};
const unsigned = await fetch(new URL('/v1/agents/sessions', base), {
headers: { 'X-Qwen-Tenant-Id': tenant },
redirect: 'error',
signal: signal(),
});
assert.equal(unsigned.status, 401, 'Unsigned requests must be refused.');
await unsigned.body?.cancel();
try {
if (sessionId) {
assert(
await historyContains(
`/v1/agents/sessions/${sessionId}`,
(item) => item.role === 'assistant',
),
'Previous assistant history is missing.',
);
} else {
const created = await request('POST', '/v1/agents/sessions', {
agent_id: 'qwen-code',
});
sessionId = created.id;
assert(sessionId, 'Session creation returned no ID.');
}
const path = `/v1/agents/sessions/${sessionId}`;
const marker = `K8S_SMOKE_${randomUUID().replaceAll('-', '')}`;
const input = await request('POST', `${path}/events`, {
type: 'agent.session.input.message',
input: [{ type: 'input_text', text: `Reply with exactly ${marker}.` }],
});
assert(input.turn_id, 'Input admission returned no Turn ID.');
await wait(`${path}/turns/${input.turn_id}`);
assert(
await historyContains(
path,
(item) =>
item.role === 'assistant' &&
item.content?.some(
(part) =>
part.type === 'output_text' && part.text?.includes(marker),
),
),
'Assistant history does not contain the smoke response.',
);
if (!keepSession) {
const operation = await request('POST', `${path}/close`);
assert(operation.id, 'Close admission returned no operation ID.');
await wait(`${path}/operations/${operation.id}`);
const closed = await request('GET', path);
assert.equal(closed.status, 'closed');
}
return {
sessionId,
turnId: input.turn_id,
status: 'completed',
closed: !keepSession,
};
} catch (error) {
throw new Error(
`Smoke failed (Session ${sessionId ?? 'not created'}): ${error.message}`,
{ cause: error },
);
}
}
async function main() {
const { values } = parseArgs({
options: {
url: { type: 'string', default: 'http://127.0.0.1:8080' },
'key-file': { type: 'string' },
tenant: { type: 'string', default: 'k8s-smoke' },
actor: { type: 'string', default: 'operator' },
'session-id': { type: 'string' },
'keep-session': { type: 'boolean', default: false },
'timeout-ms': { type: 'string', default: '120000' },
},
});
assert(
values['key-file'],
'Pass --key-file pointing to the auth-signing-key.',
);
const signingKey = await readFile(values['key-file'], 'utf8');
const result = await runSmoke({
baseUrl: values.url,
signingKey,
tenant: values.tenant,
actor: values.actor,
sessionId: values['session-id'],
keepSession: values['keep-session'],
timeoutMs: Number(values['timeout-ms']),
});
process.stdout.write(`${JSON.stringify(result)}\n`);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
});
}
+265
View File
@@ -0,0 +1,265 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { execFile } from 'node:child_process';
import { createHash, createHmac } from 'node:crypto';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { fileURLToPath, URL } from 'node:url';
import { promisify } from 'node:util';
import { runSmoke, signedHeaders } from './smoke.mjs';
const signingKey = 'fixture-signing-key-with-at-least-thirty-two-bytes';
async function apiPeer(
t,
{
output = true,
turnStatus = 'completed',
unsignedStatus = 401,
key = signingKey,
paginated = false,
retainedOnNextPage = false,
} = {},
) {
const state = {
inputs: 0,
closed: false,
signatureErrors: 0,
marker: '',
itemCursors: [],
};
const peer = createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const body = Buffer.concat(chunks).toString();
const url = new URL(request.url, 'http://fixture');
const headers = request.headers;
const send = (status, value) => {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(value));
};
if (!headers['x-qwen-signature']) return send(unsignedStatus, {});
const canonical = `qwen-broker-auth-v1\n${request.method}\n${url.pathname}\n${url.search.slice(1)}\n${headers['x-qwen-tenant-id']}\n${headers['x-qwen-actor-id']}\n${headers['x-qwen-signature-timestamp']}\n${createHash('sha256').update(body).digest('hex')}\n${headers['idempotency-key'] ?? ''}`;
if (
headers['x-qwen-signature'] !==
`v1=${createHmac('sha256', key).update(canonical).digest('hex')}` ||
headers['x-qwen-tenant-id'] !== 'k8s-smoke' ||
headers['x-qwen-actor-id'] !== 'operator' ||
(request.method === 'POST' && !headers['idempotency-key'])
) {
state.signatureErrors++;
return send(401, {});
}
const root = '/v1/agents/sessions';
if (request.method === 'POST' && url.pathname === root) {
assert.deepEqual(JSON.parse(body), { agent_id: 'qwen-code' });
return send(202, { id: 'session-1' });
}
if (
request.method === 'POST' &&
url.pathname === `${root}/session-1/events`
) {
state.inputs++;
const input = JSON.parse(body);
assert.equal(input.type, 'agent.session.input.message');
state.marker = input.input[0].text.match(/K8S_SMOKE_[a-f0-9]+/)[0];
return send(202, { turn_id: 'turn-1' });
}
if (url.pathname === `${root}/session-1/turns/turn-1`) {
return send(200, { status: turnStatus, error_code: 'fixture_failure' });
}
if (url.pathname === `${root}/session-1/items`) {
const cursor = url.searchParams.get('cursor');
state.itemCursors.push(cursor);
if (paginated && !cursor) {
return send(200, {
data: Array.from({ length: 100 }, () => ({
role: retainedOnNextPage ? 'user' : 'assistant',
content: [{ type: 'output_text', text: 'old response' }],
})),
has_more: true,
next_cursor: 'snapshot/+first=',
});
}
if (paginated) assert.equal(cursor, 'snapshot/+first=');
return send(200, {
data: [
{
role: 'assistant',
content: [
{
type: 'output_text',
text: output ? state.marker : 'old response',
},
],
},
],
has_more: false,
});
}
if (
request.method === 'POST' &&
url.pathname === `${root}/session-1/close`
) {
state.closed = true;
return send(202, { id: 'close-1' });
}
if (url.pathname === `${root}/session-1/operations/close-1`) {
return send(200, { status: 'completed' });
}
if (url.pathname === `${root}/session-1`) {
return send(200, { status: state.closed ? 'closed' : 'active' });
}
send(404, {});
});
await new Promise((ready) => peer.listen(0, '127.0.0.1', ready));
t.after(async () => {
peer.closeAllConnections();
await new Promise((done) => peer.close(done));
});
return {
state,
baseUrl: `http://127.0.0.1:${peer.address().port}`,
signingKey: key,
};
}
test('signs UTF-8 body, raw query and idempotency key in the Java canonical order', () => {
const url = new URL('https://api.example/v1/agents/sessions?q=a%20b');
const body = '{"text":"你好"}';
const headers = signedHeaders(
'POST',
url,
body,
signingKey,
'tenant',
'actor',
'request-1',
);
const canonical = [
'qwen-broker-auth-v1',
'POST',
'/v1/agents/sessions',
'q=a%20b',
'tenant',
'actor',
headers['X-Qwen-Signature-Timestamp'],
createHash('sha256').update(body).digest('hex'),
'request-1',
].join('\n');
assert.equal(
headers['X-Qwen-Signature'],
`v1=${createHmac('sha256', signingKey).update(canonical).digest('hex')}`,
);
});
test('smoke requires authenticated completion and history, then closes', async (t) => {
const fixture = await apiPeer(t);
const result = await runSmoke(fixture);
assert.deepEqual(result, {
sessionId: 'session-1',
turnId: 'turn-1',
status: 'completed',
closed: true,
});
assert.equal(fixture.state.inputs, 1);
assert.equal(fixture.state.closed, true);
assert.equal(fixture.state.signatureErrors, 0);
});
test('CLI preserves trailing whitespace in operator-managed signing keys', async (t) => {
const key = `${signingKey}\n `;
const fixture = await apiPeer(t, { key });
const temporary = await mkdtemp(join(tmpdir(), 'qwen-smoke-key-'));
t.after(() => rm(temporary, { recursive: true, force: true }));
const keyFile = join(temporary, 'signing-key');
await writeFile(keyFile, key, { mode: 0o600 });
const { stdout } = await promisify(execFile)(process.execPath, [
fileURLToPath(new URL('./smoke.mjs', import.meta.url)),
'--url',
fixture.baseUrl,
'--key-file',
keyFile,
]);
assert.equal(JSON.parse(stdout).closed, true);
assert.equal(fixture.state.signatureErrors, 0);
});
test('idle restart workflow preserves the Session until explicitly resumed', async (t) => {
const fixture = await apiPeer(t);
const kept = await runSmoke({ ...fixture, keepSession: true });
assert.equal(kept.closed, false);
assert.equal(fixture.state.closed, false);
const resumed = await runSmoke({ ...fixture, sessionId: kept.sessionId });
assert.equal(resumed.closed, true);
assert.equal(fixture.state.inputs, 2);
});
test('a completed Turn with missing model output cannot pass', async (t) => {
const fixture = await apiPeer(t, { output: false });
await assert.rejects(
runSmoke(fixture),
/Session session-1.*does not contain/,
);
assert.equal(fixture.state.closed, false);
});
test('a reused long Session follows signed history cursors to the new output', async (t) => {
const fixture = await apiPeer(t, { paginated: true });
const result = await runSmoke({ ...fixture, sessionId: 'session-1' });
assert.equal(result.closed, true);
assert.deepEqual(fixture.state.itemCursors, [null, null, 'snapshot/+first=']);
assert.equal(fixture.state.inputs, 1);
assert.equal(fixture.state.signatureErrors, 0);
});
test('exhausted history pages without the new marker still fail', async (t) => {
const fixture = await apiPeer(t, { paginated: true, output: false });
await assert.rejects(
runSmoke({ ...fixture, sessionId: 'session-1' }),
/does not contain the smoke response/,
);
assert.equal(fixture.state.inputs, 1);
assert.equal(fixture.state.closed, false);
assert.equal(fixture.state.signatureErrors, 0);
});
test('reused history can start with a full page without an assistant', async (t) => {
const fixture = await apiPeer(t, {
paginated: true,
retainedOnNextPage: true,
});
const result = await runSmoke({ ...fixture, sessionId: 'session-1' });
assert.equal(result.closed, true);
assert.deepEqual(fixture.state.itemCursors, [
null,
'snapshot/+first=',
null,
'snapshot/+first=',
]);
assert.equal(fixture.state.inputs, 1);
assert.equal(fixture.state.signatureErrors, 0);
});
test('uncertain execution fails without replaying input or closing the Session', async (t) => {
const fixture = await apiPeer(t, { turnStatus: 'recovery_blocked' });
await assert.rejects(runSmoke(fixture), /recovery_blocked: fixture_failure/);
assert.equal(fixture.state.inputs, 1);
assert.equal(fixture.state.closed, false);
});
test('an unsigned deployment is refused before creating any Session', async (t) => {
const fixture = await apiPeer(t, { unsignedStatus: 200 });
await assert.rejects(runSmoke(fixture), /Unsigned requests must be refused/);
assert.equal(fixture.state.inputs, 0);
});
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
umask 077
if [ "${QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED:-}" != false ] || \
[ -n "${QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY:-}${QWEN_HOME:-}" ]; then
# Kubelet may widen PVC permissions for fsGroup before this process starts.
for directory in "${QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY:?}" "${QWEN_HOME:?}"; do
if [ -L "$directory" ]; then
echo >&2 'Runtime private directory must not be a symlink.'
exit 1
fi
if [ ! -e "$directory" ]; then
mkdir -m 700 -- "$directory"
fi
if [ ! -d "$directory" ] || \
[ -n "$(find "$directory" ! -uid "$(id -u)" -print -quit)" ] || \
[ -n "$(find "$directory" ! -type d ! -type f -print -quit)" ]; then
echo >&2 'Runtime private state contains an unowned or unsupported entry.'
exit 1
fi
find "$directory" -type d -exec chmod 700 {} +
find "$directory" -type f -exec chmod 600 {} +
done
fi
exec "${JAVA_HOME:?}/bin/java" -jar /app/qwen-managed-agent-server.jar "$@"
@@ -0,0 +1,117 @@
# Managed Agent Kubernetes bootstrap
[English](2026-10-24-managed-agent-kubernetes.md) | [简体中文](2026-10-24-managed-agent-kubernetes.zh-CN.md)
## Problem and current state
The Java control plane and Qwen CLI have container build definitions, but there
is no complete Kubernetes installation. The reference distribution targets a
single trusted Linux host. The existing K2 resident Workspace configuration
describes registered CSI execution, rather than installing the application.
## Proposed deployment
Add a dependency-free Helm chart under `deploy/kubernetes`. After building and
pushing the Java and CLI images from the same revision, one `helm upgrade
--install` installs the following resources:
| Resource | Responsibility |
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
| Single-replica Deployment | Java control plane and Hosted Harness in one Pod |
| ClusterIP Service | Signed public Java API on port 8080 |
| MySQL 8.4 StatefulSet and headless Service | Application database with a persistent volume |
| ConfigMap | Explicit OpenAI-compatible model settings and MySQL initialization grant |
| Secret | Stable signing, Session Store, Harness and database credentials, and model API key |
The Harness must bind to loopback and requires bearer authentication even for
health requests. Co-location preserves that existing contract. An isolated Java
internal listener on loopback port 8081 serves the Session Store. Neither private
port is exposed through a Service. The public listener uses signed authentication;
the application and Session Store use different keys. No Kubernetes API access or
service-account token is needed by the application.
The initial deployment supports durable text-only Managed Sessions. Runtime
Broker, Workspace files and Shell remain disabled. This avoids treating a
container-local directory as a registered physical Workspace or assuming that
workers survive Pod replacement. It does not change K2 admission or recovery.
## Configuration and credentials
Require explicit server and Harness image references and an explicit model ID
and base URL. The operator builds both images from the same revision; no
unpublished registry image is assumed. Install the model settings through
`QWEN_HOME/settings.json`; credentials enter the Harness through a Secret-backed
environment variable, rather than command arguments or the ConfigMap.
On initial installation, the chart generates independent random credentials.
Subsequent installations and upgrades reuse the retained Secret through Helm
`lookup`. An upgrade without the managed Secret must fail rather than silently
rotate keys against existing database data. A reinstall with a retained database
PVC but no Secret also refuses. The Secret is retained on uninstall,
as is the StatefulSet volume. An operator may instead provide an existing Secret
with the same keys. Updating an existing model API key is an explicit Secret
rotation followed by a Pod restart; an ordinary Helm upgrade does not rotate it.
The bundled MySQL uses an application user, not root, for Java connections. Its
initialization grant includes SELECT on
`performance_schema.user_variables_by_thread`, which Flyway requires at boot.
Storage size/class, image pull secrets and container resources are deployment
inputs. There is no external-database abstraction, Ingress controller, chart
dependency, automatic Workspace registration or cluster-wide RBAC installation.
## Startup, persistence and upgrades
The application uses one replica and the Recreate strategy. Java startup and
liveness check application liveness; readiness also checks the database. Harness
exec probes run Node fetch against the authenticated loopback health route.
Database readiness checks the application credentials and Flyway grant. A database
outage must remove readiness without making Java liveness fail.
MySQL is the durable authority for Sessions and journals. Harness home, temporary
files and the text-only working directory use writable emptyDir volumes. Application
containers run as non-root with read-only roots and no added capabilities. The
official MySQL entry point retains its own initialization and ownership behavior.
Before upgrading, stop new admissions, settle active work and back up the database
and credentials. Keep the release name, namespace, volume and keys stable. Do not
use automatic Helm rollback across a schema migration. A failed upgrade requires
a forward fix or restoration of the matching database and application backup.
Current pre-release builds may change the canonical V1 baseline in place;
incompatible development catalogs require a fresh database and must remain with
their matching application for historical access.
Idle restart validation is distinct from arbitrary in-flight recovery or HA.
## Affected files
Add the chart, deployment instructions in both languages, render/contract tests
and a signed API smoke client under `deploy/kubernetes`. Link the new entry point
from both reference-deployment documents. Application code, APIs, authentication,
database schema and K2 runtime behavior are unchanged.
## Validation and acceptance
1. Helm lint and rendering must produce valid objects, including custom release
and namespace values, image pull secrets and storage class configuration.
2. Missing required deployment inputs and an upgrade missing its credentials must
fail before resource mutation. Existing-Secret installations must not generate
another Secret. No credential belongs in ConfigMaps or command arguments.
3. Verify distinct signing/store keys, matching Harness token/digest references,
private listener ownership, non-root application containers, MySQL grant and
probe semantics from rendered objects.
4. Verify the signed smoke client against real Java/Harness processes and a
controlled model when available: reject unsigned requests, create a Session,
complete a text Turn, read history and close. A controlled model is not
real-provider acceptance.
5. On a disposable real cluster, install, execute the smoke, retain an idle Session,
recreate the application Pod and verify history plus a new Turn. Verify a Helm
upgrade preserves credentials and database bytes. Record real-cluster, MySQL,
image-build and provider evidence independently.
Local rendering does not establish cluster, CSI, ACK, production or fault-matrix
acceptance. Those checks stay pending when the required environment is absent.
## Open questions
No implementation decision is blocked. Published images, an authenticated
WebShell gateway, external database integration and qualified K2 provisioning can
be separate follow-ups once this deployment baseline is exercised on a cluster.
@@ -0,0 +1,92 @@
# Managed Agent Kubernetes 启动部署
[English](2026-10-24-managed-agent-kubernetes.md) | [简体中文](2026-10-24-managed-agent-kubernetes.zh-CN.md)
## 问题与现状
Java 控制面和 Qwen CLI 已有容器构建定义,但缺少完整的 Kubernetes 安装方案。
参考分发产物面向单台可信 Linux 主机。已有 K2 常驻 Workspace 配置描述已注册的
CSI 执行,而不负责安装应用。
## 部署方案
在 `deploy/kubernetes` 增加无外部依赖的 Helm chart。从同一 revision 构建并推送
Java 和 CLI 镜像后,一条 `helm upgrade --install` 安装以下资源:
| 资源 | 职责 |
| ----------------------------------------- | ------------------------------------------------------------ |
| 单副本 Deployment | 在同一 Pod 运行 Java 控制面和 Hosted Harness |
| ClusterIP Service | 在 8080 端口提供签名认证的 Java 公开 API |
| MySQL 8.4 StatefulSet 和 headless Service | 使用持久卷的应用数据库 |
| ConfigMap | 显式的 OpenAI 兼容模型设置和 MySQL 初始化授权 |
| Secret | 稳定的签名、Session Store、Harness、数据库凭据和模型 API key |
Harness 必须绑定 loopback,健康请求也要求 bearer 认证。同 Pod 部署保留该契约。
Java 独立内部监听在 loopback 8081 端口提供 Session Store。两个私有端口都不通过
Service 暴露。公开监听使用签名认证,应用与 Session Store 使用不同密钥。
应用不需要访问 Kubernetes API,也不需要 service-account token。
首次部署支持持久化的纯文本 Managed Session。Runtime Broker、Workspace files
和 Shell 保持关闭,避免将容器本地目录视为已注册的物理 Workspace,或假设 worker
能够在 Pod 替换后存活。不修改 K2 准入与恢复机制。
## 配置与凭据
要求显式提供 server、Harness 镜像,以及模型 ID 和 base URL。操作者从同一 revision
构建两个镜像,不假设存在尚未发布的镜像地址。通过 `QWEN_HOME/settings.json`
安装模型设置;凭据通过 Secret 环境变量传入 Harness,不进入命令参数或 ConfigMap。
首次安装由 chart 生成相互独立的随机凭据。后续安装和升级通过 Helm `lookup`
复用保留的 Secret。升级时缺少受管 Secret 必须失败,不能对已有数据库静默轮换密钥。
保留数据库 PVC 却缺少 Secret 时,重新安装也会拒绝。卸载时保留 Secret 和
StatefulSet 卷。操作者也可提供包含相同键的现有 Secret。
更新已有模型 API key 必须显式轮换 Secret 并重启 Pod;普通 Helm 升级不轮换它。
内置 MySQL 使用应用用户供 Java 连接,不使用 root。初始化授权包含 Flyway 启动所需的
`performance_schema.user_variables_by_thread` SELECT 权限。存储大小与 class、
镜像拉取凭据和容器资源是部署输入。不引入外部数据库抽象、Ingress 控制器、chart
依赖、自动 Workspace 注册或集群级 RBAC 安装。
## 启动、持久化与升级
应用固定单副本并使用 Recreate 策略。Java startup 与 liveness 检查应用存活状态;
readiness 额外检查数据库。Harness exec 探针通过 Node fetch 访问带认证的 loopback
健康接口。数据库 readiness 核验应用凭据和 Flyway 授权。数据库故障必须撤下 readiness,
不能使 Java liveness 失败。
MySQL 是 Session 和 journal 的持久化权威。Harness home、临时文件和纯文本工作目录
使用可写 emptyDir。应用容器以非 root 用户运行,根文件系统只读,不增加 capabilities。
MySQL 官方入口保留其既有初始化和权限处理方式。
升级前停止新准入、结算活动工作并备份数据库和凭据。保持 release 名、namespace、卷和
密钥稳定。数据库 migration 后不能自动 Helm rollback。升级失败需向前修复,或恢复
相匹配的数据库与应用备份。当前预发布版本可能原地更新 canonical V1 baseline;
不兼容的开发 catalog 需要新数据库,历史访问必须保留其匹配的应用版本。
空闲重启验证不等于任意在途恢复或 HA 验收。
## 影响文件
在 `deploy/kubernetes` 增加 chart、双语部署说明、渲染与契约测试及签名 API smoke
客户端。两个参考部署文档都添加新入口链接。应用代码、API、认证、数据库 schema 和
K2 runtime 行为保持不变。
## 验证与验收
1. Helm lint 与渲染必须生成有效对象,覆盖自定义 release、namespace、镜像拉取凭据
和 storage class。
2. 缺少必要部署输入、升级时缺少凭据必须在修改资源前失败。使用已有 Secret 时不能再
生成 Secret。凭据不得进入 ConfigMap 或命令参数。
3. 从渲染对象验证签名与 Store 密钥分离、Harness token/digest 引用一致、私有监听归属、
应用非 root 运行、MySQL 授权与探针语义。
4. 条件允许时,用真实 Java/Harness 进程和受控模型验证签名 smoke 客户端:拒绝无签名
请求、创建 Session、完成文本 Turn、读取历史并关闭。受控模型不等于真实 provider 验收。
5. 在可丢弃的真实集群安装并执行 smoke,保留空闲 Session,重建应用 Pod,验证历史和
新 Turn。验证 Helm 升级保持凭据和数据库内容。分别记录真实集群、MySQL、镜像构建和
provider 证据。
本地渲染不代表集群、CSI、ACK、生产或故障矩阵验收。环境缺失时,相应检查继续待完成。
## 开放问题
没有阻塞实现的决策。发布镜像、带认证的 WebShell 网关、外部数据库集成和经过资格验证的
K2 provisioning,可在本部署基线得到真实集群验证后分别跟进。
@@ -0,0 +1,107 @@
# Managed Agent Kubernetes Workspace tools and WebShell
[English](2026-10-25-managed-agent-kubernetes-tools.md) | [简体中文](2026-10-25-managed-agent-kubernetes-tools.zh-CN.md)
## Problem and current state
The [bootstrap chart](2026-10-24-managed-agent-kubernetes.md) deploys text-only
Managed Sessions. Files and Shell need a registered physical Workspace and a
Runtime owner. A browser also needs an authenticated gateway: the Java API uses
signed requests and must not disclose its signing key to browser code.
K2 resident CSI currently refuses Shell. A kind hostpath volume cannot satisfy
its Aliyun CSI disk identity or RWOP contract. This increment instead adds an
explicit, trusted single-node LOCAL Runtime option and a separate WebShell
gateway. It does not alter the K2 admission or recovery contract.
## Proposed changes
Keep the default text-only deployment. An opt-in LOCAL mode requires a fixed
trusted node, a Workspace PVC, and a Runtime state PVC. The server image combines
JRE 21, Node 22, the same revision of the bundled CLI, and Linux filesystem tools.
Mount the selected node's real machine identity read-only. Never manufacture a
machine ID to pass admission. The existing Workspace creation API prepares the
root, registers physical storage, and grants the fixed caller OWNER access.
The legacy cwd `/workspace` uses the first 16 SHA-256 hex characters of its
canonical path as its Workspace ID in both modes, matching Runtime Broker
admission and keeping that identity stable when tools are enabled.
Workspace creation roots, legacy cwd, private Runtime state, and Runtime home
must be separate. Runtime records retain exact owner-private permissions; a
private child directory, rather than a PVC root, holds state. Enable durable
LOCAL Session isolation, verified recovery, files, and foreground Shell with
default approval. Leave publication and child agents disabled. Background Shell,
Monitor, command Hooks, and delegated cgroup support are outside this increment.
Add `qwen serve --managed-web-config <file>` as a dedicated gateway runner.
Configuration contains only `apiUrl`, `tenantId`, `actorId`, and `signingKeyFile`.
The upstream must be a loopback HTTP origin. Read the signing key as exact UTF-8
bytes and require an independent browser bearer token. Reuse the current signing
proxy, static assets, and Host/Origin/authentication middleware. Expose only the
Managed proxy, artifact content, boot data, authentication probe, and static UI;
all ordinary daemon routes, ACP, and WebSocket entry points remain unavailable.
Do not initialize a daemon Runtime or broaden the existing local runner.
The gateway uses the existing Managed-only `ManagedAgentWebShell` UI, without
daemon Workspace/Session contexts. A same-Pod web container receives only its
browser token and signing key. Java remains loopback-reachable from the gateway;
the gateway's ClusterIP Service publishes the browser port. Tenant and actor are
fixed deployment inputs, never supplied by browser headers. This is one trusted
operator identity, not a multi-user identity provider. HTTP port-forward is the
local entry point; TLS ingress needs the existing explicit allowed origin.
## Persistence and lifecycle
SQL, credentials, Workspace data, and private Runtime records persist. LOCAL
workers belong to the original host and PID/time namespaces. A replacement Pod
cannot adopt an old live worker by assuming that a stable node or PVC is enough.
Close every tool Session and confirm physical holders are clear before a planned
rollout. After replacement, create a new Session against the retained Workspace.
Interrupted effects retain their original uncertainty and must not be replayed
or falsely marked closed. This is not HA or cross-Pod live Session recovery.
Generated optional credentials are independent and retained across upgrades.
Enabling an optional component may add its missing keys; it must never rotate
existing database, signing, Store, or Harness keys. External Secrets must supply
every enabled component's required keys. No provider key belongs in browser
configuration, ConfigMaps, command arguments, or logs.
## Affected components
Update the Helm chart, deployment images, bilingual instructions, and smoke
clients under `deploy/kubernetes`. Add the gateway command/runner and focused
CLI tests, and a Managed-only browser boot branch with focused UI tests. Reuse
existing Java Workspace creation and Runtime provisioning APIs without adding a
database schema or public API change.
## Validation and acceptance
1. Build matching Linux arm64 images and install the default chart on isolated
kind. Verify migration, unsigned rejection, a completed controlled-model
Turn, history, idle restart, credential-preserving upgrade, and retained
database/Secret behavior. Record image and cluster revisions.
2. Enable LOCAL mode, create a Workspace through the signed API, verify its
physical registration and OWNER grant, and have the model write/read/edit a
file. Check exact PVC bytes independently of model output.
3. Request a foreground Shell test command, observe approval, approve it, and
verify its result and one execution. Verify denial/cancellation and repeated
request handling. Without delegated cgroups, background work must not start.
4. Close tool Sessions and verify workers, receipts, journals, and holders;
replace the Pod and read retained files from a new Session. Faulted workers
must not trigger replay or fabricated cleanup.
5. Reject missing/equal browser and signing credentials. With a valid browser
token, reject ordinary daemon file, Workspace/trust, Session/Shell, settings,
MCP, ACP, and WebSocket routes, including HTML Accept requests. A browser
must authenticate, create/select a Workspace, change a project file, approve
a test command, and display the result through Managed APIs only.
Controlled-model kind checks establish local Linux/container behavior. They do
not establish a real provider, ACK/CSI, multi-node isolation, arbitrary in-flight
recovery, or production acceptance. Required CI checks remain separate gates.
## Open questions
No implementation decision requires user input. A qualified CSI execution lane,
live worker recovery across Pod replacement, public images, and multi-user SSO
remain follow-up work.
@@ -0,0 +1,88 @@
# Managed Agent Kubernetes Workspace 工具与 WebShell
[English](2026-10-25-managed-agent-kubernetes-tools.md) | [简体中文](2026-10-25-managed-agent-kubernetes-tools.zh-CN.md)
## 问题与现状
[基础 Chart](2026-10-24-managed-agent-kubernetes.zh-CN.md) 部署的是纯文本
Managed Session。文件和 Shell 需要注册过的物理 Workspace 与 Runtime owner。
浏览器还需要认证网关:Java API 使用签名请求,不能把签名密钥交给浏览器代码。
K2 resident CSI 当前拒绝 Shell,kind 的 hostpath 卷也不满足其阿里云 CSI 磁盘身份
和 RWOP 契约。本次增加显式启用的可信单节点 LOCAL Runtime 和独立 WebShell
网关,不修改 K2 的准入或恢复契约。
## 拟议变更
默认保留纯文本部署。启用 LOCAL 模式必须指定固定可信节点、Workspace PVC 和
Runtime state PVC。服务端镜像组合 JRE 21、Node 22、同 revision 的 bundled CLI
及 Linux 文件系统工具。只读挂入所选节点的真实 machine identity,禁止制造
machine ID 来绕过准入。复用 Workspace creation API 准备目录、注册物理存储,
并授予固定调用者 OWNER 权限。
两种模式的 legacy cwd `/workspace` 都使用其规范路径 SHA-256 十六进制摘要的前
16 位作为 Workspace ID,满足 Runtime Broker 准入,并在启用工具时保持该身份稳定。
Workspace creation root、legacy cwd、私有 Runtime state 和 Runtime home 必须
彼此分离。Runtime 记录保持精确的 owner-private 权限;state 放在卷内私有子目录,
不直接使用 PVC 根目录。开启 durable LOCAL Session isolation、verified recovery、
文件和默认审批的前台 Shell。关闭 publication 和 child agents。本次不实现后台
Shell、Monitor、command Hooks 或 cgroup 委派。
新增 `qwen serve --managed-web-config <file>`,进入独立网关 runner。配置仅包含
`apiUrl`、`tenantId`、`actorId`、`signingKeyFile`。上游必须是 loopback HTTP
origin,签名密钥按精确 UTF-8 字节读取,并要求独立的浏览器 bearer token。复用
当前签名代理、静态资源与 Host/Origin/认证中间件,仅暴露 Managed 代理、artifact
内容、boot 数据、认证探针及静态 UI;普通 daemon 路由、ACP 和 WebSocket 入口
全部不可用。网关不初始化 daemon Runtime,也不放宽现有本地 runner。
网关使用已有的 Managed-only `ManagedAgentWebShell` UI,不创建 daemon
Workspace/Session context。同 Pod 的 web 容器仅获得浏览器 token 和签名密钥。
网关通过 loopback 访问 Java,以独立 ClusterIP Service 发布浏览器端口。tenant
和 actor 是固定部署参数,浏览器 header 不可选择身份。这是单一可信操作者身份,
不承担多用户身份提供方职责。本地通过 HTTP port-forward 进入;TLS ingress 需要
使用已有的显式 allowed origin。
## 持久化与生命周期
持久化 SQL、密钥、Workspace 数据及私有 Runtime 记录。LOCAL worker 属于原始
主机和 PID/time namespace。替换后的 Pod 不能仅凭节点或 PVC 相同就接管旧的
存活 worker。计划 rollout 前必须关闭全部工具 Session,确认物理 holder 已清空。
替换后通过新 Session 复用保留的 Workspace。中断 effect 保持原有不确定性,禁止
重放或伪造关闭结果。这不提供 HA 或跨 Pod 的存活 Session 恢复。
可选组件的自动凭据彼此独立,并在升级时保留。首次启用可选组件时允许补充缺失
键,但禁止旋转已有数据库、签名、Store 和 Harness 密钥。外部 Secret 必须提供
全部已启用组件需要的键。provider key 不进入浏览器配置、ConfigMap、命令参数
或日志。
## 涉及组件
更新 `deploy/kubernetes` 下的 Helm Chart、部署镜像、中英文说明和 smoke 客户端。
增加网关命令、runner、定向 CLI 测试以及 Managed-only 浏览器启动分支和定向 UI
测试。复用现有 Java Workspace creation 与 Runtime provisioning API,不增加
数据库 schema 或公共 API 变更。
## 验证与验收
1. 构建匹配的 Linux arm64 镜像,在隔离 kind 中安装默认 Chart。验证数据库迁移、
未签名拒绝、受控模型完成 Turn、历史、空闲重启、保留密钥的升级和数据库/Secret
保留行为,并记录镜像和集群 revision。
2. 开启 LOCAL 模式,通过签名 API 创建 Workspace,检查物理注册和 OWNER grant,
由模型写入、读取、编辑文件。独立核对 PVC 精确字节,不能仅依赖模型回复。
3. 请求前台 Shell 测试命令,观察审批、批准并验证结果及只执行一次;验证拒绝、
取消和重复请求处理。没有委派 cgroup 时,后台任务不能启动。
4. 关闭工具 Session,核验 worker、receipt、journal 和 holder;替换 Pod 后用新
Session 读取原文件。worker 故障不能触发重放或虚构清理成功。
5. 拒绝缺失或相同的浏览器/签名凭据。有效浏览器 token 访问普通 daemon 的文件、
Workspace/trust、Session/Shell、settings、MCP、ACP 和 WebSocket 路由也必须
拒绝,包括带 HTML Accept 的请求。浏览器应能认证、创建/选择 Workspace、修改
项目文件、批准测试命令,并仅通过 Managed API 展示结果。
受控模型的 kind 验证仅证明本地 Linux/容器行为,不证明真实 provider、ACK/CSI、
多节点隔离、任意执行中恢复或生产验收。必需 CI 检查是独立门槛。
## 待定问题
没有需要用户决策的实现问题。合格的 CSI 执行路径、跨 Pod 替换的存活 worker 恢复、
公共镜像和多用户 SSO 留作后续工作。
@@ -4,6 +4,11 @@
The Managed Agent reference distribution is experimental. Its accepted initial topology is one Java control plane and its embedded Runtime Broker on one trusted Linux host, one MySQL database, and one compatible Qwen Hosted Harness. The compatibility manifest in each release is authoritative for that release. The Managed Agent reference distribution is experimental. Its accepted initial topology is one Java control plane and its embedded Runtime Broker on one trusted Linux host, one MySQL database, and one compatible Qwen Hosted Harness. The compatibility manifest in each release is authoritative for that release.
For a source-based Kubernetes installation with optional trusted-node tools and
WebShell, see the separate
[Helm deployment guide](https://github.com/QwenLM/qwen-code/tree/main/deploy/kubernetes).
That chart does not extend this reference distribution's acceptance scope.
## Verify and extract ## Verify and extract
Download the archive, its detached manifest, and `SHA256SUMS.txt` from the same GitHub prerelease. Verify them before extraction: Download the archive, its detached manifest, and `SHA256SUMS.txt` from the same GitHub prerelease. Verify them before extraction:
@@ -4,6 +4,10 @@
Managed Agent 参考分发产物仍处于实验阶段。首个已接受拓扑为:一台可信 Linux 主机上的单 Java 控制面及其嵌入式 Runtime Broker、一个 MySQL 数据库,以及一个兼容的 Qwen Hosted Harness。每个发布版本中的兼容性 manifest 是该版本的权威依据。 Managed Agent 参考分发产物仍处于实验阶段。首个已接受拓扑为:一台可信 Linux 主机上的单 Java 控制面及其嵌入式 Runtime Broker、一个 MySQL 数据库,以及一个兼容的 Qwen Hosted Harness。每个发布版本中的兼容性 manifest 是该版本的权威依据。
从源码安装 Kubernetes 部署(可选可信单节点工具和 WebShell)时,参见独立的
[Helm 部署说明](https://github.com/QwenLM/qwen-code/tree/main/deploy/kubernetes)。
该 chart 不扩大本参考分发产物的验收范围。
## 校验并解压 ## 校验并解压
从同一个 GitHub prerelease 下载压缩包、独立 manifest 和 `SHA256SUMS.txt`,解压前完成校验: 从同一个 GitHub prerelease 下载压缩包、独立 manifest 和 `SHA256SUMS.txt`,解压前完成校验:
+67
View File
@@ -14,11 +14,13 @@ import {
maybeOpenWebShellBrowser, maybeOpenWebShellBrowser,
serveCommand, serveCommand,
validateManagedLocalFlags, validateManagedLocalFlags,
validateManagedWebFlags,
} from './serve.js'; } from './serve.js';
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn()); const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true)); const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));
const mockRunQwenServe = vi.hoisted(() => vi.fn()); const mockRunQwenServe = vi.hoisted(() => vi.fn());
const mockRunManagedWebServe = vi.hoisted(() => vi.fn());
const mockApplyOpenWithAuth = vi.hoisted(() => const mockApplyOpenWithAuth = vi.hoisted(() =>
vi.fn((options: { token?: string }) => { vi.fn((options: { token?: string }) => {
options.token ??= 'generated-token'; options.token ??= 'generated-token';
@@ -47,6 +49,9 @@ vi.mock('qrcode-terminal', () => ({ default: mockQr }));
vi.mock('../serve/run-qwen-serve.js', () => ({ vi.mock('../serve/run-qwen-serve.js', () => ({
runQwenServe: mockRunQwenServe, runQwenServe: mockRunQwenServe,
})); }));
vi.mock('../serve/managed-web-serve.js', () => ({
runManagedWebServe: mockRunManagedWebServe,
}));
vi.mock('../serve/open-with-auth.js', () => ({ vi.mock('../serve/open-with-auth.js', () => ({
applyOpenWithAuth: mockApplyOpenWithAuth, applyOpenWithAuth: mockApplyOpenWithAuth,
})); }));
@@ -58,6 +63,68 @@ function buildParser(): Argv {
} }
describe('serve command args', () => { describe('serve command args', () => {
it('parses and isolates the Managed Web gateway deployment options', () => {
const parsed = buildParser().parseSync(
'--managed-web-config /etc/qwen-web/gateway.json --hostname 0.0.0.0 --port 4171 --allow-origin https://managed.example',
);
expect(parsed['managed-web-config']).toBe('/etc/qwen-web/gateway.json');
expect(parsed['allow-origin']).toEqual(['https://managed.example']);
expect(() => buildParser().parseSync('--managed-web-config')).toThrow(
/Not enough arguments/u,
);
expect(() =>
validateManagedWebFlags([
'--managed-web-config=x',
'--hostname',
'0.0.0.0',
'--require-auth',
]),
).not.toThrow();
for (const flag of [
'--managed',
'--workspace=/run/secrets',
'--profile=hosted-harness',
'--enable-session-shell',
'--channel=telegram',
'--no-require-auth',
'--open',
]) {
expect(() =>
validateManagedWebFlags(['--managed-web-config=x', flag]),
).toThrow('not supported with --managed-web-config');
}
});
it('dispatches the gateway without starting the ordinary daemon', async () => {
mockRunManagedWebServe.mockResolvedValueOnce({
url: 'http://0.0.0.0:4171',
finished: new Promise(() => {}),
});
mockRunQwenServe.mockClear();
const argv = buildParser().parseSync(
'--managed-web-config /etc/qwen-web/gateway.json --hostname 0.0.0.0 --port 4171',
);
void serveCommand.handler!(
argv as Parameters<NonNullable<typeof serveCommand.handler>>[0],
);
await vi.waitFor(() => expect(mockRunManagedWebServe).toHaveBeenCalled());
expect(mockRunManagedWebServe).toHaveBeenLastCalledWith({
configFile: '/etc/qwen-web/gateway.json',
hostname: '0.0.0.0',
port: 4171,
token: undefined,
allowOrigins: undefined,
});
expect(mockRunQwenServe).not.toHaveBeenCalled();
});
it('keeps a dash-leading numeric gateway token as an option value', () => {
const token = '-1234567890123456789012345678901234567890';
const args = ['--managed-web-config=x', '--token', token];
expect(buildParser().parseSync(args)['token']).toBe(token);
expect(() => validateManagedWebFlags(args)).not.toThrow();
});
it('refuses flags whose ordinary-daemon behavior is unavailable in Managed mode', () => { it('refuses flags whose ordinary-daemon behavior is unavailable in Managed mode', () => {
expect(() => expect(() =>
validateManagedLocalFlags(['--managed', '--port=0', '--no-open']), validateManagedLocalFlags(['--managed', '--port=0', '--no-open']),
+60
View File
@@ -80,6 +80,25 @@ export function validateManagedLocalFlags(args: readonly string[]): void {
} }
} }
export function validateManagedWebFlags(args: readonly string[]): void {
const supported = new Set([
'--managed-web-config',
'--hostname',
'--port',
'--allow-origin',
'--token',
'--require-auth',
]);
const unsupported = args.find(
(arg) => arg.startsWith('--') && !supported.has(arg.split('=')[0]!),
);
if (unsupported) {
throw new Error(
`${unsupported.split('=')[0]} is not supported with --managed-web-config. Only hostname, port, allow-origin and browser authentication options are supported.`,
);
}
}
/** /**
* Turn Local Control on through the daemon and print the pairing QR. * Turn Local Control on through the daemon and print the pairing QR.
* *
@@ -223,6 +242,7 @@ interface ServeArgs {
profile: 'default' | 'hosted-harness'; profile: 'default' | 'hosted-harness';
managed: boolean; managed: boolean;
'managed-data-dir'?: string; 'managed-data-dir'?: string;
'managed-web-config'?: string;
'hosted-harness-capability-digest'?: string; 'hosted-harness-capability-digest'?: string;
token?: string; token?: string;
'max-sessions'?: number; 'max-sessions'?: number;
@@ -338,6 +358,12 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
description: description:
'Private persistent data directory for --managed; defaults to <Qwen home>/managed-local.', 'Private persistent data directory for --managed; defaults to <Qwen home>/managed-local.',
}) })
.option('managed-web-config', {
type: 'string',
requiresArg: true,
description:
'Run only the authenticated Managed Web gateway using a JSON file with apiUrl, tenantId, actorId and signingKeyFile. Requires an independent browser token of at least 32 bytes via QWEN_SERVER_TOKEN or --token.',
})
.option('token', { .option('token', {
type: 'string', type: 'string',
description: description:
@@ -846,6 +872,40 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
'Requires --rate-limit.', 'Requires --rate-limit.',
}) as unknown as Argv<ServeArgs>, }) as unknown as Argv<ServeArgs>,
handler: async (argv) => { handler: async (argv) => {
if (argv['managed-web-config'] !== undefined) {
try {
const serveIndex = process.argv.indexOf('serve');
if (serveIndex >= 0)
validateManagedWebFlags(process.argv.slice(serveIndex + 1));
if (
argv.managed ||
argv.profile !== 'default' ||
!argv.web ||
argv.workspace !== undefined
) {
throw new Error(
'--managed-web-config cannot be combined with daemon execution or Managed local mode.',
);
}
const { runManagedWebServe } = await import(
'../serve/managed-web-serve.js'
);
const handle = await runManagedWebServe({
configFile: argv['managed-web-config'],
hostname: argv.hostname,
port: argv.port,
token: argv.token,
allowOrigins: argv['allow-origin'],
});
writeStdoutLine(`Managed WebShell: ${handle.url}`);
process.exit(await handle.finished);
} catch (error) {
writeStderrLine(
`qwen serve: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
if (argv.managed) { if (argv.managed) {
try { try {
const serveIndex = process.argv.indexOf('serve'); const serveIndex = process.argv.indexOf('serve');
+5
View File
@@ -679,6 +679,10 @@ describe('serve fast path argument parsing', () => {
const sampleArgvByOption = new Map<string, string[]>([ const sampleArgvByOption = new Map<string, string[]>([
['managed', ['--managed']], ['managed', ['--managed']],
['managed-data-dir', ['--managed-data-dir', '/tmp/qwen-managed-data']], ['managed-data-dir', ['--managed-data-dir', '/tmp/qwen-managed-data']],
[
'managed-web-config',
['--managed-web-config', '/etc/qwen-web/gateway.json'],
],
['port', ['--port', '0']], ['port', ['--port', '0']],
['hostname', ['--hostname', '127.0.0.1']], ['hostname', ['--hostname', '127.0.0.1']],
['token', ['--token', 'token']], ['token', ['--token', 'token']],
@@ -802,6 +806,7 @@ describe('serve fast path argument parsing', () => {
'channel', 'channel',
'managed', 'managed',
'managed-data-dir', 'managed-data-dir',
'managed-web-config',
'profile', 'profile',
'hosted-harness-capability-digest', 'hosted-harness-capability-digest',
'experimental-paired-engines', 'experimental-paired-engines',
@@ -17,6 +17,7 @@ export interface ManagedLocalWebOptions {
actorId: string; actorId: string;
productScope: string; productScope: string;
agentId: string; agentId: string;
managedOnly?: boolean;
modelPresets?: ReadonlyArray<{ modelPresets?: ReadonlyArray<{
agentId: string; agentId: string;
label: string; label: string;
@@ -94,6 +95,7 @@ export function mountManagedLocalWeb(
agentId: options.agentId, agentId: options.agentId,
enableWorkspaceBinding: true, enableWorkspaceBinding: true,
modelPresets: options.modelPresets, modelPresets: options.modelPresets,
...(options.managedOnly ? { managedOnly: true } : {}),
}); });
}); });
app.use( app.use(
@@ -0,0 +1,307 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHmac, createHash } from 'node:crypto';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { createServer, request } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runManagedWebServe } from './managed-web-serve.js';
const browserToken = 'browser-token-with-thirty-two-independent-bytes';
const signingKey = 'operator-signing-key-with-thirty-two-bytes\n ';
describe('dedicated Managed Web gateway', () => {
let directory: string;
let configFile: string;
let keyFile: string;
let apiUrl: string;
let peer: ReturnType<typeof createServer>;
let gateway: Awaited<ReturnType<typeof runManagedWebServe>> | undefined;
let handler: (req: IncomingMessage, res: ServerResponse) => void;
const calls: Array<{
url?: string;
headers: IncomingMessage['headers'];
body: Buffer;
}> = [];
beforeEach(async () => {
vi.stubEnv('QWEN_SERVER_TOKEN', undefined);
calls.length = 0;
directory = await mkdtemp(join(tmpdir(), 'qwen-managed-gateway-'));
await mkdir(join(directory, 'assets'));
await writeFile(
join(directory, 'index.html'),
'<html><head></head><body>WebShell</body></html>',
);
await writeFile(join(directory, 'assets', 'app-12345678.js'), 'app;');
keyFile = join(directory, 'operator-signing-key');
await writeFile(keyFile, signingKey, { mode: 0o600 });
handler = (_req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end('{"ok":true}');
};
peer = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
calls.push({
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks),
});
handler(req, res);
});
await new Promise<void>((resolve) => peer.listen(0, '127.0.0.1', resolve));
const address = peer.address();
if (!address || typeof address === 'string')
throw new Error('Missing peer port');
apiUrl = `http://127.0.0.1:${address.port}`;
configFile = join(directory, 'gateway.json');
await saveConfig();
});
afterEach(async () => {
await gateway?.close();
gateway = undefined;
peer.closeAllConnections();
await new Promise<void>((resolve) => peer.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
vi.unstubAllEnvs();
});
function saveConfig(extra: Record<string, unknown> = {}) {
return writeFile(
configFile,
JSON.stringify({
apiUrl,
tenantId: 'tenant-a',
actorId: 'operator-a',
signingKeyFile: keyFile,
...extra,
}),
);
}
async function start(
options: Partial<Parameters<typeof runManagedWebServe>[0]> = {},
) {
gateway = await runManagedWebServe(
{
configFile,
hostname: '127.0.0.1',
port: 0,
token: browserToken,
...options,
},
{ webShellDir: directory, installSignalHandlers: false },
);
return gateway;
}
function get(path: string, options: RequestInit = {}) {
return fetch(`${gateway!.url}${path}`, {
...options,
headers: { Authorization: `Bearer ${browserToken}`, ...options.headers },
});
}
it('serves public assets and authenticated Managed-only boot without credentials', async () => {
await start();
const html = await (await fetch(`${gateway!.url}/`)).text();
expect(html).toContain('<meta name="qwen-managed-app" content="true">');
expect(html).not.toContain(browserToken);
expect(html).not.toContain(signingKey);
expect(
await (await fetch(`${gateway!.url}/assets/app-12345678.js`)).text(),
).toBe('app;');
for (const path of ['/managed/boot', '/capabilities', '/health']) {
expect((await fetch(`${gateway!.url}${path}`)).status).toBe(401);
expect(
(await get(path, { headers: { Authorization: 'Bearer wrong' } }))
.status,
).toBe(401);
}
const boot = await (await get('/managed/boot')).json();
expect(boot).toMatchObject({
mode: 'managed',
managedOnly: true,
agentId: 'qwen-code',
enableWorkspaceBinding: true,
});
expect(JSON.stringify(boot)).not.toContain(keyFile);
expect(JSON.stringify(boot)).not.toContain(signingKey);
expect(await (await get('/capabilities')).json()).toEqual({
mode: 'managed',
});
expect((await get('/health')).ok).toBe(true);
expect(calls).toHaveLength(0);
});
it('rejects daemon routes even with a valid browser token and HTML Accept', async () => {
await start();
for (const accept of ['application/json', 'text/html']) {
for (const [method, path] of [
['GET', '/file?path=operator-signing-key'],
['GET', '/file/bytes?path=operator-signing-key'],
['POST', '/workspaces'],
['POST', '/workspaces/key-dir/trust/grant'],
['GET', '/workspaces/key-dir/file?path=operator-signing-key'],
['POST', '/session'],
['GET', '/session/id'],
['POST', '/session/id/shell'],
['POST', '/workspace/settings'],
['POST', '/workspace/mcp/initialize'],
['POST', '/workspace/mcp/server/restart'],
['POST', '/desktop-relay/credential'],
['GET', '/acp'],
['GET', '/internal/managed-session-store/v1/read'],
['GET', '/v1/agents/sessions'],
]) {
const response = await get(path!, {
method,
headers: { Accept: accept },
});
expect(response.status, `${method} ${path} ${accept}`).toBe(404);
expect(response.headers.get('Content-Type')).toContain(
'application/json',
);
}
}
expect(calls).toHaveLength(0);
});
it('signs exact bytes with the fixed principal and preserves artifact range', async () => {
await start();
const path = '/api/agent/web-shell/v1/sessions/create?q=a%2Fb+c';
const body = ' {"input":"汉字"}\n';
const response = await get(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': 'one-command',
'X-Qwen-Tenant-Id': 'attacker',
'X-Qwen-Actor-Id': 'administrator',
'X-Qwen-Writer-Token': 'private-value',
Origin: gateway!.url,
},
body,
});
expect(response.status).toBe(200);
const call = calls[0]!;
expect(call.body.toString()).toBe(body);
expect(call.headers['x-qwen-tenant-id']).toBe('tenant-a');
expect(call.headers['x-qwen-actor-id']).toBe('operator-a');
expect(call.headers.authorization).toBeUndefined();
expect(call.headers['x-qwen-writer-token']).toBeUndefined();
const canonical = `qwen-broker-auth-v1\nPOST\n/api/agent/web-shell/v1/sessions/create\nq=a%2Fb+c\ntenant-a\noperator-a\n${call.headers['x-qwen-signature-timestamp']}\n${createHash('sha256').update(body).digest('hex')}\none-command`;
expect(call.headers['x-qwen-signature']).toBe(
`v1=${createHmac('sha256', signingKey).update(canonical).digest('hex')}`,
);
expect(
(
await get('/v1/agents/sessions/s1/artifacts/a1/content', {
headers: { Range: 'bytes=0-2', 'If-Match': '"revision"' },
})
).status,
).toBe(200);
expect(calls[1]?.headers.range).toBe('bytes=0-2');
expect(calls[1]?.headers['if-match']).toBe('"revision"');
});
it('streams SSE without buffering and closes upstream on browser disconnect', async () => {
let disconnected = false;
handler = (_req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.write('data: {"ready":true}\n\n');
res.once('close', () => {
disconnected = true;
});
};
await start();
const controller = new AbortController();
const response = await get('/api/agent/web-shell/v1/events/stream', {
method: 'POST',
body: '{}',
signal: controller.signal,
});
const reader = response.body!.getReader();
expect(new TextDecoder().decode((await reader.read()).value)).toContain(
'ready',
);
controller.abort();
await reader.cancel().catch(() => {});
await vi.waitFor(() => expect(disconnected).toBe(true));
});
it('supports authenticated non-loopback HTTP and an explicit TLS ingress origin', async () => {
await start({
hostname: '0.0.0.0',
allowOrigins: ['https://managed.example'],
});
const url = gateway!.url.replace('0.0.0.0', '127.0.0.1');
for (const origin of [url, 'https://managed.example']) {
const response = await fetch(`${url}/managed/boot`, {
headers: { Authorization: `Bearer ${browserToken}`, Origin: origin },
});
expect(response.status).toBe(200);
}
expect(
(
await fetch(`${url}/managed/boot`, {
headers: {
Authorization: `Bearer ${browserToken}`,
Origin: 'https://attacker.example',
},
})
).status,
).toBe(403);
await new Promise<void>((resolve, reject) => {
const upgrade = request(`${url}/acp`, {
headers: {
Connection: 'Upgrade',
Upgrade: 'websocket',
Authorization: `Bearer ${browserToken}`,
},
});
upgrade.on('upgrade', () =>
reject(new Error('Gateway accepted a WebSocket upgrade')),
);
upgrade.on('error', () => resolve());
upgrade.end();
});
});
it('fails before listening for invalid configuration or missing, weak, shared credentials', async () => {
for (const token of [undefined, 'short', signingKey, signingKey.trim()]) {
await expect(start({ token })).rejects.toThrow(
'independent browser and signing keys',
);
}
for (const values of [
{ apiUrl: 'http://remote.example' },
{ apiUrl: `${apiUrl}/internal` },
{ apiUrl: `${apiUrl}?secret=x` },
{ tenantId: 'bad tenant' },
{ actorId: '' },
{ signingKeyFile: 'relative-key' },
{ signingKey: 'must-not-be-in-config' },
]) {
await saveConfig(values);
await expect(start()).rejects.toThrow(/Managed Web/u);
}
await saveConfig();
await writeFile(keyFile, 'weak');
await expect(start()).rejects.toThrow(
'independent browser and signing keys',
);
await writeFile(keyFile, signingKey);
vi.stubEnv('QWEN_SERVER_TOKEN', browserToken);
await start({ token: undefined });
expect((await get('/health')).ok).toBe(true);
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { isAbsolute } from 'node:path';
import express from 'express';
import {
allowOriginCors,
bearerAuth,
hostAllowlist,
parseAllowOriginPatterns,
} from './auth.js';
import { mountManagedLocalWeb } from './managed-local-web.js';
import { resolveServeToken } from './serve-token.js';
import { isLoopbackAddress } from './loopback-binds.js';
import {
installRemoteSelfOriginMiddleware,
installSelfOriginStripMiddleware,
} from './server/self-origin.js';
import { mountWebShellAssets } from './web-shell-static.js';
import { resolveWebShellDir } from './web-shell-resolver.js';
interface ManagedWebConfig {
apiUrl: string;
tenantId: string;
actorId: string;
signingKeyFile: string;
}
interface ManagedWebServeOptions {
configFile: string;
hostname: string;
port: number;
token?: string;
allowOrigins?: string[];
}
async function readConfig(file: string): Promise<ManagedWebConfig> {
let value: unknown;
try {
value = JSON.parse(await readFile(file, 'utf8'));
} catch {
throw new Error('Cannot read the Managed Web gateway JSON configuration.');
}
const keys = ['apiUrl', 'tenantId', 'actorId', 'signingKeyFile'];
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.keys(value).length !== keys.length ||
keys.some(
(key) =>
!(key in value) ||
typeof (value as Record<string, unknown>)[key] !== 'string',
)
) {
throw new Error(
`Managed Web configuration requires only ${keys.join(', ')}.`,
);
}
const config = value as ManagedWebConfig;
let api: URL;
try {
api = new URL(config.apiUrl);
} catch {
throw new Error('Managed Web apiUrl must be a loopback HTTP origin.');
}
if (
api.protocol !== 'http:' ||
!isLoopbackAddress(api.hostname) ||
api.username ||
api.password ||
api.pathname !== '/' ||
api.search ||
api.hash
) {
throw new Error('Managed Web apiUrl must be a loopback HTTP origin.');
}
if (
!/^[A-Za-z0-9._:-]{1,128}$/u.test(config.tenantId) ||
!/^[A-Za-z0-9._:-]{1,128}$/u.test(config.actorId)
) {
throw new Error(
'Managed Web tenantId and actorId require 1-128 safe characters.',
);
}
if (!isAbsolute(config.signingKeyFile)) {
throw new Error('Managed Web signingKeyFile must be an absolute path.');
}
return config;
}
export async function runManagedWebServe(
options: ManagedWebServeOptions,
deps: { webShellDir?: string; installSignalHandlers?: boolean } = {},
) {
const config = await readConfig(options.configFile);
const signingKey = await readFile(config.signingKeyFile, 'utf8');
const token = resolveServeToken(options.token);
if (
!token ||
Buffer.byteLength(token) < 32 ||
Buffer.byteLength(signingKey) < 32 ||
token === signingKey ||
token === signingKey.trim()
) {
throw new Error(
'Managed Web requires independent browser and signing keys of at least 32 bytes.',
);
}
if (
!options.hostname.trim() ||
!Number.isInteger(options.port) ||
options.port < 0 ||
options.port > 65535
) {
throw new Error(
'Managed Web requires a hostname and a port between 0 and 65535.',
);
}
const webShellDir = deps.webShellDir ?? resolveWebShellDir();
if (!webShellDir)
throw new Error('Managed Web requires built Web Shell assets.');
const app = express();
app.disable('x-powered-by');
let port = options.port;
installSelfOriginStripMiddleware(app, () => port, options.hostname);
app.use(hostAllowlist(options.hostname, () => port));
installRemoteSelfOriginMiddleware(app, options.hostname, token);
app.use(
allowOriginCors(parseAllowOriginPatterns(options.allowOrigins ?? [])),
);
const assets = express();
mountWebShellAssets(assets, webShellDir, [], false, true);
app.use((req, res, next) => {
if (
(req.method === 'GET' || req.method === 'HEAD') &&
(['/', '/manifest.webmanifest', '/sw.js'].includes(req.path) ||
req.path.startsWith('/assets/'))
) {
assets(req, res, next);
} else next();
});
app.use(bearerAuth(token));
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
app.get('/capabilities', (_req, res) => res.json({ mode: 'managed' }));
mountManagedLocalWeb(app, {
javaUrl: config.apiUrl,
signingKey,
tenantId: config.tenantId,
actorId: config.actorId,
productScope: `gateway:${createHash('sha256')
.update(JSON.stringify([config.apiUrl, config.tenantId, config.actorId]))
.digest('hex')}`,
agentId: 'qwen-code',
managedOnly: true,
});
app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
const server = createServer(app);
server.maxConnections = 256;
server.on('upgrade', (_request, socket) => socket.destroy());
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(options.port, options.hostname, () => {
server.removeListener('error', reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Managed Web has no TCP listener.');
port = address.port;
let finish!: (code: number) => void;
const finished = new Promise<number>((resolve) => {
finish = resolve;
});
let closing: Promise<void> | undefined;
const close = (): Promise<void> => {
closing ??= new Promise<void>((resolve, reject) => {
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const)
process.removeListener(signal, stop);
server.close((error) => {
finish(error ? 1 : 0);
if (error) reject(error);
else resolve();
});
server.closeAllConnections();
});
return closing;
};
const stop = () => {
void close().catch(() => {});
};
if (deps.installSignalHandlers !== false) {
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const)
process.once(signal, stop);
}
const host = options.hostname.includes(':')
? `[${options.hostname.replace(/^\[|\]$/gu, '')}]`
: options.hostname;
return { url: `http://${host}:${port}`, finished, close };
}
@@ -4,12 +4,27 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ManagedAgentProvider } from './components/managed/managed-agent-provider'; import type { ManagedAgentProvider } from './components/managed/managed-agent-provider';
import type { ManagedAgentWebShellProps } from './ManagedAgentWebShell';
import { import {
hasManagedAppMarker, hasManagedAppMarker,
ManagedStandaloneApp, ManagedStandaloneApp,
} from './ManagedStandaloneApp'; } from './ManagedStandaloneApp';
const gateway = vi.hoisted(() => ({
props: [] as ManagedAgentWebShellProps[],
}));
vi.mock('./ManagedAgentWebShell', () => ({
ManagedAgentWebShell: (props: ManagedAgentWebShellProps) => {
gateway.props.push(props);
return (
<button onClick={() => props.onSessionChange?.('session-new')}>
Managed only
</button>
);
},
}));
const BOOT = { const BOOT = {
mode: 'managed', mode: 'managed',
productScope: 'local:installation-1', productScope: 'local:installation-1',
@@ -26,6 +41,7 @@ describe('Managed standalone application', () => {
)); ));
beforeEach(() => { beforeEach(() => {
gateway.props = [];
renderShell.mockClear(); renderShell.mockClear();
fetch.mockReset(); fetch.mockReset();
vi.stubGlobal('fetch', fetch); vi.stubGlobal('fetch', fetch);
@@ -132,6 +148,87 @@ describe('Managed standalone application', () => {
expect(fetch).toHaveBeenCalledOnce(); expect(fetch).toHaveBeenCalledOnce();
}); });
it('boots the existing Managed-only UI without composing daemon contexts', async () => {
window.history.replaceState(
null,
'',
'/?managed=1&managedSession=session-kept',
);
fetch.mockResolvedValue(Response.json({ ...BOOT, managedOnly: true }));
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token" language="zh-CN">
{renderShell}
</ManagedStandaloneApp>,
),
);
expect(renderShell).not.toHaveBeenCalled();
expect(container.textContent).toBe('Managed only');
expect(fetch).toHaveBeenCalledOnce();
expect(gateway.props.at(-1)).toMatchObject({
baseUrl: window.location.origin,
productScope: BOOT.productScope,
agentId: 'qwen-code',
enableWorkspaceBinding: true,
language: 'zh-CN',
sessionId: 'session-kept',
});
expect(await gateway.props.at(-1)?.getHeaders?.()).toEqual({
Authorization: 'Bearer browser-token',
});
await act(async () => container.querySelector('button')?.click());
expect(gateway.props.at(-1)?.sessionId).toBe('session-new');
expect(
new URL(window.location.href).searchParams.get('managedSession'),
).toBe('session-new');
await act(async () => {
window.history.replaceState(
null,
'',
'/?managed=1&managedSession=session-kept',
);
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(gateway.props.at(-1)?.sessionId).toBe('session-kept');
});
it('adds history only when the selected gateway Session changes', async () => {
window.history.replaceState(
null,
'',
'/?managed=1&managedSession=session-kept&view=history#fragment',
);
fetch.mockResolvedValue(Response.json({ ...BOOT, managedOnly: true }));
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token">
{renderShell}
</ManagedStandaloneApp>,
),
);
const initialLength = window.history.length;
await act(async () =>
gateway.props.at(-1)?.onSessionChange?.('session-kept'),
);
expect(window.history.length).toBe(initialLength);
await act(async () =>
gateway.props.at(-1)?.onSessionChange?.('session-new'),
);
expect(window.history.length).toBe(initialLength + 1);
await act(async () =>
gateway.props.at(-1)?.onSessionChange?.('session-new'),
);
expect(window.history.length).toBe(initialLength + 1);
expect(gateway.props.at(-1)?.sessionId).toBe('session-new');
expect(window.location.search).toContain('view=history');
expect(window.location.hash).toBe('#fragment');
await act(async () => gateway.props.at(-1)?.onSessionChange?.(undefined));
expect(window.history.length).toBe(initialLength + 2);
await act(async () => gateway.props.at(-1)?.onSessionChange?.(undefined));
expect(window.history.length).toBe(initialLength + 2);
expect(gateway.props.at(-1)?.sessionId).toBeUndefined();
});
it('supplies admitted local model presets to the new-task picker', async () => { it('supplies admitted local model presets to the new-task picker', async () => {
const modelPresets = [{ agentId: 'agent_model_b', label: 'Model B' }]; const modelPresets = [{ agentId: 'agent_model_b', label: 'Model B' }];
fetch.mockResolvedValue(Response.json({ ...BOOT, modelPresets })); fetch.mockResolvedValue(Response.json({ ...BOOT, modelPresets }));
@@ -186,6 +283,7 @@ describe('Managed standalone application', () => {
new Response(JSON.stringify({ ...BOOT, productScope: '' })), new Response(JSON.stringify({ ...BOOT, productScope: '' })),
new Response(JSON.stringify({ ...BOOT, agentId: '' })), new Response(JSON.stringify({ ...BOOT, agentId: '' })),
new Response(JSON.stringify({ ...BOOT, enableWorkspaceBinding: false })), new Response(JSON.stringify({ ...BOOT, enableWorkspaceBinding: false })),
Response.json({ ...BOOT, managedOnly: 'true' }),
Response.json({ ...BOOT, modelPresets: null }), Response.json({ ...BOOT, modelPresets: null }),
Response.json({ Response.json({
...BOOT, ...BOOT,
@@ -3,7 +3,11 @@ import type {
ManagedAgentProvider, ManagedAgentProvider,
ManagedModelPreset, ManagedModelPreset,
} from './components/managed/managed-agent-provider'; } from './components/managed/managed-agent-provider';
import { createJavaManagedAgentProvider } from './components/managed/java-managed-agent-provider'; import {
createJavaManagedAgentProvider,
type JavaManagedAgentProviderOptions,
} from './components/managed/java-managed-agent-provider';
import { ManagedAgentWebShell } from './ManagedAgentWebShell';
import { RootErrorFallback } from './components/RootErrorFallback'; import { RootErrorFallback } from './components/RootErrorFallback';
import type { WebShellLanguage } from './i18n'; import type { WebShellLanguage } from './i18n';
import { buildSessionPathname, parseSessionId } from './utils/sessionPath'; import { buildSessionPathname, parseSessionId } from './utils/sessionPath';
@@ -28,6 +32,7 @@ export function ManagedStandaloneApp({
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<{ const [state, setState] = useState<{
provider?: ManagedAgentProvider; provider?: ManagedAgentProvider;
gatewayOptions?: JavaManagedAgentProviderOptions;
error?: Error; error?: Error;
}>({}); }>({});
useEffect(() => { useEffect(() => {
@@ -55,7 +60,8 @@ export function ManagedStandaloneApp({
typeof boot.agentId !== 'string' || typeof boot.agentId !== 'string' ||
!boot.agentId.trim() || !boot.agentId.trim() ||
!('enableWorkspaceBinding' in boot) || !('enableWorkspaceBinding' in boot) ||
boot.enableWorkspaceBinding !== true boot.enableWorkspaceBinding !== true ||
('managedOnly' in boot && typeof boot.managedOnly !== 'boolean')
) { ) {
throw new Error('Managed application returned invalid boot options.'); throw new Error('Managed application returned invalid boot options.');
} }
@@ -87,7 +93,7 @@ export function ManagedStandaloneApp({
} }
const modelPresets = rawPresets as ManagedModelPreset[]; const modelPresets = rawPresets as ManagedModelPreset[];
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
const provider = createJavaManagedAgentProvider({ const options: JavaManagedAgentProviderOptions = {
baseUrl: window.location.origin, baseUrl: window.location.origin,
productScope: boot.productScope, productScope: boot.productScope,
agentId: boot.agentId, agentId: boot.agentId,
@@ -95,7 +101,7 @@ export function ManagedStandaloneApp({
modelPresets, modelPresets,
getHeaders: (): HeadersInit => getHeaders: (): HeadersInit =>
daemonToken ? { Authorization: `Bearer ${daemonToken}` } : {}, daemonToken ? { Authorization: `Bearer ${daemonToken}` } : {},
}); };
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (url.searchParams.get('managed') === '1') { if (url.searchParams.get('managed') === '1') {
// Managed restoration must not restore a stale Local route or history target. // Managed restoration must not restore a stale Local route or history target.
@@ -106,7 +112,11 @@ export function ManagedStandaloneApp({
url.searchParams.delete('context'); url.searchParams.delete('context');
window.history.replaceState(null, '', url); window.history.replaceState(null, '', url);
} }
setState({ provider }); setState(
'managedOnly' in boot && boot.managedOnly
? { gatewayOptions: options }
: { provider: createJavaManagedAgentProvider(options) },
);
} }
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -128,6 +138,14 @@ export function ManagedStandaloneApp({
/> />
); );
} }
if (state.gatewayOptions) {
return (
<ManagedGatewaySurface
options={state.gatewayOptions}
language={language}
/>
);
}
if (!state.provider) { if (!state.provider) {
return ( return (
<div role="status" style={{ padding: '2rem' }}> <div role="status" style={{ padding: '2rem' }}>
@@ -137,3 +155,43 @@ export function ManagedStandaloneApp({
} }
return children(state.provider); return children(state.provider);
} }
function selectedGatewaySession(): string | undefined {
return (
new URL(window.location.href).searchParams.get('managedSession') ??
undefined
);
}
function ManagedGatewaySurface({
options,
language,
}: {
options: JavaManagedAgentProviderOptions;
language: WebShellLanguage;
}) {
const [sessionId, setSessionId] = useState(selectedGatewaySession);
useEffect(() => {
const restore = () => setSessionId(selectedGatewaySession());
window.addEventListener('popstate', restore);
return () => window.removeEventListener('popstate', restore);
}, []);
return (
<ManagedAgentWebShell
{...options}
language={language}
sessionId={sessionId}
onSessionChange={(next) => {
setSessionId(next);
const url = new URL(window.location.href);
url.searchParams.set('managed', '1');
if (next) url.searchParams.set('managedSession', next);
else url.searchParams.delete('managedSession');
if (url.href !== window.location.href) {
window.history.pushState(null, '', url);
}
}}
style={{ height: '100dvh' }}
/>
);
}
@@ -35,6 +35,9 @@ const mocks = vi.hoisted(() => ({
appProps: [] as Array<Record<string, unknown>>, appProps: [] as Array<Record<string, unknown>>,
})); }));
vi.mock('../ManagedAgentWebShell', () => ({
ManagedAgentWebShell: () => null,
}));
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DaemonSessionProvider: ({ DaemonSessionProvider: ({
children, children,
@@ -43,6 +43,9 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
vi.mock('./components/WorkspaceSessionProvider', () => ({ vi.mock('./components/WorkspaceSessionProvider', () => ({
WorkspaceSessionProvider: () => null, WorkspaceSessionProvider: () => null,
})); }));
vi.mock('./ManagedAgentWebShell', () => ({
ManagedAgentWebShell: () => null,
}));
vi.mock('./config/daemon', () => ({ vi.mock('./config/daemon', () => ({
getDaemonBaseUrl: () => '', getDaemonBaseUrl: () => '',
getAllowedDaemonOrigin: (value: string) => value, getAllowedDaemonOrigin: (value: string) => value,
+1
View File
@@ -32,6 +32,7 @@ vi.mock('react-dom/client', async (importOriginal) => ({
...(await importOriginal<typeof import('react-dom/client')>()), ...(await importOriginal<typeof import('react-dom/client')>()),
default: { createRoot: () => ({ render: vi.fn() }) }, default: { createRoot: () => ({ render: vi.fn() }) },
})); }));
vi.mock('./ManagedAgentWebShell', () => ({ ManagedAgentWebShell: () => null }));
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DaemonWorkspaceProvider: ({ children }: { children: ReactNode }) => children, DaemonWorkspaceProvider: ({ children }: { children: ReactNode }) => children,
})); }));