mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
0c1b42fa31
commit
9ebd2de171
38 files changed
+3826
-5
No files matched your search
@@ -23,6 +23,9 @@ junit.xml
|
||||
# so formatting and `pnpm install` fight over the file. .yamllint.yml
|
||||
# already ignores it for the same reason.
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Helm templates are validated through Helm rendering.
|
||||
deploy/kubernetes/chart/templates/
|
||||
Thumbs.db
|
||||
packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
packages/cli/src/services/insight/templates/insightTemplate.ts
|
||||
|
||||
@@ -84,6 +84,7 @@ rules:
|
||||
|
||||
ignore:
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'deploy/kubernetes/chart/templates/'
|
||||
- 'thirdparty/'
|
||||
- 'third_party/'
|
||||
- 'vendor/'
|
||||
|
||||
@@ -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).
|
||||
@@ -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)。
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
*.tgz
|
||||
@@ -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 }}
|
||||
@@ -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: []
|
||||
@@ -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 []
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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 是该版本的权威依据。
|
||||
|
||||
从源码安装 Kubernetes 部署(可选可信单节点工具和 WebShell)时,参见独立的
|
||||
[Helm 部署说明](https://github.com/QwenLM/qwen-code/tree/main/deploy/kubernetes)。
|
||||
该 chart 不扩大本参考分发产物的验收范围。
|
||||
|
||||
## 校验并解压
|
||||
|
||||
从同一个 GitHub prerelease 下载压缩包、独立 manifest 和 `SHA256SUMS.txt`,解压前完成校验:
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
maybeOpenWebShellBrowser,
|
||||
serveCommand,
|
||||
validateManagedLocalFlags,
|
||||
validateManagedWebFlags,
|
||||
} from './serve.js';
|
||||
|
||||
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
|
||||
const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));
|
||||
const mockRunQwenServe = vi.hoisted(() => vi.fn());
|
||||
const mockRunManagedWebServe = vi.hoisted(() => vi.fn());
|
||||
const mockApplyOpenWithAuth = vi.hoisted(() =>
|
||||
vi.fn((options: { token?: string }) => {
|
||||
options.token ??= 'generated-token';
|
||||
@@ -47,6 +49,9 @@ vi.mock('qrcode-terminal', () => ({ default: mockQr }));
|
||||
vi.mock('../serve/run-qwen-serve.js', () => ({
|
||||
runQwenServe: mockRunQwenServe,
|
||||
}));
|
||||
vi.mock('../serve/managed-web-serve.js', () => ({
|
||||
runManagedWebServe: mockRunManagedWebServe,
|
||||
}));
|
||||
vi.mock('../serve/open-with-auth.js', () => ({
|
||||
applyOpenWithAuth: mockApplyOpenWithAuth,
|
||||
}));
|
||||
@@ -58,6 +63,68 @@ function buildParser(): Argv {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
expect(() =>
|
||||
validateManagedLocalFlags(['--managed', '--port=0', '--no-open']),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -223,6 +242,7 @@ interface ServeArgs {
|
||||
profile: 'default' | 'hosted-harness';
|
||||
managed: boolean;
|
||||
'managed-data-dir'?: string;
|
||||
'managed-web-config'?: string;
|
||||
'hosted-harness-capability-digest'?: string;
|
||||
token?: string;
|
||||
'max-sessions'?: number;
|
||||
@@ -338,6 +358,12 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
description:
|
||||
'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', {
|
||||
type: 'string',
|
||||
description:
|
||||
@@ -846,6 +872,40 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
'Requires --rate-limit.',
|
||||
}) as unknown as Argv<ServeArgs>,
|
||||
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) {
|
||||
try {
|
||||
const serveIndex = process.argv.indexOf('serve');
|
||||
|
||||
@@ -679,6 +679,10 @@ describe('serve fast path argument parsing', () => {
|
||||
const sampleArgvByOption = new Map<string, string[]>([
|
||||
['managed', ['--managed']],
|
||||
['managed-data-dir', ['--managed-data-dir', '/tmp/qwen-managed-data']],
|
||||
[
|
||||
'managed-web-config',
|
||||
['--managed-web-config', '/etc/qwen-web/gateway.json'],
|
||||
],
|
||||
['port', ['--port', '0']],
|
||||
['hostname', ['--hostname', '127.0.0.1']],
|
||||
['token', ['--token', 'token']],
|
||||
@@ -802,6 +806,7 @@ describe('serve fast path argument parsing', () => {
|
||||
'channel',
|
||||
'managed',
|
||||
'managed-data-dir',
|
||||
'managed-web-config',
|
||||
'profile',
|
||||
'hosted-harness-capability-digest',
|
||||
'experimental-paired-engines',
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ManagedLocalWebOptions {
|
||||
actorId: string;
|
||||
productScope: string;
|
||||
agentId: string;
|
||||
managedOnly?: boolean;
|
||||
modelPresets?: ReadonlyArray<{
|
||||
agentId: string;
|
||||
label: string;
|
||||
@@ -94,6 +95,7 @@ export function mountManagedLocalWeb(
|
||||
agentId: options.agentId,
|
||||
enableWorkspaceBinding: true,
|
||||
modelPresets: options.modelPresets,
|
||||
...(options.managedOnly ? { managedOnly: true } : {}),
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ManagedAgentProvider } from './components/managed/managed-agent-provider';
|
||||
import type { ManagedAgentWebShellProps } from './ManagedAgentWebShell';
|
||||
|
||||
import {
|
||||
hasManagedAppMarker,
|
||||
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 = {
|
||||
mode: 'managed',
|
||||
productScope: 'local:installation-1',
|
||||
@@ -26,6 +41,7 @@ describe('Managed standalone application', () => {
|
||||
));
|
||||
|
||||
beforeEach(() => {
|
||||
gateway.props = [];
|
||||
renderShell.mockClear();
|
||||
fetch.mockReset();
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
@@ -132,6 +148,87 @@ describe('Managed standalone application', () => {
|
||||
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 () => {
|
||||
const modelPresets = [{ agentId: 'agent_model_b', label: 'Model B' }];
|
||||
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, agentId: '' })),
|
||||
new Response(JSON.stringify({ ...BOOT, enableWorkspaceBinding: false })),
|
||||
Response.json({ ...BOOT, managedOnly: 'true' }),
|
||||
Response.json({ ...BOOT, modelPresets: null }),
|
||||
Response.json({
|
||||
...BOOT,
|
||||
|
||||
@@ -3,7 +3,11 @@ import type {
|
||||
ManagedAgentProvider,
|
||||
ManagedModelPreset,
|
||||
} 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 type { WebShellLanguage } from './i18n';
|
||||
import { buildSessionPathname, parseSessionId } from './utils/sessionPath';
|
||||
@@ -28,6 +32,7 @@ export function ManagedStandaloneApp({
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState<{
|
||||
provider?: ManagedAgentProvider;
|
||||
gatewayOptions?: JavaManagedAgentProviderOptions;
|
||||
error?: Error;
|
||||
}>({});
|
||||
useEffect(() => {
|
||||
@@ -55,7 +60,8 @@ export function ManagedStandaloneApp({
|
||||
typeof boot.agentId !== 'string' ||
|
||||
!boot.agentId.trim() ||
|
||||
!('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.');
|
||||
}
|
||||
@@ -87,7 +93,7 @@ export function ManagedStandaloneApp({
|
||||
}
|
||||
const modelPresets = rawPresets as ManagedModelPreset[];
|
||||
if (!controller.signal.aborted) {
|
||||
const provider = createJavaManagedAgentProvider({
|
||||
const options: JavaManagedAgentProviderOptions = {
|
||||
baseUrl: window.location.origin,
|
||||
productScope: boot.productScope,
|
||||
agentId: boot.agentId,
|
||||
@@ -95,7 +101,7 @@ export function ManagedStandaloneApp({
|
||||
modelPresets,
|
||||
getHeaders: (): HeadersInit =>
|
||||
daemonToken ? { Authorization: `Bearer ${daemonToken}` } : {},
|
||||
});
|
||||
};
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get('managed') === '1') {
|
||||
// Managed restoration must not restore a stale Local route or history target.
|
||||
@@ -106,7 +112,11 @@ export function ManagedStandaloneApp({
|
||||
url.searchParams.delete('context');
|
||||
window.history.replaceState(null, '', url);
|
||||
}
|
||||
setState({ provider });
|
||||
setState(
|
||||
'managedOnly' in boot && boot.managedOnly
|
||||
? { gatewayOptions: options }
|
||||
: { provider: createJavaManagedAgentProvider(options) },
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -128,6 +138,14 @@ export function ManagedStandaloneApp({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (state.gatewayOptions) {
|
||||
return (
|
||||
<ManagedGatewaySurface
|
||||
options={state.gatewayOptions}
|
||||
language={language}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!state.provider) {
|
||||
return (
|
||||
<div role="status" style={{ padding: '2rem' }}>
|
||||
@@ -137,3 +155,43 @@ export function ManagedStandaloneApp({
|
||||
}
|
||||
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>>,
|
||||
}));
|
||||
|
||||
vi.mock('../ManagedAgentWebShell', () => ({
|
||||
ManagedAgentWebShell: () => null,
|
||||
}));
|
||||
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
|
||||
DaemonSessionProvider: ({
|
||||
children,
|
||||
|
||||
@@ -43,6 +43,9 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
|
||||
vi.mock('./components/WorkspaceSessionProvider', () => ({
|
||||
WorkspaceSessionProvider: () => null,
|
||||
}));
|
||||
vi.mock('./ManagedAgentWebShell', () => ({
|
||||
ManagedAgentWebShell: () => null,
|
||||
}));
|
||||
vi.mock('./config/daemon', () => ({
|
||||
getDaemonBaseUrl: () => '',
|
||||
getAllowedDaemonOrigin: (value: string) => value,
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('react-dom/client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-dom/client')>()),
|
||||
default: { createRoot: () => ({ render: vi.fn() }) },
|
||||
}));
|
||||
vi.mock('./ManagedAgentWebShell', () => ({ ManagedAgentWebShell: () => null }));
|
||||
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
|
||||
DaemonWorkspaceProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user