feat(managed-agent): Start daemon and Managed locally with one command (#14928)

* feat(managed-agent): add local serve and lightweight runtime packages

Co-authored-by: Qwen-Coder <[email protected]>

* feat(managed-agent): download local server runtime on first use

Require local Java 21, install the exact-version service JAR atomically from OSS or GitHub, and reuse verified runtime caches offline. Add protected signed Central publication and identical release distribution without bundling Java in the CLI.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(ci): register managed runtime workflow size

Record the new release workflow in the repository size baseline so the static CI gate can track it.

Co-authored-by: Qwen-Coder <[email protected]>

* feat(managed-agent): Share the local daemon and Managed entry

Serve daemon APIs and the signed Managed proxy from one loopback listener. Keep workspace ownership explicit, enable authorized local directory import and preserve the daemon trust boundary.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): expose configured models in local Managed tasks

Co-authored-by: Qwen-Coder <[email protected]>

* test(web-shell): Cover Managed and local deep-link restoration

Co-authored-by: Qwen-Coder <[email protected]>

* test(managed-agent): Align local database expectation with main schema

Co-authored-by: Qwen-Coder <[email protected]>

* fix(managed): Keep local model and approval choices independent

Use native initial model selection for local model presets and persist it with the selected approval mode before admitting the first Turn. Preserve custom Agent definition policy pins and creation replay semantics.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
jinyeandQwen-Coder authored and GitHub committed 2026-10-22 16:25:18 +00:00
1 parent 10e4701862
commit c61044fae4
75 files changed
+7591 -127

No files matched your search

+1
View File
@@ -50,6 +50,7 @@
362377 qwen-triage.yml
15171 release-chrome-extension.yml
22974 release-managed-agent-reference.yml
13464 release-managed-runtime.yml
11950 release-sdk-java.yml
22345 release-sdk-python.yml
20008 release-sdk.yml
@@ -0,0 +1,273 @@
name: 'Release Managed server runtime'
on:
release:
types: ['published']
workflow_dispatch:
inputs:
tag:
description: 'Existing published CLI release tag, for example v0.25.4.'
type: 'string'
required: true
dry_run:
description: 'Build and verify without publishing to Central, GitHub, or OSS.'
type: 'boolean'
required: true
default: true
concurrency:
group: 'managed-runtime-${{ github.event.release.tag_name || inputs.tag }}'
cancel-in-progress: false
permissions:
contents: 'read'
env:
RELEASE_TAG: '${{ github.event.release.tag_name || inputs.tag }}'
jobs:
prepare:
if: "${{ github.repository == 'QwenLM/qwen-code' && (github.event_name == 'workflow_dispatch' || startsWith(github.event.release.tag_name, 'v')) }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 10
outputs:
eligible: '${{ steps.gate.outputs.eligible }}'
sha: '${{ steps.source.outputs.sha }}'
version: '${{ steps.source.outputs.version }}'
timestamp: '${{ steps.source.outputs.timestamp }}'
steps:
- name: 'Validate the official published CLI release'
id: 'gate'
env:
GH_TOKEN: '${{ github.token }}'
EVENT_NAME: '${{ github.event_name }}'
WORKFLOW_REF: '${{ github.ref }}'
run: |-
set -euo pipefail
if [[ "${EVENT_NAME}" == 'workflow_dispatch' && "${WORKFLOW_REF}" != 'refs/heads/main' ]]; then
echo 'Manual Managed runtime releases must run from main.' >&2
exit 1
fi
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+([.-][a-zA-Z0-9]+)*)?$ ]]; then
if [[ "${EVENT_NAME}" == 'release' ]]; then
echo 'eligible=false' >> "${GITHUB_OUTPUT}"
exit 0
fi
echo 'An exact CLI release tag is required.' >&2
exit 1
fi
gh api "repos/QwenLM/qwen-code/releases/tags/${RELEASE_TAG}" > "${RUNNER_TEMP}/managed-release.json"
jq --exit-status --arg tag "${RELEASE_TAG}" '.draft == false and .tag_name == $tag' "${RUNNER_TEMP}/managed-release.json"
echo 'eligible=true' >> "${GITHUB_OUTPUT}"
- name: 'Checkout the existing release tag'
if: "${{ steps.gate.outputs.eligible == 'true' }}"
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ env.RELEASE_TAG }}'
persist-credentials: false
- name: 'Pin the source and exact CLI version'
id: 'source'
if: "${{ steps.gate.outputs.eligible == 'true' }}"
run: |-
set -euo pipefail
version=$(node -p 'JSON.parse(require("fs").readFileSync("package.json", "utf8")).version')
if [[ "${RELEASE_TAG}" != "v${version}" ]]; then
echo 'Release tag and CLI package version differ.' >&2
exit 1
fi
test -f scripts/managed-runtime-release.js
printf 'sha=%s\nversion=%s\ntimestamp=%s\n' "$(git rev-parse HEAD)" "${version}" "$(git show -s --format=%cI HEAD)" >> "${GITHUB_OUTPUT}"
release:
needs: ['prepare']
if: "${{ needs.prepare.outputs.eligible == 'true' }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 90
environment:
name: 'production-release'
permissions:
contents: 'write'
env:
CLI_VERSION: '${{ needs.prepare.outputs.version }}'
SOURCE_SHA: '${{ needs.prepare.outputs.sha }}'
BUILD_TIMESTAMP: '${{ needs.prepare.outputs.timestamp }}'
OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}"
steps:
- name: 'Checkout the pinned source'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ needs.prepare.outputs.sha }}'
persist-credentials: false
- name: 'Set up Java 21'
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
distribution: 'temurin'
java-version: '21'
cache: 'maven'
cache-dependency-path: |
packages/sdk-java/qwencode/pom.xml
packages/managed-agent-server/pom.xml
- name: 'Set up Node.js 22'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22'
package-manager-cache: false
- name: 'Build the matching CLI prerequisites'
env:
QWEN_SKIP_PREPARE: '1'
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
npm run build
npm run typecheck
npm run bundle
- name: 'Verify and install the SDK from the same source'
run: |-
mvn --batch-mode --no-transfer-progress -f packages/sdk-java/qwencode/pom.xml clean install -Dgpg.skip=true -Dproject.build.outputTimestamp="${BUILD_TIMESTAMP}"
- name: 'Set the server artifact to the exact CLI version'
run: |-
mvn --batch-mode --no-transfer-progress -f packages/managed-agent-server/pom.xml versions:set -DnewVersion="${CLI_VERSION}" -DgenerateBackupPoms=false
- name: 'Verify the executable JAR and publication profile'
run: |-
mvn --batch-mode --no-transfer-progress -f packages/managed-agent-server/pom.xml clean verify -Pcentral-release -Dgpg.skip=true -Dproject.build.outputTimestamp="${BUILD_TIMESTAMP}"
node scripts/managed-runtime-release.js --jar "packages/managed-agent-server/target/qwen-managed-agent-server-${CLI_VERSION}.jar" --version "${CLI_VERSION}" --out-dir dist/managed-runtime
- name: 'Retain verified JAR and checksum'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'managed-runtime-${{ needs.prepare.outputs.version }}'
path: 'dist/managed-runtime/*'
if-no-files-found: 'error'
- name: 'Recheck the immutable tag and existing publication bytes'
id: 'publication'
if: "${{ github.event_name == 'release' || !inputs.dry_run }}"
env:
GH_TOKEN: '${{ github.token }}'
run: |-
set -euo pipefail
direct=$(git ls-remote --tags origin "refs/tags/${RELEASE_TAG}" | cut -f1)
peeled=$(git ls-remote --tags origin "refs/tags/${RELEASE_TAG}^{}" | cut -f1)
if [[ "${peeled:-${direct}}" != "${SOURCE_SHA}" ]]; then
echo 'The release tag moved after verification; refusing publication.' >&2
exit 1
fi
gh api "repos/QwenLM/qwen-code/releases/tags/${RELEASE_TAG}" | jq --exit-status '.draft == false'
jar="qwen-managed-agent-server-${CLI_VERSION}.jar"
central="https://repo.maven.apache.org/maven2/com/alibaba/qwen-managed-agent-server/${CLI_VERSION}/${jar}"
state=$(node scripts/managed-runtime-release.js --check-url "${central}" --file "dist/managed-runtime/${jar}") || exit 1
echo "central=${state}" >> "${GITHUB_OUTPUT}"
: > "${RUNNER_TEMP}/managed-github-missing"
: > "${RUNNER_TEMP}/managed-oss-missing"
for asset in "${jar}" "${jar}.sha256"; do
github="https://github.com/QwenLM/qwen-code/releases/download/${RELEASE_TAG}/${asset}"
oss="${OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}/${asset}"
state=$(node scripts/managed-runtime-release.js --check-url "${github}" --file "dist/managed-runtime/${asset}") || exit 1
if [[ "${state}" == 'missing' ]]; then
echo "dist/managed-runtime/${asset}" >> "${RUNNER_TEMP}/managed-github-missing"
fi
state=$(node scripts/managed-runtime-release.js --check-url "${oss}" --file "dist/managed-runtime/${asset}") || exit 1
if [[ "${state}" == 'missing' ]]; then
echo "dist/managed-runtime/${asset}" >> "${RUNNER_TEMP}/managed-oss-missing"
fi
done
- name: 'Configure signed Central publication'
if: "${{ (github.event_name == 'release' || !inputs.dry_run) && steps.publication.outputs.central == 'missing' }}"
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
distribution: 'temurin'
java-version: '21'
server-id: 'central'
server-username: 'MAVEN_USERNAME'
server-password: 'MAVEN_PASSWORD'
gpg-private-key: '${{ secrets.MAVEN_GPG_PRIVATE_KEY }}'
gpg-passphrase: 'MAVEN_GPG_PASSPHRASE'
- name: 'Publish the signed server artifact to Central'
if: "${{ (github.event_name == 'release' || !inputs.dry_run) && steps.publication.outputs.central == 'missing' }}"
env:
MAVEN_USERNAME: '${{ secrets.CENTRAL_USERNAME }}'
MAVEN_PASSWORD: '${{ secrets.CENTRAL_PASSWORD }}'
MAVEN_GPG_PRIVATE_KEY: '${{ secrets.MAVEN_GPG_PRIVATE_KEY }}'
MAVEN_GPG_PASSPHRASE: '${{ secrets.MAVEN_GPG_PASSPHRASE }}'
run: |-
set -euo pipefail
for name in MAVEN_USERNAME MAVEN_PASSWORD MAVEN_GPG_PRIVATE_KEY MAVEN_GPG_PASSPHRASE; do
[[ -n "${!name}" ]] || { echo "Missing production-release secret: ${name}" >&2; exit 1; }
done
mvn --batch-mode --no-transfer-progress -f packages/managed-agent-server/pom.xml deploy -Pcentral-release -DskipTests -Dproject.build.outputTimestamp="${BUILD_TIMESTAMP}"
cmp "packages/managed-agent-server/target/qwen-managed-agent-server-${CLI_VERSION}.jar" "dist/managed-runtime/qwen-managed-agent-server-${CLI_VERSION}.jar"
- name: 'Verify Central availability before distribution'
if: "${{ github.event_name == 'release' || !inputs.dry_run }}"
run: |-
set -euo pipefail
jar="qwen-managed-agent-server-${CLI_VERSION}.jar"
for attempt in {1..40}; do
state=$(node scripts/managed-runtime-release.js --check-url "https://repo.maven.apache.org/maven2/com/alibaba/qwen-managed-agent-server/${CLI_VERSION}/${jar}" --file "dist/managed-runtime/${jar}")
if [[ "${state}" == 'present' ]]; then exit 0; fi
sleep 30
done
echo 'The exact server JAR is not yet available on Central.' >&2
exit 1
- name: 'Attach only missing GitHub release assets'
if: "${{ github.event_name == 'release' || !inputs.dry_run }}"
env:
GH_TOKEN: '${{ github.token }}'
run: |-
set -euo pipefail
mapfile -t assets < "${RUNNER_TEMP}/managed-github-missing"
if (( ${#assets[@]} )); then gh release upload "${RELEASE_TAG}" "${assets[@]}"; fi
- name: 'Install the verified OSS uploader'
if: "${{ github.event_name == 'release' || !inputs.dry_run }}"
env:
OSSUTIL_URL: 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip'
OSSUTIL_SHA256: 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343'
run: |-
set -euo pipefail
work=$(mktemp -d)
trap 'rm -rf "${work}"' EXIT
curl -fsSL --connect-timeout 15 --max-time 300 "${OSSUTIL_URL}" -o "${work}/ossutil.zip"
echo "${OSSUTIL_SHA256} ${work}/ossutil.zip" | sha256sum -c -
unzip -q "${work}/ossutil.zip" -d "${work}"
binary=$(find "${work}" -type f -name 'ossutil64' -print -quit)
test -n "${binary}"
mkdir -p "${HOME}/.local/bin"
install -m 0755 "${binary}" "${HOME}/.local/bin/ossutil"
echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
- name: 'Upload only missing OSS release assets'
if: "${{ github.event_name == 'release' || !inputs.dry_run }}"
env:
ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}'
ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}'
ALIYUN_OSS_ENDPOINT: "${{ vars.ALIYUN_OSS_ENDPOINT || 'https://oss-cn-hangzhou.aliyuncs.com' }}"
ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}"
run: |-
set -euo pipefail
mapfile -t assets < "${RUNNER_TEMP}/managed-oss-missing"
if (( ${#assets[@]} == 0 )); then exit 0; fi
[[ -n "${ALIYUN_OSS_ACCESS_KEY_ID}" && -n "${ALIYUN_OSS_ACCESS_KEY_SECRET}" ]]
ossutil config -e "${ALIYUN_OSS_ENDPOINT}" -i "${ALIYUN_OSS_ACCESS_KEY_ID}" -k "${ALIYUN_OSS_ACCESS_KEY_SECRET}" -L EN -c "${RUNNER_TEMP}/managed-ossutilconfig"
node scripts/upload-aliyun-oss-assets.js --bucket "${ALIYUN_OSS_BUCKET}" --config "${RUNNER_TEMP}/managed-ossutilconfig" --prefix "releases/qwen-code/${RELEASE_TAG}" "${assets[@]}"
- name: 'Verify both public download channels'
if: "${{ github.event_name == 'release' || !inputs.dry_run }}"
run: |-
set -euo pipefail
jar="qwen-managed-agent-server-${CLI_VERSION}.jar"
for base in "https://github.com/QwenLM/qwen-code/releases/download/${RELEASE_TAG}" "${OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"; do
for asset in "${jar}" "${jar}.sha256"; do
[[ "$(node scripts/managed-runtime-release.js --check-url "${base}/${asset}" --file "dist/managed-runtime/${asset}")" == 'present' ]]
done
done
@@ -0,0 +1,330 @@
# Managed local serve
[English](2026-10-20-managed-local-serve.md) | [简体中文](2026-10-20-managed-local-serve.zh-CN.md)
Status: local serve, first-use JAR download, and release integration implemented; production publication pending.
Date: 2026-10-21.
## Problem and current state
Hosted Managed Sessions already have a Java control plane with an embedded
Runtime Broker, a private Node Hosted Harness, and native local tool workers.
The reference deployment requires a separately configured MySQL server,
Harness, credentials, and production WebShell integration. The development
launcher relies on a source checkout, Maven, and Vite. Neither is a local
application users can start with one command.
H2 in MySQL mode already exercises the canonical Flyway schema and JDBC stores
in tests. Native workers support Linux and macOS; macOS supports same-boot
adoption, not trusted whole-host reboot recovery. systemd is an optional
operator deployment choice.
## Goals and scope
Managed local runs `qwen serve --managed --open` without MySQL, Docker, systemd,
Maven, or a source checkout. The default first-use installer downloads only the
matching server JAR and requires local Java 21. A separately installed managed-runtime
archive can supply both the matching Java server JAR and Java 21 runtime. It reuses
the installed Qwen CLI, its Node process, Hosted Harness, native worker entry,
and built WebShell assets. An optional complete standalone distribution includes
all components. A lightweight archive omits Java and requires an installed
Java 21. The initial targets are native Linux and macOS on x64 and
arm64. Windows and Bun are outside this delivery.
This is one trusted user's loopback application with one Java control plane
per private data directory. It retains SQL ownership, leases, fencing,
idempotency, original Runtime routing, and unknown-result semantics. MySQL
reference deployments keep their existing defaults. Public multi-user hosting,
HA, SSH/Kubernetes setup, and automatic repair of recovery facts are outside
scope.
The initial Workspace is application-owned. Users can explicitly import an
existing local project by absolute path through Workspace → Choose existing
directory. The launcher enables the existing [local directory import](2026-10-21-managed-local-directory-import.md)
capability under loopback signed authentication. Its registration, OWNER grant,
physical identity checks and recovery remain authoritative. Files stay in place
and the maintenance marker stays outside Git worktrees. The launch directory is
not automatically imported.
## Proposed components
| Component | Responsibility |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CLI managed launcher | Resolve packaged runtime, lock private data, retain secrets, start and supervise children, wait for readiness, bootstrap through existing APIs, expose the browser URL |
| `managed-local` Spring profile | Persistent embedded H2, loopback and private-data validation, canonical migrations, local process settings |
| Ordinary daemon front door | Serve packaged assets and ordinary daemon APIs/transports, authenticate the browser, proxy the Java WebShell API using server-owned signed identity |
| Unified standalone WebShell | Read authenticated boot options, instantiate the existing Java provider, use shared workspace/session navigation at the current origin |
| Managed distribution builder | Package JAR and JRE separately, or add them to a complete standalone distribution; record versions and checksums and retain runtime licenses |
### Startup and persistence
`--managed` is an opt-in application launcher, lazily loaded. It starts the
ordinary daemon alongside Java and the private Harness, rejecting conflicting
execution profiles and flags. The ordinary daemon owns the public loopback-only
listener, which always uses a bearer token. See the
[unified WebShell design](2026-10-21-managed-daemon-web-shell.md).
`--port`, `--open`, and `--open-with-auth` retain their usual meanings.
`--managed-data-dir` optionally selects the private state directory; the
default is beneath the user's Qwen home. Concurrent launchers for the same
directory refuse admission.
The private directory contains stable installation identity, separate public,
Harness, Broker, signature, writer-binding and Runtime-credential secrets,
file database, Runtime state, durable worker file history, application-owned
Workspace creation root, and service logs. Directories are owner-only and
credential files are owner-readable/writable only. Startup never rotates
credentials or removes persisted facts. A corrupt or incomplete credential file
fails closed. No secret is passed in process command arguments or browser boot
options.
Start the private Hosted Harness and Java processes with loopback listeners.
The current Qwen process supplies its canonical CLI entry and Node executable
to both the Harness and workers; no second CLI is discovered through PATH.
The runtime archive must match that CLI version exactly. The Harness keeps the
user's normal Qwen model configuration; workers retain tool execution only.
Enable files and Shell with the existing `default` approval mode. Explicitly
enable the existing `hosted-workspace-shell/1` lifecycle profile and require it
in the authenticated Harness capability check so Session close can settle Shell
resources. The public front door opens only after Java readiness and these
capability checks succeed. The built-in `qwen-code` agent uses the user's configured model.
A stable bootstrap idempotency key creates the default Workspace through the
existing signed API. Retries replay that receipt; they do not manufacture
replacement identities. The private legacy Workspace ID remains the existing
hash of its canonical directory; application-owned Workspace IDs are generated
by the existing API.
The H2 URL is a private file database using MySQL mode,
`DATABASE_TO_LOWER=TRUE`, `DB_CLOSE_ON_EXIT=FALSE`, and `WRITE_DELAY=0`.
There is no TCP server, `AUTO_SERVER`, or Web console. H2's file lock provides
an additional single-control-plane guard. Keep the existing fresh-catalog,
Flyway, and shared schema checks; foreign, partial, history-only, or unsupported
catalogs remain refused. Existing resource kind names are retained because they
are persisted protocol identifiers, even when inline bytes reside in H2.
Control-plane mutations lock the Session row before reading its event stream
counters with a separate locking query. An H2 outer join can retain counters
from before a lock wait even after refreshing the Session row. A fresh stream
read keeps event allocation and mutation-attempt boundaries current; no batch
may move the committed stream counter backwards.
### Browser and API ownership
The server places a public Managed-mode marker in the HTML. The browser obtains
boot options from authenticated `GET /managed/boot`: `mode`, `productScope`,
`agentId`, and Workspace binding options. This configuration contains no tenant,
actor, private endpoint, or credential. Managed mode ignores `?daemon=` and
fanout targets and fixes the provider to the page's origin.
`GET /capabilities` is the authenticated process-global browser auth probe and
returns the ordinary daemon's real capabilities and Workspaces. Authenticated
boot constructs the Java provider and supplies it to the normal standalone
WebShell's shared Projects navigation without execution-mode tabs. Local sessions use the launch
directory; this does not import that directory into Managed storage.
`/api/agent/web-shell/v1/**` and exact artifact-content GET/HEAD requests at
`/v1/agents/sessions/:sessionId/artifacts/:artifactId/content` are the proxied
API surface; the latter is required by the existing Java client. The front door
strips caller identity/private auth headers and signs the exact bounded raw
request bytes with a fixed server-owned tenant and actor. Java retains
persisted-Workspace and live-Session authorization. SSE responses stream with
backpressure and abort on client disconnect. Internal writer, Runtime Broker,
maintenance, and general Java routes are not exposed by the front door.
### Lifecycle and failures
The launcher tracks only children it created and owns process signals, with
embedded daemon signal handlers disabled. SIGINT/SIGTERM stop admission and
close the daemon and its transports, then stop Java and Harness with bounded graceful waits.
Both children use separate process groups so terminal signals reach the launcher
first. Forced shutdown targets Java itself and the Harness's process group;
durable workers keep their existing ownership. Startup rechecks child failure
and interruption after the public listener binds before reporting success.
Startup failure unwinds created children and releases its lock; unrelated
processes and private data are never deleted. An unexpected child exit closes
the application with a nonzero status. There is no blind restart or replay of
unknown side effects. Original durable workers and SQL recovery retain their
existing ownership rules. macOS explicitly disables trusted reboot recovery.
Restarting a Session may wait for the previous SQL writer lease to expire
(60 seconds by default); process exit does not release or shorten that lease.
A missing storage marker does not prevent UI bootstrap or replay of its
Workspace-creation receipt. Runtime admission for the affected bound tool turn
fails with `workspace_unavailable`, without recreating the marker or executing
the tool elsewhere.
This delivery does not claim immunity to laptop sleep or automatic login-time
startup. Keeping a terminal open is sufficient for normal foreground use;
operators may choose their own service manager later.
### Distribution and runtime resolution
The separate `qwen-code-managed-runtime-<target>.tar.gz` contains only
`managed-runtime/<cli-version>/<target>/manifest.json`, `server.jar`, and
`jre/`. It includes Java licenses and contains no Node, CLI, Hosted Harness,
worker copy, or WebShell assets. Extract it into the user's Qwen home; the
installed `qwen serve --managed --open` discovers its exact version/target
directory. Users still configure their model through the installed CLI.
The lightweight `qwen-code-managed-runtime-lite-<target>.tar.gz` contains only
the same `manifest.json` and `server.jar`. Builders select it explicitly with
`--without-jre`, mutually exclusive with `--jre-home`. It installs in the same
version/target directory; users choose one edition rather than overlaying both.
The manifest records `javaSource: system` for lightweight delivery and
`javaSource: bundled` for complete delivery. Older manifests without this field
retain bundled semantics. Unknown values are refused.
For lightweight delivery, a nonempty `JAVA_HOME` selects `bin/java` exclusively;
otherwise resolve the first executable `java` on PATH to its canonical path.
Probe that executable with a bounded Java properties/version command and require
`java.specification.version=21` before creating application state or service
children. Missing, unlaunchable or incompatible Java produces an actionable
error. Java itself is not installed or downloaded, and another Java version is not searched for.
Bundled delivery still requires its own runtime; a missing or corrupt JRE never
switches to system Java. The service launches the exact verified executable.
The manifest binds CLI version, target, Java source, Java major version, and server digest.
An explicit `QWEN_MANAGED_LOCAL_RUNTIME_DIR` selects one payload. Without it,
the launcher uses a colocated `managed-runtime` directory when present, then
the versioned installation under Qwen home when the colocated directory is
absent. A selected invalid or incomplete payload fails closed without trying
another location. Compatibility is validated before private state or children
are created. When neither a selected payload nor the versioned cache exists,
the launcher verifies local Java 21 before installing the matching server JAR.
Older Qwen releases without this launcher require a CLI upgrade;
installing only the runtime cannot add missing CLI behavior.
The existing complete Managed standalone builder keeps its optional JAR/JRE
inputs and `lib/managed-runtime/` layout. Both builders use the same payload
copy/validation logic. A separate runtime build does not require built CLI
assets or a Node archive. Its archives are written outside the CLI asset tree
by default. Missing payload errors identify the expected version,
target and installation path. Offline users can preinstall either archive or
select an explicit runtime directory. Publishing archives or npm packages remains
a separate release operation.
### First-use download and publication
Ordinary CLI packages contain no Java payload. Only `qwen serve --managed`
downloads a missing default runtime. The versioned release asset is
`qwen-managed-agent-server-<cli-version>.jar` with a matching `.sha256` file.
The installer tries the existing official Aliyun OSS release prefix first and
the same GitHub Release tag second. It never selects `latest`, resolves Maven
dependencies, or downloads Java. Both sources must provide the exact CLI version.
A per-version/target installation lock serializes downloads across processes.
The installer streams into a private temporary sibling directory with a bounded
download time and size, validates the JAR SHA-256 and ZIP signature, writes the
existing `javaSource: system` manifest, and atomically renames the completed
directory into the cache. A failed or interrupted download cannot publish a
partial runtime. A later invocation can retry. Existing explicit, colocated,
or installed payloads remain authoritative: invalid content is refused instead
of being silently replaced or causing a download. Cached startup requires no
network access. Installation occurs before application credentials, data, or
service children are created.
A dedicated protected release workflow builds from an existing CLI release tag,
checks its package version, and verifies the Java SDK and server using Java 21.
The server JAR is published as the independent Maven coordinate
`com.alibaba:qwen-managed-agent-server:<cli-version>` using the SDK's signed
Central publication conventions, including sources and Javadoc. Auxiliary
operator executable JARs are not attached to that publication. The exact same
primary JAR and checksum are attached to the CLI GitHub Release and copied to
the existing versioned OSS prefix, outside the standalone archive allowlist.
Reproducible timestamps and checksum comparisons prevent a retry from replacing
an existing version with different bytes. Dry runs build and verify without
publishing. Production credentials and publication remain protected by the
existing `production-release` environment.
Maven Central is a Java artifact publication channel, not the CLI's startup
download dependency. The distribution follows [Sonatype's guidance for runtime
artifacts](https://central.sonatype.org/faq/429-operational-dependencies/) by using the project's own OSS/GitHub channels. A new CLI release must
have matching runtime assets published before its Managed first-use path can
succeed; release availability failures name the required version and preserve
the offline installation option.
## Affected files
- `packages/cli/src/serve/managed-local-download.ts` and focused installation tests; `.github/workflows/release-managed-runtime.yml`, `scripts/managed-runtime-release.js`, and publication contract tests.
- `packages/cli/src/commands/serve.ts` and new managed local launcher/front-door
modules and focused tests.
- `packages/cli/src/serve/web-shell-static.ts` and related tests.
- `packages/web-shell/client/main.tsx`, Managed standalone bootstrap, and tests.
- `packages/managed-agent-server/pom.xml`, a local Spring profile/configuration,
fresh Session stream locking reads, and persistent file-database/concurrency tests.
- `scripts/create-standalone-package.js`, a runtime-only builder, managed packaging helpers/tests,
and bilingual usage documentation.
## Validation and acceptance
Baseline the globally installed `qwen` before edits. Verify the local bundle
after implementation, including a packaged layout outside the repository.
Acceptance requires parameter handling, absent/mismatched runtime refusal,
single-directory locking, secret permissions and persistence, private listener
authentication, production UI bootstrap, Workspace creation, one real local
worker file/Shell turn and a second turn in the same Session, graceful restart
with retained history, and failure cleanup. Use a deterministic model fixture
when provider credentials are unavailable and label that evidence precisely.
Run focused CLI/WebShell tests, root build/typecheck/bundle, Java `mvn verify`,
and persistent H2 restart/lock/rollback/schema-refusal tests. Preserve MySQL
integration regression coverage where available and do not report H2 as MySQL
validation. Linux/macOS qualification is separate; this machine can validate
only its native target. Record actual results and limitations in the E2E plan.
For the split distribution, verify archive contents and checksum without a Node
or CLI build input, automatic discovery under an isolated Qwen home, explicit
and colocated precedence, and refusal of selected invalid payloads before
state or children are created. Run a real native-worker Session from an
ordinary installed Qwen outside the source tree; prove Java comes from the
separate runtime while Harness, workers and WebShell use that local Qwen.
Verify that a Harness batch waiting behind a committed event cannot reuse or
decrease its sequence, with and without a public projection, and retain MySQL
store regressions for the shared locking change.
For lightweight delivery, verify a JAR/manifest-only archive, both JAVA_HOME
and PATH selection, refusal of absent/wrong Java before application state,
and no system-Java fallback for broken bundled payloads. Repeat the installed
CLI lifecycle test with external Java 21 and measure actual archive/payload
bytes against the bundled runtime. Existing Java server behavior is unchanged.
The separate runtime passed this lifecycle plan with an ordinary npm CLI 0.25.3
installation and the existing Node 22.14.0 outside the source tree. Automatic
Qwen-home discovery and selected-payload refusal passed. Process paths confirm
Java comes from the separate runtime while Harness and workers use that Node
and npm CLI; served WebShell bytes match the npm installation. H2 and MySQL 8.4
concurrency regressions passed for the Session/stream locking change.
The macOS arm64 archive passed the complete local lifecycle plan outside the
source tree with no Java on PATH: file and Shell approval, streaming, subsequent
turns, restart with retained context, cancellation, Session close, child-failure
cleanup, and missing-marker refusal. A browser check confirmed production
Managed UI bootstrap and same-origin routing. The model was a deterministic
OpenAI-compatible fixture; tools used real native workers. Real-provider
inference and other platform targets remain unqualified.
The lightweight macOS arm64 archive passed the installed-CLI lifecycle with
external Java 21 and the same deterministic model fixture. Fifteen selection
checks passed, including JAVA_HOME/PATH success, wrong/missing Java refusal,
preservation of existing state, and no system fallback for broken bundled or
legacy payloads. The ordinary npm package contains no JAR or JRE. The lightweight
archive measures 78,299,321 bytes compressed and 86,278,025 payload-file bytes;
the bundled runtime measures 278,836,538 and 437,061,513 bytes respectively.
This change reuses the previously verified server JAR without Java source changes.
In the original local-delivery trial, root build/typecheck/bundle, focused
CLI/WebShell/packaging tests, persistent H2 tests, Java unit tests, and SpotBugs
passed. That trial's full Maven verification was blocked by two MySQL integration
assertions and six Checkstyle violations in unchanged files on its original
base. Those baseline failures remain recorded separately from local acceptance;
they do not describe the current main branch. The results above belong to the
pre-split trial. Current split-head verification and limitations are recorded
in the PR test report.
The first-use download path passed fourteen built-bundle scenarios with the real Java 21 server and Hosted Harness: initial installation, offline restart, OSS-to-GitHub fallback, interrupted-download retry, and refusal of invalid downloads or selected payloads. Transport was intercepted at the exact production URLs to serve the locally built JAR; public production assets have not been published or validated. The release profile builds the primary executable JAR, sources, and Javadoc locally; signed Central publication and protected GitHub/OSS upload require a production release run.
## Open questions
No blocking design decision remains for this delivery. Future release automation
may download verified JRE archives at build time. Local project import uses the
existing explicit registration workflow; startup does not import a project
without a user selection.
@@ -0,0 +1,254 @@
# Managed 本地一键启动
[English](2026-10-20-managed-local-serve.md) | [简体中文](2026-10-20-managed-local-serve.zh-CN.md)
状态:本地 serve、首次 JAR 下载与发行集成已实现;生产发布待执行。日期:2026-10-21。
## 问题与现状
Hosted Managed Session 已有 Java 控制面、内嵌 Runtime Broker、私有 Node Hosted
Harness 和原生本地工具 worker。参考部署需要单独配置 MySQL、Harness、凭据和生产
WebShell 接线;开发启动器依赖源码、Maven 和 Vite。两者都不是用户可以一条命令
启动的本地应用。
H2 MySQL 模式已在测试中运行规范 Flyway schema 和 JDBC store。原生 worker 支持
Linux 和 macOS;macOS 支持同一次系统启动内接管,不支持可信整机重启恢复。
systemd 是可选的运维部署选择。
## 目标与范围
Managed 本地模式运行 `qwen serve --managed --open`,无需 MySQL、Docker、
systemd、Maven 或源码。默认首次安装仅下载匹配的服务 JAR,要求本机 Java 21。
独立安装的 managed-runtime archive 可同时提供匹配的 Java 服务 JAR 和
Java 21 runtime,复用本地安装的 Qwen CLI、它的 Node 进程、
Hosted Harness、原生 worker 入口及编译好的 WebShell。可选的完整 standalone
发行包包含所有组件。轻量 archive 不带 Java,要求本机已安装 Java 21。
首批目标为 x64、arm64 的原生 Linux 和 macOS;本次不交付
Windows 和 Bun。
这是单个可信用户使用的 loopback 应用,每个私有数据目录只有一个 Java 控制面。
保留 SQL 所有权、租约、fencing、幂等、原 Runtime 路由和未知结果语义。MySQL
参考部署沿用现有默认配置。公网多用户托管、HA、SSH/Kubernetes 配置和恢复事实
自动修复不在范围内。
初始 Workspace 由应用管理。用户可通过“工作区 → 选择已有目录”按绝对路径
显式导入已有本机项目。启动器在回环签名认证下启用已有的[本机目录导入](2026-10-21-managed-local-directory-import.zh-CN.md)
能力,沿用存储登记、OWNER 授权、物理身份校验和恢复流程。文件保留原处,
维护标记位于 Git 工作树之外。启动目录不会自动导入。
## 组件方案
| 组件 | 职责 |
| ------------------------------ | --------------------------------------------------------------------------------------------------------- |
| CLI Managed 启动器 | 解析发行 runtime、锁定私有数据、保留密钥、启动与监督子进程、等待就绪、通过现有 API 初始化并提供浏览器地址 |
| `managed-local` Spring profile | 持久化嵌入式 H2、loopback 与私有目录校验、规范迁移、本地进程配置 |
| 普通 daemon 前门 | 托管发行静态资源与普通 daemon API/传输、认证浏览器、以服务端签名身份代理 Java WebShell API |
| 融合 standalone WebShell | 读取认证后的启动选项、创建现有 Java provider、在当前 origin 使用共享工作区与会话导航 |
| Managed 发行构建器 | 独立打包 JAR 和 JRE,或将其加入完整 standalone 包;记录版本与校验和,保留 runtime 许可证 |
### 启动与持久化
`--managed` 是延迟加载、显式启用的应用启动器。同时启动普通 daemon、Java 和私有
Harness,拒绝冲突的执行 profile 或参数。普通 daemon 拥有公开的 loopback 监听,
始终要求 bearer token。参见[融合 WebShell 设计](2026-10-21-managed-daemon-web-shell.zh-CN.md)。
`--port`、`--open`、`--open-with-auth` 沿用现有含义。
`--managed-data-dir` 可指定私有状态目录,默认位于用户 Qwen home 下。相同目录的
并发启动器会拒绝进入。
私有目录包含稳定安装身份、分别独立的公开入口、Harness、Broker、签名、writer
binding、Runtime credential 密钥,以及文件数据库、Runtime 状态、持久 worker
文件历史、应用管理的 Workspace 创建根目录和服务日志。目录仅所有者可访问,
凭据文件仅所有者可读写。启动不轮换凭据、不删除持久事实;损坏或不完整的凭据
文件会拒绝启动。密钥不出现在进程命令参数或浏览器启动选项中。
使用 loopback 监听启动私有 Hosted Harness 和 Java 进程。当前 Qwen 进程将其规范
CLI 入口和 Node 可执行文件传给 Harness 与 worker,不通过 PATH 寻找另一份 CLI。
runtime archive 必须与该 CLI 版本精确匹配。Harness 使用用户正常的 Qwen 模型
配置;worker 仅执行工具。文件与 Shell
沿用现有 `default` 审批模式。显式开启已有的 `hosted-workspace-shell/1` 生命周期
profile,并在 Harness 认证 capability 检查中要求它存在,使 Session 关闭能够结算
Shell 资源。Java readiness 和这些 capability 检查成功后才开放公开前门。
内置 `qwen-code` agent 使用用户配置的模型。通过现有签名
API,以稳定幂等键创建默认 Workspace。重试重放原 receipt,不生成替代身份。
私有旧式 Workspace ID 沿用规范目录的既有哈希规则,应用管理的 Workspace ID
则由现有 API 生成。
H2 使用私有文件数据库和 MySQL 模式,启用 `DATABASE_TO_LOWER=TRUE`、
`DB_CLOSE_ON_EXIT=FALSE`、`WRITE_DELAY=0`。不启用 TCP、`AUTO_SERVER` 或 Web
console。H2 文件锁提供额外的单控制面保护。保留现有全新 catalog、Flyway 和共享
schema 校验,继续拒绝外来、部分、仅历史表或不支持的 catalog。现有 resource kind
名称是持久协议标识,inline bytes 存入 H2 时也保留名称。
控制面修改先锁定 Session 行,再通过独立的锁定查询读取其事件流计数。H2 外连接
在等待锁之后可能只刷新 Session 行,仍保留等待前的旧计数。重新读取事件流使事件
分配和修改尝试边界保持最新,任何 batch 都不得回退已提交的事件流计数。
### 浏览器与 API 所有权
服务端在 HTML 放入公开的 Managed 模式标记。浏览器从认证后的
`GET /managed/boot` 获取 `mode`、`productScope`、`agentId` 和 Workspace 绑定
选项,不包含 tenant、actor、私有 endpoint 或密钥。Managed 模式忽略 `?daemon=`
和 fanout 目标,provider 固定使用页面 origin。
`GET /capabilities` 是认证后的进程级浏览器认证探针,返回普通 daemon 的真实能力
与 Workspace。认证 boot 创建 Java provider,传给正常 standalone WebShell 已有
的共享项目导航,不再提供执行方式 tab。Local 会话使用启动目录,这不代表把该目录导入 Managed
存储。
代理 API 面限于 `/api/agent/web-shell/v1/**`,以及精确的附件内容 GET/HEAD 路径
`/v1/agents/sessions/:sessionId/artifacts/:artifactId/content`,后者是现有 Java
client 所需路径。前门清除调用者身份和私有认证头,
使用服务端固定 tenant、actor 对经过大小限制的原始请求字节签名。持久 Workspace
和实时 Session 授权继续由 Java 校验。SSE 流式转发、保留背压,客户端断连即中止
上游。前门不暴露内部 writer、Runtime Broker、维护接口或其他 Java 路由。
### 生命周期与故障
启动器仅跟踪自己创建的子进程并独占进程信号,禁用内嵌 daemon 的信号处理。
SIGINT/SIGTERM 关闭 daemon 与传输、停止新接入,然后在
有界等待内优雅停止 Java 和 Harness。两个子进程分别使用独立进程组,使终端信号
先由启动器处理。强制停止仅针对 Java 进程和 Harness 进程组,持久 worker 保留
既有所有权。公开监听绑定后再次检查子进程失败和中断,确认后才报告启动成功。
启动失败会清理已创建子进程并释放锁,不
删除无关进程或私有数据。子进程意外退出时关闭应用并返回非零状态。不盲目重启或
重放未知副作用。原持久 worker 和 SQL 恢复保留既有所有权规则。macOS 显式禁用
可信整机重启恢复。
重启后的 Session 可能需要等待原 SQL writer 租约到期(默认 60 秒);进程退出
不会释放或缩短租约。存储标记缺失时,仍可启动 UI 和重放原 Workspace 创建
receipt;受影响的绑定工具 Turn 会在 Runtime 接入时返回 `workspace_unavailable`,
不会重建标记或改去其他位置执行工具。
本次不承诺阻止笔记本睡眠或登录时自动启动。普通前台使用只需保持终端打开,
运维之后可自行选择服务管理器。
### 发行与 runtime 定位
独立 `qwen-code-managed-runtime-<target>.tar.gz` 仅包含
`managed-runtime/<cli-version>/<target>/manifest.json`、`server.jar` 和 `jre/`。
保留 Java 许可证,不包含 Node、CLI、Hosted Harness、worker 副本或 WebShell
资源。用户将其解压到 Qwen home,本地 `qwen serve --managed --open` 自动发现
精确匹配的版本和平台目录。模型仍通过已安装的 CLI 配置。
轻量 `qwen-code-managed-runtime-lite-<target>.tar.gz` 只包含相同的
`manifest.json` 和 `server.jar`。构建时显式选择 `--without-jre`,与
`--jre-home` 互斥。安装到相同的版本/平台目录,用户选择其中一种版本,不叠加解压。
manifest 用 `javaSource: system` 表示轻量发行,`javaSource: bundled` 表示带 Java
发行。旧 manifest 缺少此字段时仍按内置 Java 处理,未知值拒绝启动。
轻量发行在 `JAVA_HOME` 非空时只使用其 `bin/java`;否则解析 PATH 中第一个可执行
`java` 的规范路径。用有时间和输出上限的 Java 属性/版本命令探测该可执行文件,
要求 `java.specification.version=21`,然后才创建应用状态或服务子进程。Java
缺失、无法执行或不兼容时给出可操作的错误。不自动安装、下载或寻找另一个 Java
版本。带 Java 发行仍要求自身 runtime,JRE 缺失或损坏绝不回退本机 Java。服务
启动使用已验证的同一可执行文件。
manifest 绑定 CLI 版本、目标平台、Java 来源、Java 主版本和服务摘要。显式设置
`QWEN_MANAGED_LOCAL_RUNTIME_DIR` 时仅选择该 payload。否则优先使用存在的相邻
`managed-runtime` 目录;相邻目录缺失时才查找 Qwen home 下的版本化安装。选中的
payload 损坏或不完整会拒绝启动,不尝试其他位置。在创建私有状态或子进程前完成
兼容性校验。不包含该启动器的旧 Qwen 版本需要先升级 CLI;仅安装 runtime 不能
补上缺失的 CLI 行为。
现有完整 Managed standalone 构建器保留可选 JAR/JRE 输入和
`lib/managed-runtime/` 布局。两个构建器共用 payload 复制和校验逻辑。独立
runtime 构建不要求编译好的 CLI 资源或 Node archive,默认将 archive 输出到 CLI
资源树之外。缺失 payload 的错误会说明
期望的版本、平台和安装位置。既无选中的 payload、也无版本化缓存时,启动器会先
验证本机 Java 21,再安装匹配的服务 JAR。离线用户可预装任意一种 archive,或选择
显式 runtime 目录。发布 archive 或 npm 包仍是
独立发行操作。
### 首次下载与发布
普通 CLI 包不包含 Java payload。仅 `qwen serve --managed` 会下载缺失的默认
runtime。版本化发行产物为 `qwen-managed-agent-server-<cli-version>.jar` 及匹配的
`.sha256` 文件。安装器优先使用现有官方阿里云 OSS 发行前缀,再尝试相同 GitHub
Release tag。不选择 `latest`,不解析 Maven 依赖,也不下载 Java。两个下载源都必须
提供精确匹配 CLI 版本的产物。
按版本和平台设置安装锁,在多个进程间串行化下载。安装器将内容流式写入私有的
临时相邻目录,限制下载时间与大小,校验 JAR SHA-256 和 ZIP 文件头,写入现有的
`javaSource: system` manifest,再原子重命名到缓存。下载失败或被中断不会发布部分
runtime,后续调用可以重试。已有显式、相邻或已安装的 payload 仍是权威来源:
内容无效时拒绝启动,不静默替换或触发下载。缓存启动无需联网。安装发生在创建
应用凭据、数据或服务子进程之前。
独立的受保护发行 workflow 从已存在的 CLI release tag 构建,检查 package 版本,
并使用 Java 21 验证 Java SDK 和服务。服务 JAR 沿用 SDK 的 Central 签名发布约定,
以独立 Maven 坐标 `com.alibaba:qwen-managed-agent-server:<cli-version>` 发布,
同时提供源码和 Javadoc。辅助运维可执行 JAR 不附加到这次发布。完全相同的主 JAR
和校验文件附加到 CLI GitHub Release,再复制到现有版本化 OSS 前缀,位于
standalone archive 允许列表之外。可重现时间戳和校验和比较防止重试时用不同字节
替换已有版本。dry-run 只构建和验证,不发布。生产凭据与发布操作继续受现有
`production-release` environment 保护。
Maven Central 用于 Java 制品发布,不作为 CLI 启动下载依赖。按照 [Sonatype 对
runtime 制品的指导](https://central.sonatype.org/faq/429-operational-dependencies/),发行下载使用项目自身的 OSS/GitHub 渠道。新 CLI 版本必须先
发布匹配的 runtime 产物,其 Managed 首次使用流程才能成功;发行产物不可用时,
错误说明所需版本,并保留离线安装方式。
## 影响文件
- `packages/cli/src/serve/managed-local-download.ts` 及定向安装测试;`.github/workflows/release-managed-runtime.yml`、`scripts/managed-runtime-release.js` 及发布契约测试。
- `packages/cli/src/commands/serve.ts`、新的本地 Managed 启动器/前门模块及定向测试。
- `packages/cli/src/serve/web-shell-static.ts` 及相关测试。
- `packages/web-shell/client/main.tsx`、Managed 独立启动组件及测试。
- `packages/managed-agent-server/pom.xml`、本地 Spring profile/配置、Session 事件流的最新锁定读取,以及文件数据库/并发测试。
- `scripts/create-standalone-package.js`、独立 runtime 构建器、Managed 打包辅助模块/测试和双语使用文档。
## 验证与验收
修改前用全局 `qwen` 记录基线。实现后验证本地 bundle,包括源码仓库外的发行布局。
验收覆盖参数处理、缺失/不兼容 runtime 拒绝、同目录互斥、凭据权限和持久化、私有
监听认证、生产 UI 启动、Workspace 创建、一次真实本地 worker 文件/Shell Turn
与同 Session 第二轮、优雅重启后的历史保留,以及故障清理。无 provider 凭据时
使用确定性模型 fixture,并明确标注证据范围。
运行定向 CLI/WebShell 测试、根目录 build/typecheck/bundle、Java `mvn verify`,
以及文件 H2 重启、锁、回滚和 schema 拒绝测试。可用时保留 MySQL integration
回归验证,不以 H2 结果代替 MySQL 验证。Linux/macOS 分别验收;当前机器只验证
其原生目标。实际结果和限制记录在 E2E 计划中。
拆分发行需验证不依赖 Node 或 CLI 构建输入的 archive 内容与校验和、隔离 Qwen
home 下的自动发现、显式与相邻 payload 的优先级,以及选中无效 payload 时在创建
状态或子进程前拒绝。使用源码树外安装的普通 Qwen 运行真实原生 worker Session,
证明 Java 来自独立 runtime,而 Harness、worker 和 WebShell 使用该本地 Qwen。
验证等待已有事件提交的 Harness batch 不会重用或回退事件序号,分别覆盖有、无公开
投影的 batch,并对共用锁定读取改动保留 MySQL store 回归验证。
轻量发行需验证 archive 只有 JAR 和 manifest、JAVA_HOME 与 PATH 两种定位方式、
Java 缺失/版本错误在应用状态创建前拒绝,以及带 Java payload 损坏时不回退本机
Java。用外部 Java 21 重跑已安装 CLI 的生命周期测试,测量实际 archive/payload
字节并与带 Java runtime 对比。现有 Java 服务行为不变。
独立 runtime 已使用源码树外安装的普通 npm CLI 0.25.3 和现有 Node 22.14.0 通过
上述生命周期验收。Qwen home 自动发现和选中无效 payload 时拒绝均通过。进程路径
确认 Java 来自独立 runtime,Harness 与 worker 使用该 Node 和 npm CLI;托管的
WebShell 字节与 npm 安装一致。Session/事件流锁定读取改动的 H2 与 MySQL 8.4
并发回归也已通过。
macOS arm64 archive 已在源码树外、PATH 中无 Java 的条件下通过完整本地生命周期
验收:文件和 Shell 审批、流式响应、后续轮次、重启后的上下文保留、取消、Session
关闭、子进程故障清理和存储标记缺失拒绝。浏览器检查确认生产 Managed UI 启动和
同源路由。模型使用确定性 OpenAI 兼容 fixture,工具由真实原生 worker 执行。
真实 provider 推理和其他平台目标尚未验收。
轻量 macOS arm64 archive 使用外部 Java 21 和相同确定性模型 fixture,通过已安装
CLI 的生命周期验收。十五项选择检查通过,包括 JAVA_HOME/PATH 正向、Java 缺失或
版本错误拒绝、保留已有状态,以及损坏的带 Java 或旧 payload 不回退系统 Java。
普通 npm 包不含 JAR 或 JRE。轻量 archive 压缩后为 78,299,321 字节,payload 文件
合计 86,278,025 字节;带 Java runtime 分别为 278,836,538 和 437,061,513 字节。
本次复用此前验证的服务 JAR,没有改动 Java 源码。
在原始本地交付试验中,根目录 build/typecheck/bundle、定向 CLI/WebShell/打包测试、
持久 H2 测试、Java 单元测试和 SpotBugs 通过。该次完整 Maven 验证被原始基线中
未修改文件的两项 MySQL 集成断言和六项 Checkstyle 问题阻塞。这些基线失败仍与
本地验收分别记录,不代表当前 main 分支状态。上述结果属于拆分前试验;当前
拆分 head 的验证和限制记录在 PR 测试报告中。
首次下载路径使用真实 Java 21 服务与 Hosted Harness,通过十四项 built-bundle 场景:首次安装、离线重启、OSS 转 GitHub 回退、中断下载后重试,以及无效下载和选中 payload 拒绝。测试在精确生产 URL 处拦截 transport,提供本地构建的 JAR;公开生产产物尚未发布或验证。发布 profile 已在本地构建主可执行 JAR、源码与 Javadoc;Central 签名发布及受保护的 GitHub/OSS 上传仍需执行生产发行。
## 未决问题
本次交付无阻塞设计决策。后续发行自动化可在构建时下载并验证 JRE archive。
本机项目导入复用已有的显式登记流程,启动时不会未经用户选择自动导入项目。
@@ -0,0 +1,123 @@
# Unified local daemon and Managed WebShell
[English](2026-10-21-managed-daemon-web-shell.md) | [简体中文](2026-10-21-managed-daemon-web-shell.zh-CN.md)
Status: unified startup and shared workspace navigation implemented; native macOS arm64 acceptance uses a deterministic model. Date: 2026-10-21.
## Problem and scope
Before this change, the local launcher in #14928 started Java, a private Hosted Harness and a
Managed-only WebShell. Users also need the ordinary daemon's Local sessions in
the same application, started with one command. The existing WebShell already
supports Local and Managed navigation when supplied with a Java provider.
`qwen serve --managed --open` supervises the ordinary daemon, Java and the
private Harness, exposing one authenticated loopback origin. Ordinary
`qwen serve` keeps its current behavior. Runtime installation, H2 persistence,
Managed Workspace ownership and existing recovery constraints remain in force.
## Implementation
The launcher waits for Java and Harness readiness and creates the default
Managed Workspace before starting the ordinary daemon through `runQwenServe`.
The daemon uses its normal current-directory Workspace and persistent public
bearer. Its built-in HTTP, SSE and WebSocket transports remain the sole owner of
ordinary Session, terminal, voice and workspace routes. This avoids adding a
second proxy for the daemon's transport and security boundaries.
The ordinary ACP child starts on the first Local use, rather than preheating
while application readiness is being reported. This avoids an initialization
shutdown race for a user who starts and immediately stops the application, and
does not create an unused Local model process for Managed-only work.
The launcher's private dependency configuration mounts the existing Managed
boot and signed Java proxy routes after daemon authentication and before JSON
body parsing. `/managed/boot` is process-scoped. The Java WebShell and exact
artifact-content routes retain Java tenant, actor, Workspace and Session
authorization. They never fall back to ordinary daemon handlers. `/capabilities`
returns the actual daemon envelope. Internal Java, writer, Broker and maintenance
routes remain private.
The server-owned HTML marker enables authenticated same-origin Managed boot.
The browser constructs the existing Java provider using only validated public
boot values and the public bearer, and supplies it to the normal standalone
WebShell. A shared workspace navigation controls both kinds of Session.
Caller-supplied daemon targets, identity fields and Java endpoints cannot change
the Managed provider. Managed URL restoration must not be blocked by stale Local
Workspace or Session navigation parameters.
The launcher owns process signals; embedded daemon signal handlers are disabled
for this caller. Shutdown closes daemon admission and transports before stopping
Java and Harness. An unexpected daemon listener close or owned child exit ends
the application with a nonzero result. Failed startup cleans up owned services
and releases the private data lock. Existing durable worker ownership survives.
## Shared workspace navigation
The Local/Managed execution tabs are removed. Ordinary and Managed workspaces appear in the existing Projects navigation, with Managed workspaces labelled Managed. Each workspace contains its own sessions. Selecting a session selects its existing provider; creating a task from a workspace uses that workspace's provider. The top New task action follows the selected conversation or draft. Ordinary workspaces, projectless sessions and their existing actions remain available while a Managed conversation is open.
The sidebar provides an internal slot inside the existing workspace list. The Managed page keeps its single session catalog, pagination and refresh lifecycle and projects its navigation into this slot. The slot receives the common search text. Managed workspace discovery uses the existing provider's workspace binding API; no Java, SDK protocol or daemon route changes are required. Workspace and session identity retain the provider storage key and server IDs. A Managed workspace is never represented by an ordinary daemon cwd, even if display names or IDs match.
Managed workspace selection feeds the existing creation component, retaining its workspace validation, edited-directory confirmation, pending-command recovery and authorization checks. An explicit workspace that cannot admit sessions stays unavailable; it cannot silently select another workspace or the ordinary primary. New workspaces and sessions refresh the shared catalog. Pagination remains explicit for both Managed workspace discovery and the globally paged Managed session list. Sessions whose workspace is not on the current workspace page remain reachable under their server workspace identity.
Both conversation components remain mounted so navigation preserves drafts and ongoing work. Managed deep links and explicit ordinary links retain their existing URL contracts. Ordinary permissions, filesystem, Git, trust, deletion and runtime controls are never offered as actions on Managed rows. The shared visual navigation does not migrate conversations, combine storage or add cross-provider operations.
A selected session's workspace resolution distinguishes pending, confirmed bound and confirmed legacy-unbound states. History replay to a different session invalidates the previous workspace before New task is allowed; replay or completion of the same session preserves its confirmed binding.
Explicit navigation and asynchronous command completion have separate callbacks. Clicking Managed history opens that conversation; a Managed create or submit that finishes after the user selects an ordinary conversation only updates its recorded Managed selection. Workspace creation uses the explicit choice described in [Workspace execution choice](2026-10-21-unified-workspace-navigation.md). Completing an open shared creation dialog selects the created workspace; dismissed or background operations do not switch the visible provider.
Acceptance requires no execution tab, simultaneous navigation for both providers, correct workspace grouping including empty and paged workspaces, provider-scoped selection, task creation and drafts, refresh and deep-link restoration, shared search, and explicit Managed errors without ordinary fallback. Existing ordinary-only and Managed-only embeddings must remain usable.
## Affected components and boundaries
- CLI local launcher, Managed proxy middleware and daemon embedding dependencies.
- WebShell authenticated boot, provider injection, shared workspace navigation and creation/selection regression tests.
- Existing local-delivery design and user instructions in both languages.
No new hosting mode, public Java endpoint, cross-provider Session conversion,
remote-daemon Managed fanout or release publication is included. The ordinary
daemon can use the launch directory. Managed work uses an application-created
Workspace or a project explicitly imported through the existing local directory
import capability, enabled by the loopback signed launcher.
The existing full-shell Mobile access / Local Control affordance is not
integrated: its paired LAN listener cannot pass the primary-listener Managed
boot gate. This change qualifies only the loopback application; adapting or
suppressing that affordance is follow-up work. Ordinary daemon Local Control
semantics are unchanged.
## Validation and acceptance
Run the global CLI first to record the unsupported baseline, then validate the
local bundle with isolated runtime/data directories. Confirm one listener serves
real daemon capabilities, ordinary Workspace APIs and authenticated Managed boot;
the UI presents both providers in one workspace navigation without execution tabs. Exercise an ordinary
Session and a Managed Session with deterministic model fixtures, refresh and
restore both, verify streaming and the ordinary ACP WebSocket, and stop the
launcher. Check that startup refusal and owned-service failure clean up only
owned services and retain durable data.
Focused tests cover raw-byte Java signatures, route ownership and refusals,
public boot authentication, provider origin/token binding, stale Local navigation,
launcher cleanup and the embedded signal policy. Run root build, typecheck and
bundle, affected CLI/WebShell tests, formatting and lint. The Java authorization
change for signed local-directory import is covered by its
[separate design](2026-10-21-managed-local-directory-import.md); report any
unavailable runtime or browser checks.
Acceptance passed with the rebuilt CLI and matching existing Java runtime on
macOS arm64: both Session types completed two turns, retained separate drafts,
and restored after refresh; a stale Local deep link restored Managed history.
Authenticated HTTP, raw-byte signatures, SSE and ACP WebSocket checks passed.
Normal and immediate shutdown, occupied-port fallback, restart persistence,
owned-service failure and missing-runtime refusal passed, with owned test
services cleaned up. Root build, typecheck and bundle, 215 relevant unit tests,
focused lint, formatting and documentation references passed. Model responses
were deterministic fixtures. Linux lifecycle, real-provider inference and
reboot recovery were not qualified by this change.
The shared-navigation revision passed 431 focused frontend tests, including workspace/session pagination, explicit selection refusals, pending creation, edited-directory confirmation, hidden Local ownership and background command completion. Browser acceptance exercised same-named ordinary and Managed workspaces and histories, independent drafts, actual new tasks in both providers, empty-workspace drafts, shared search, refresh with stale Local routes and browser back/forward. API read-back confirmed the new Managed task's exact workspace and the ordinary task's original cwd. Model calls used the isolated deterministic fixture. The initial exact-text wait for a new prompt was corrected to the fixture's actual `Fixture reply:` protocol; it was not a product failure. A targeted background-completion test first reproduced a navigation regression and passed after separating explicit navigation from result recording.
## Open questions
None required for this scope. Publishing matching runtime archives and additional
platform lifecycle qualification remain separate from this local implementation.
@@ -0,0 +1,103 @@
# 融合本地 daemon 与 Managed WebShell
[English](2026-10-21-managed-daemon-web-shell.md) | [简体中文](2026-10-21-managed-daemon-web-shell.zh-CN.md)
状态:统一启动与共享工作区导航已实现;原生 macOS arm64 验收使用确定性模型。日期:2026-10-21。
## 问题与范围
本次改动之前,#14928 的本地启动器运行 Java、私有 Hosted Harness 和仅支持 Managed 的 WebShell。
用户还需要一条命令启动普通 daemon,并在同一个应用中使用 Local 会话。现有
WebShell 在传入 Java provider 后已经支持 Local 与 Managed 导航。
`qwen serve --managed --open` 统一监督普通 daemon、Java 和私有 Harness,公开
一个需要认证的 loopback origin。普通 `qwen serve` 保持现有行为。继续遵循 runtime
安装、H2 持久化、Managed Workspace 所有权与既有恢复限制。
## 实现方案
启动器等待 Java 与 Harness 就绪、创建默认 Managed Workspace,然后通过
`runQwenServe` 启动普通 daemon。daemon 使用正常的当前目录 Workspace 和持久公开
bearer。其内置 HTTP、SSE 与 WebSocket 传输继续独占普通 Session、终端、语音和
Workspace 路由,避免为 daemon 的传输与安全边界再实现一层代理。
普通 ACP 子进程在首次 Local 使用时启动,不在报告应用就绪时并行预热。这样用户
刚启动便立即停止应用时,不会遇到初始化期间的退出竞态;仅使用 Managed 时也不
创建闲置的 Local 模型进程。
启动器通过私有依赖配置,在 daemon 认证之后、JSON body 解析之前,挂载既有
Managed boot 与签名 Java 代理路由。`/managed/boot` 属于进程级作用域。Java
WebShell 与精确附件内容路由保留 Java 的 tenant、actor、Workspace 与 Session
授权,绝不回退普通 daemon handler。`/capabilities` 返回真实 daemon 能力。
Java 内部、writer、Broker 和维护接口继续保持私有。
服务端 HTML 标记启用需要认证的同源 Managed boot。浏览器仅使用经过校验的公开
启动值和公开 bearer 创建现有 Java provider,并传给正常的 standalone WebShell。
共享工作区导航控制两类会话。调用者提供的 daemon 目标、身份字段或 Java
endpoint 不能修改 Managed provider。恢复 Managed URL 时,不能被过时的 Local
Workspace 或 Session 导航参数阻断。
启动器独占进程信号,本调用禁用内嵌 daemon 的信号处理。退出时先关闭 daemon
接入与传输,再停止 Java 和 Harness。daemon 监听意外关闭或所属子进程退出时,
以非零结果结束应用。启动失败清理所属服务并释放私有数据锁。持久 worker 继续
遵循既有所有权。
## 共享工作区导航
移除 Local/Managed 执行方式 tab。普通与托管工作区进入现有“项目”导航,托管工作区带有“托管”标识,每个工作区包含自己的会话。选择会话沿用其所属 provider,从工作区新建任务则使用该工作区的 provider。顶部“新建任务”跟随当前会话或草稿。打开托管会话时,普通工作区、无工作区会话及其既有操作仍然可用。
侧边栏在现有工作区列表内提供内部插槽。托管页面保留单一的会话目录、分页和刷新生命周期,并将导航投影到该插槽;插槽接收共同的搜索文本。托管工作区发现使用已有 provider 的 workspace binding API,无需修改 Java、SDK 协议或 daemon 路由。工作区和会话身份保留 provider storage key 与服务端 ID。即使显示名或 ID 相同,也绝不把托管工作区伪装成普通 daemon cwd。
托管工作区选择接入已有创建组件,保留工作区校验、已编辑目录的切换确认、未决命令恢复和授权检查。显式选择的工作区若不能创建会话,应保持不可用,不能悄悄切换到其他工作区或普通 primary。新建工作区或会话会刷新共同目录。托管工作区发现与全局分页的托管会话列表均保留显式加载更多入口。会话所属工作区不在当前工作区页时,仍按服务端工作区身份提供可达入口。
两种会话组件保持挂载,使导航保留草稿和进行中的工作。托管深链与显式普通链接沿用现有 URL 约定。普通权限、文件系统、Git、信任、删除和运行时控制不会成为托管行的操作。共享界面导航不迁移会话、不合并存储,也不增加跨 provider 操作。
已选会话的工作区解析区分待确认、已确认绑定和已确认旧式未绑定三种状态。历史导航进入不同会话时,先使原工作区失效,再允许新建任务;重放或完成同一会话时保留已确认绑定。
显式导航与异步命令完成使用不同回调。点击托管历史会打开对应会话;如果托管创建或提交在用户选择普通会话后才完成,只更新已记录的托管选择。工作区创建使用[工作区运行方式选择](2026-10-21-unified-workspace-navigation.zh-CN.md)中的显式选项。统一创建弹窗保持打开时,完成创建会选中新工作区;已关闭或后台操作不会切换当前显示的 provider。
验收要求:没有执行方式 tab、两种 provider 导航同时可见、空工作区和分页工作区正确归组、选择与新建及草稿保持 provider 作用域、刷新和深链恢复正常、共同搜索生效,以及托管错误明确显示且不回退普通后端。仅普通或仅托管的现有嵌入方式仍需可用。
## 影响组件与边界
- CLI 本地启动器、Managed 代理中间件和 daemon 内嵌依赖。
- WebShell 认证启动、provider 注入、共享工作区导航与创建及选择回归测试。
- 双语本地交付设计和用户说明。
本次不增加托管模式、公开 Java endpoint、跨 provider 会话转换、远程 daemon
Managed fanout 或发行发布。普通 daemon 可使用启动目录。Managed 工作使用应用
创建的 Workspace,或通过已有本机目录导入能力显式导入的项目;回环签名启动器
会启用该能力。
完整界面已有的 Mobile access / Local Control 入口尚未接入:其配对 LAN 监听无法
通过 Managed boot 的 primary-listener 校验。本次仅验收 loopback 应用,调整或
隐藏该入口属于后续工作;普通 daemon 的 Local Control 语义不变。
## 验证与验收
先用全局 CLI 记录尚未支持的基线,再使用隔离 runtime/data 目录验证本地 bundle。
确认单个监听提供真实 daemon capabilities、普通 Workspace API 和认证后的
Managed boot,UI 在同一工作区导航中显示两种 provider 且没有执行方式 tab。使用确定性模型 fixture 分别
运行普通 Session 与 Managed Session,刷新并恢复两者,验证流式响应与普通 ACP
WebSocket,再停止启动器。检查启动拒绝和所属服务故障只清理所属服务,并保留
持久数据。
定向测试覆盖 Java 原始字节签名、路由所有权与拒绝、公开 boot 认证、provider
origin/token 绑定、过时 Local 导航、启动器清理与内嵌信号策略。运行根目录 build、
typecheck、bundle,受影响 CLI/WebShell 测试,以及格式和 lint。回环签名本机目录
导入涉及的 Java 授权改动见其[独立设计](2026-10-21-managed-local-directory-import.zh-CN.md);
明确报告无法运行的 runtime 或浏览器检查。
macOS arm64 上重新构建的 CLI 与匹配的既有 Java runtime 已通过验收:两类 Session
均完成两轮对话,保留独立草稿并在刷新后恢复;带有过时 Local 参数的链接能恢复
Managed 历史。认证 HTTP、原始字节签名、SSE 与 ACP WebSocket 检查通过。正常与
立即退出、端口占用后重试、重启持久化、所属服务故障与缺失 runtime 拒绝均通过,
所属测试服务已清理。根目录 build、typecheck、bundle、215 项相关单元测试、定向
lint、格式和文档引用检查通过。模型响应来自确定性 fixture;本次未验收 Linux
生命周期、真实 provider 推理和整机重启恢复。
共享导航修订通过 431 项定向前端测试,覆盖工作区及会话分页、显式选择拒绝、未决创建、已编辑目录确认、隐藏 Local 所有权与后台命令完成。浏览器验收覆盖同名普通及托管工作区与历史、独立草稿、在两种 provider 中实际新建任务、空工作区草稿、共同搜索、带过时 Local 路由的刷新,以及浏览器前进和返回。API 回读确认新托管任务的精确工作区及普通任务原有 cwd。模型调用使用隔离的确定性 fixture。针对新 prompt 的首次精确文本等待已按 fixture 实际的 `Fixture reply:` 协议修正,这不是产品失败。一项定向后台完成测试先复现了导航回归,在区分显式导航与结果记录后通过。
## 未决问题
本范围没有必需的未决问题。匹配 runtime archive 的公开发行与更多平台的生命周期
验收独立于本地实现。
@@ -13,12 +13,19 @@ deployments, marker repair, and filesystem sandboxing are outside this change.
## Authority
Add `runtime-broker.local-directory-import-enabled`, default `false`. Startup
requires a loopback public listener, OPEN authentication with a configured
trusted actor header, Workspace files, durable local-process execution, and
verified recovery. This is an explicit single-user host capability. Publish
requires a loopback public listener, either SIGNED authentication or OPEN
authentication with a configured trusted actor header, Workspace files, durable
local-process execution, and verified recovery. This is an explicit single-user host capability. Publish
`localDirectoryImport` independently from existing directory selection and empty
Workspace creation. Every request still requires an authenticated tenant actor.
The single-user `serve --managed` launcher enables this capability in its
controlled Java environment. It retains browser bearer authentication, fixed
local tenant/actor identity, signed proxy requests and the private loopback Java
listener. Signed mode authenticates the actor through the existing signature
filter and does not accept a trusted-header substitute. Non-loopback import
listeners remain forbidden even with the insecure-bind override.
## Import lifecycle
Extend WebShell Workspace creation with an optional absolute `localPath`,
@@ -102,3 +109,8 @@ Targeted Java/MySQL and TypeScript/browser checks are recorded separately from
the interrupted full Java suite in `.qwen/e2e-tests/`. No unresolved product
decisions remain. Ordinary creation receipts omit the unused import field so
they remain readable by previous Java versions.
The local-launcher follow-up also verifies loopback SIGNED startup, retained
bearer/signature refusals, absolute-path browser import, an actual Read tool
turn and restart retention on native macOS. Detailed command results and test
artifact limitations remain in `.qwen/e2e-tests/managed-local-import.md`.
@@ -11,11 +11,16 @@ Workspace。受信任的本机单用户部署应在用户选择项目时自动
## 权限边界
新增 `runtime-broker.local-directory-import-enabled`,默认 `false`。启动时要求
公共监听地址为回环地址、OPEN 认证并配置受信任 actor 头,同时启用 Workspace
文件、持久化 local-process 执行和验证恢复。这是一项显式的本机单用户能力。
公共监听地址为回环地址,并使用 SIGNED 认证或配置受信任 actor 头的 OPEN 认证,
同时启用 Workspace 文件、持久化 local-process 执行和验证恢复。这是一项显式的本机单用户能力。
独立发布 `localDirectoryImport`,不与已有目录选择、空 Workspace 创建混用。
每次请求仍要求经过认证的 tenant actor。
单用户 `serve --managed` 启动器在受控 Java 环境中启用该能力。浏览器 bearer
认证、固定的本机 tenant/actor、代理请求签名和私有回环 Java 监听保持生效。
SIGNED 模式通过现有签名过滤器认证 actor,不接受可信请求头代替验签。
非回环监听即使启用 insecure-bind 覆盖也不能启用目录导入。
## 导入生命周期
WebShell Workspace 创建新增可选绝对路径 `localPath`,与 `sourceWorkspaceId`
@@ -81,3 +86,7 @@ Git 清理范围之外,项目本身作为初始工作目录。
浏览器重试,以及关闭新增导入后的重启。未在本地执行原生 Linux 验收。专项 Java/MySQL
和 TypeScript/浏览器检查与中断的 Java 全量测试分别记录在 `.qwen/e2e-tests/`。
没有未决产品选择。普通创建回执省略未使用的导入字段,继续支持旧 Java 版本读取。
本机启动器后续验收还覆盖原生 macOS 上的回环 SIGNED 启动、保留的 bearer/签名
拒绝、浏览器绝对路径导入、真实 Read 工具 Turn 和重启保留。详细命令结果与测试
产物限制记录在 `.qwen/e2e-tests/managed-local-import.md`。
@@ -0,0 +1,23 @@
# Model selection at Managed Workspace creation
[English](2026-10-22-managed-initial-model-selection.md) | [简体中文](2026-10-22-managed-initial-model-selection.zh-CN.md)
## Problem
The local launcher registered one custom AgentDefinition per configured model, with a compiled `default` approval policy. This made model selection restrict the new-task permission menu to `default`, although built-in `qwen-code` supports `default`, `auto-edit` and `yolo`.
## Decision
Publish local model presets as `{agentId: "qwen-code", modelId, label}`. Keep legacy `{agentId, label}` presets compatible. Selecting a model changes `modelId`, independently of Agent identity and approval mode. Existing stored definitions and Sessions retain their original identity and policy; no data migration is required.
Add optional `modelId` to bound WebShell creation and advertise `modelSelectionSupported` in Session options. Require built-in `qwen-code`, the existing Harness model-selection capability, and an exact host-catalog entry. Reject an unsupported choice before admission. Missing capability means the client cannot submit a catalog model. Preserve omitted-field request digests and default behavior.
Freeze the chosen model in the persisted client creation intention and server idempotency digest. In the existing creation transaction, write `model_id` and `model_revision=1` before inserting the first Turn, which snapshots that selection alongside approval. Empty Sessions also retain it. Same-key retries return the original admission before current capability gates; changed model content conflicts. Read back Agent, Workspace, model and initial approval before clearing the draft.
## Scope and risks
Reuse existing model-catalog validation, actor/Workspace checks, Turn snapshots and Harness execution. Do not replace an existing Session, modify custom-definition creation policy, change runtime authority, or create multiple requests that could send the first Turn before model/approval selection. Keep local definition allowlists for previously created custom Sessions. The new optional fields require a compatible control plane and frontend; update the local preview with matched artifacts.
## Verification
Verify two deterministic models under all three approval modes, including the first Turn's model and approval snapshots. Cover empty creation, omitted `modelId`, unknown models, missing capabilities, actor/Workspace refusal, frozen retry identity and different-body conflict. Exercise the UI model and permission selectors together, draft retention and readback, and confirm legacy custom presets remain supported. Run affected Java/TypeScript tests, API contract gates, build/typecheck/bundle, module verification and an isolated native smoke before updating the user preview.
@@ -0,0 +1,23 @@
# 托管工作区创建时选择模型
[English](2026-10-22-managed-initial-model-selection.md) | [简体中文](2026-10-22-managed-initial-model-selection.zh-CN.md)
## 问题
本地启动器为每个配置模型注册一个自定义 AgentDefinition,并将编译后的审批策略固定为 `default`。因此选择模型后,新建任务的权限菜单只剩 `default`,而内置 `qwen-code` 支持 `default`、`auto-edit` 和 `yolo`。
## 决策
将本地模型预设发布为 `{agentId: "qwen-code", modelId, label}`,兼容原有 `{agentId, label}` 预设。选择模型只改变 `modelId`,与 Agent 身份和审批模式独立。已有定义和会话保留原身份与策略,无需数据迁移。
为绑定工作区的 WebShell 创建请求增加可选 `modelId`,在会话选项中声明 `modelSelectionSupported`。要求使用内置 `qwen-code`、现有 Harness 模型选择能力,并精确匹配宿主模型目录。不支持的选择在准入前拒绝。能力缺失时,客户端不能提交目录中的模型。字段省略时,保持原请求摘要和默认行为。
将所选模型冻结在客户端持久化创建意图和服务端幂等摘要中。在现有创建事务内,先写入 `model_id` 与 `model_revision=1`,再插入首轮,使首轮同时保存模型和审批快照。空会话同样保留选择。同键重试在当前能力门禁前返回原准入;改变模型内容则冲突。清空草稿前读回并核对 Agent、工作区、模型和初始审批策略。
## 范围与风险
复用模型目录校验、用户与工作区授权、回合快照和 Harness 执行。不替换已有会话,不修改自定义定义的创建策略,不改变 Runtime 权限,不拆成多次请求而让首轮先于模型或权限选择发送。保留本地定义白名单,使已有自定义会话继续可用。新增可选字段需要匹配的控制面和前端;更新本地预览时使用匹配产物。
## 验证
以两个受控模型覆盖三种审批模式,检查首轮模型与审批快照。覆盖空会话创建、省略 `modelId`、未知模型、能力缺失、用户与工作区拒绝、冻结重试身份及不同请求内容冲突。同时操作 UI 模型和权限选择器,核对草稿保留与读回,并确认旧自定义预设兼容。更新用户预览前完成相关 Java/TypeScript 测试、API 契约检查、构建、类型检查、打包、模块验证及隔离原生测试。
@@ -0,0 +1,25 @@
# Local Managed model catalog
[English](managed-local-model-catalog.md) | [简体中文](managed-local-model-catalog.zh-CN.md)
## Problem and scope
The local `qwen serve --managed` adapter inherits the user's model configuration for Hosted Harness execution, but neither configures the Java model catalog nor supplies new-task model presets. The UI therefore shows only the server default despite configured local models. Main supplies the Session model catalog and approval contracts. This change connects the local launcher to them and adds optional initial model selection to WebShell creation, as detailed in [the creation design](2026-10-22-managed-initial-model-selection.md). It adds no provider credential transport, remote provisioning behavior, or changes to existing Sessions.
## Design
At startup, load the same deployment settings as the Hosted Harness: honor the selected `QWEN_HOME`, system settings and provider protocol declarations; ignore workspace settings and environment-file loading. Use the existing model registry to resolve configured routes. Include conversation models with exactly one matching ID across all registry protocols/routes, excluding the built-in OAuth catalog and specialized fast/image/voice/vision models. Resolve the default with the existing CLI generation-config resolver, including auth type and environment precedence. Put a uniquely matching model first; otherwise include the built-in `qwen-code` default preset so other configured providers cannot silently replace the current default. Keep the remaining models in configuration order. No configured models retains the existing default option. IDs shared by routes are excluded because the existing Hosted model pin intentionally refuses ambiguous IDs.
Pass only `{modelId,label}` entries and allowed model IDs to Java through a launcher-owned `SPRING_APPLICATION_JSON` array. Allow the existing `hosted-workspace-shell/1` definition profile. The launcher continues to filter inherited Spring and Managed server overrides and keeps the signed loopback boundary.
Use the built-in `qwen-code` Agent for new local tasks. Publish each configured model as a separate `modelId`; do not register a custom AgentDefinition whose compiled asking policy would constrain the permission menu. Keep definition model/profile allowlists for existing custom Sessions, and keep management routes private.
The authenticated, same-origin `/managed/boot` response supplies `{agentId: "qwen-code",modelId,label}` presets. The standalone client validates unique model choices and forwards them to the provider, while legacy `{agentId,label}` presets remain supported. New tasks select the host-catalog model and approval independently; a capability-gated creation request persists both before its first Turn. Existing Sessions use the separate Java Session model catalog and its idle/revision/Workspace-grant gates. Model pin resolution and cached credentials remain in Hosted Harness. Credentials, provider URLs and signing material are absent from the boot response, definitions and model catalog.
## Constraints and risks
The catalog is a startup snapshot; reload configuration by restarting the local service. Cached credentials are checked by existing execution logic, not by making provider requests at startup. Built-in OAuth-only models and ambiguous model IDs retain the existing default execution path but are not explicit choices. Removing a configured model does not rewrite historical definitions or Sessions; execution fails closed if a historical pin no longer resolves. Runtime workers retain their isolated `QWEN_HOME`; this change copies no settings or credentials into it. Browser storage and product identity remain unchanged.
## Validation and acceptance
Focused tests cover local settings scope, protocol routing and ambiguous IDs, specialized-model filtering, selected-model ordering, metadata-only serialization, independent model/approval selection, atomic creation/replay, unsupported admission, and boot validation. Build, typecheck and run the affected package tests. In an isolated deployment using two controlled model endpoints, select B for a new task and observe `request.model=B`, switch the idle Session to A and observe the next `request.model=A`, then reread the selection after refresh and a new service cohort. Verify catalog/boot output contains no provider endpoint or secret and an unauthenticated browser cannot fetch boot. Update the existing local preview only after validation and confirm its original Workspace/Session data survives.
@@ -0,0 +1,25 @@
# 本地 Managed 模型目录
[English](managed-local-model-catalog.md) | [简体中文](managed-local-model-catalog.zh-CN.md)
## 问题与范围
本地 `qwen serve --managed` 适配层为 Hosted Harness 执行继承了用户模型配置,却没有配置 Java 模型目录,也没有提供新建任务模型预设。即使本地已经配置模型,页面仍只显示服务端默认模型。主干已有 Session 模型目录与审批协议。本次将本地启动器接到这些协议,并为 WebShell 创建增加可选初始模型选择,详见[创建模型设计](2026-10-22-managed-initial-model-selection.zh-CN.md)。不新增提供方凭据传输或远程部署行为,也不修改已有 Session。
## 设计
启动时读取与 Hosted Harness 相同的部署配置:遵循所选 `QWEN_HOME`、系统配置与提供方协议声明,忽略工作区配置及环境文件加载。复用现有模型注册表解析配置路由。仅纳入在注册表所有协议和路由中 ID 唯一的对话模型,排除内置 OAuth 目录和专用 fast/image/voice/vision 模型。复用已有 CLI 生成配置解析器,遵循鉴权类型及环境变量优先级。默认模型能唯一匹配目录时将其排在第一位,否则保留内置 `qwen-code` 默认预设,避免其他已配置提供方悄然替换当前默认。其余模型保持配置顺序。未配置模型时保留已有默认选项。由于现有 Hosted 模型固定机制会拒绝歧义 ID,共用 ID 的路由不进入目录。
通过启动器控制的 `SPRING_APPLICATION_JSON` 数组,仅向 Java 传递 `{modelId,label}` 条目和允许的模型 ID。允许使用已有 `hosted-workspace-shell/1` 定义配置。启动器继续过滤继承的 Spring 和 Managed 服务端覆盖配置,保留签名的回环地址边界。
本地新任务使用内置 `qwen-code` Agent,将每个已配置模型作为独立 `modelId` 发布,不再注册会以编译后询问策略限制权限菜单的自定义 AgentDefinition。保留定义模型和工具配置白名单,使已有自定义 Session 可用;管理路由继续不对外开放。
通过已鉴权的同源 `/managed/boot` 返回 `{agentId: "qwen-code",modelId,label}` 预设,独立客户端验证模型选项唯一后传入 provider,并兼容原有 `{agentId,label}` 预设。新任务分别选择宿主目录模型和审批策略;创建请求通过能力检查后,在首轮之前同时保存这两项选择。已有 Session 使用独立的 Java Session 模型目录及其空闲状态、版本和 Workspace 授权校验。模型固定解析与缓存凭据继续由 Hosted Harness 负责。boot、定义和模型目录均不包含凭据、提供方 URL 或签名材料。
## 约束与风险
目录是启动时快照,修改配置后需重启本地服务。缓存凭据由已有执行逻辑校验,启动时不请求提供方。仅内置 OAuth 的模型和有歧义的模型 ID 保留已有默认执行路径,不作为显式选项。移除已配置模型不会重写历史定义或 Session;历史固定配置不再可解析时执行会拒绝继续。Runtime worker 保持独立 `QWEN_HOME`,本次不向其中复制配置或凭据。浏览器存储及产品身份保持不变。
## 验证与验收
定向测试覆盖本地配置作用域、协议路由与歧义 ID、专用模型过滤、选定模型排序、仅元数据序列化、模型与审批独立选择、原子创建与重试、不支持的准入及 boot 验证。完成构建、类型检查和相关包测试。在使用两个受控模型端点的隔离部署中,新建任务选择 B,观察 `request.model=B`;空闲 Session 切换到 A,观察下一轮 `request.model=A`;刷新以及更换服务进程后重新读取选择。验证目录和 boot 不包含提供方地址或秘密,未鉴权浏览器无法读取 boot。仅在验证通过后更新现有本地预览,并确认原 Workspace/Session 数据保留。
+262
View File
@@ -0,0 +1,262 @@
# Run Managed Sessions locally
[English](managed-local-serve.md) | [简体中文](managed-local-serve.zh-CN.md)
Managed local uses a persistent embedded H2 database. With local Java 21 installed,
the first Managed launch downloads the matching Java server JAR automatically.
It reuses the Node executable running Qwen,
its Hosted Harness, native worker entry and production WebShell. Users do not install
MySQL, Docker, Maven, or systemd. An optional preinstalled bundled runtime includes
Java; the default download and lightweight edition require local Java 21.
This mode is experimental. The implementation supports native macOS and Linux
on arm64 and x64. Platform acceptance results are recorded separately; providing
a packaging target does not establish that platform has passed E2E validation.
Managed runtime archives are built explicitly as described below; ordinary CLI
packages do not contain the Java payload. An optional complete Managed
standalone archive bundles all components.
## Start and stop
Use a Qwen version that includes Managed local support. Older releases need a
CLI upgrade; installing the runtime alone cannot add the launcher. Install Java 21
and set `JAVA_HOME` to its home, or put its executable on PATH, then run:
```sh
qwen serve --managed --open
```
Before creating application data or starting services, Qwen checks Java 21 and
downloads `qwen-managed-agent-server-<cli-version>.jar` plus its checksum from
the official Aliyun OSS release mirror, with the same GitHub Release as fallback.
It verifies the checksum and installs a complete runtime atomically under
`<Qwen home>/managed-runtime/<cli-version>/<platform>-<arch>/`. Later launches
reuse that cache without networking. Ordinary Qwen commands do not download it.
Only the service JAR is downloaded; Qwen does not install Java or run Maven.
If the matching release is unavailable or a download fails, no application data
or service children are created. Retry later or preinstall a matching runtime.
For offline use or bundled Java, obtain a matching
`qwen-code-managed-runtime-<platform>-<arch>.tar.gz` and its `.sha256` file from
your distribution builder. Verify and extract it before starting:
```sh
shasum -a 256 -c qwen-code-managed-runtime-darwin-arm64.tar.gz.sha256
mkdir -p "${QWEN_HOME:-$HOME/.qwen}"
tar -xzf qwen-code-managed-runtime-darwin-arm64.tar.gz -C "${QWEN_HOME:-$HOME/.qwen}"
qwen serve --managed --open
```
The archive installs under
`<Qwen home>/managed-runtime/<cli-version>/<platform>-<arch>/`. Qwen discovers
the directory for its exact version and platform automatically. The runtime
does not include another Node, CLI, Harness, worker or WebShell installation.
For a smaller offline payload, choose `qwen-code-managed-runtime-lite-<platform>-<arch>.tar.gz`
and verify/extract it in the same way. It contains only the server JAR and
manifest. Install one edition in a fresh version/target directory; do not
overlay lightweight and bundled archives. The lightweight launcher uses
`JAVA_HOME/bin/java` when JAVA_HOME is nonempty, otherwise the first executable
`java` on PATH. It requires Java 21 specifically and checks the executable
before creating application state. An incorrect JAVA_HOME is an error even if
PATH contains another working Java. For example:
```sh
JAVA_HOME=/absolute/java-21-home qwen serve --managed --open
```
On macOS, JAVA_HOME typically ends in `Contents/Home`. Missing or incompatible
Java produces an installation/configuration error before downloading the JAR; Qwen does not download Java.
The bundled edition always uses its own Java and does not fall back to the
machine's Java if its payload is damaged.
The optional complete `qwen-code-managed-local-<platform>-<arch>.tar.gz` also
works: extract it and run `./qwen-code/bin/qwen serve --managed --open`.
The application binds to `127.0.0.1:4170`. Use `--port 0` for an available
port, or select a fixed port. Keep the terminal open; Ctrl+C shuts down the
ordinary daemon, Java service, and Harness. systemd is optional for operator-managed
deployments and is not used by this command.
An occupied public port follows the ordinary daemon's bounded nearby-port
retry; use the browser address printed by the launcher.
This one command starts both the ordinary daemon and Managed Agent. The same
WebShell lists ordinary and Managed workspaces and sessions together under Projects, with a Managed label on Managed workspaces and no execution-mode tabs. New task follows the selected workspace or conversation. Local sessions use
the directory where you ran the command, while Managed sessions use their
application-created Workspaces or explicitly imported local projects. Both use
the same authenticated browser origin;
you do not need a second daemon command or browser page.
In **Add workspace**, choose **Local (daemon)** or **Managed (Managed Agent)**
before submitting. The choice defaults to the current conversation's service.
Workspace rows and **New task** show that service, and new tasks inherit the
selected workspace. The daemon's existing SSH workspace support remains
available; Managed SSH registration is not integrated into this shared dialog.
The terminal prints a browser URL with a token fragment. Treat it as a local
password. The browser uses the token to authenticate every API request; Java
signing keys and internal credentials never reach the browser. The listener is
loopback-only. For a headless machine, omit `--open` and open the printed URL
on that machine, or use an authenticated tunnel you manage.
The unified application currently supports the local loopback page. The full
shell's existing Mobile access / Local Control entry is not integrated with
Managed boot: a paired LAN listener cannot open this unified page. Mobile
sharing needs a separate integration; the ordinary daemon retains its existing
Local Control behavior.
The Harness uses normal Qwen model configuration and provider credentials.
Configure a model using the usual Qwen CLI setup before sending your first
prompt. Files and Shell use the `default` approval mode; the Managed UI asks
for approval before an action that requires it.
After a restart, continuing an existing Session may wait for its previous SQL
writer lease to expire (60 seconds by default). Keep the original data directory
and retry after that interval; restarting processes does not clear ownership.
## Workspaces and persistent data
The default private data directory is `<Qwen home>/managed-local`, normally
`~/.qwen/managed-local`. Override it with:
```sh
qwen serve --managed --managed-data-dir /absolute/private/data --open
```
The directory must be outside every Git worktree. One launcher may use it at
a time; Java also holds an exclusive database file lock. Private service ports
and credentials remain stable across restarts. A private port occupied by
another application causes startup to fail; choose a different fresh data
directory or stop the conflicting service rather than editing persisted facts.
Startup creates an application-owned default Workspace and its OWNER grant
through the existing idempotent Workspace API. To use an existing local project,
open **Workspace → Choose existing directory**, browse or enter its absolute
path, then add the folder. The service registers its parent and grants OWNER
access automatically, keeping existing files in place. New tasks start in the
selected project. You can also create an empty Workspace.
The launch directory remains available to Local sessions and is not imported
automatically. `--workspace` remains unsupported in Managed local mode; select
the project in the UI. The `.qwen-managed-storage.json` maintenance marker is
kept outside Git worktrees; do not edit, delete, stash or commit it. Imported
project files stay outside the private Managed data directory and require their
own backup.
Local session history continues to use ordinary Qwen storage. It is separate
from the Managed data directory and is not included in a Managed-only backup.
Preserve the complete data directory together, including `state.json`,
`database/`, `runtime/`, `worker-home/`, and `workspaces/`. Settle or cancel
active work, close its Sessions, then stop the application before making a
filesystem backup. Durable original workers can outlive a control-plane exit;
confirm all Workspace writers have stopped. An unknown execution needs the
existing original-owner recovery procedure before an offline backup.
Credentials, database, worker history and Workspace identity belong to the same
installation. Restoring only a subset
can close recovery admission. Startup does not recreate a missing marker or
replace corrupt credentials. Service diagnostics are in `logs/`.
macOS supports native workers and same-boot durable adoption. Trusted whole-host
reboot recovery remains disabled in the local launcher. Unknown tool outcomes
retain existing original-owner recovery rules and are never blindly replayed.
This mode does not add automatic login-time startup or prevent laptop sleep.
## Build a distribution
These commands are for release builders, not end users. Build the CLI and Java
server from the same checkout with Node 22+, Java 21, and Maven 3.8.9+:
```sh
corepack pnpm install --frozen-lockfile
npm run build
npm run typecheck
npm run bundle
npm run prepare:package
mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install
mvn -f packages/managed-agent-server/pom.xml verify
npm run package:managed-runtime -- \
--target darwin-arm64 \
--server-jar packages/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \
--jre-home /verified/java-21-home \
--out-dir /output/managed-runtime
```
The runtime builder only needs the matching server JAR, Java runtime and CLI
version from this checkout. It does not require built CLI assets or a Node
archive. By default it writes to
`packages/managed-agent-server/target/managed-runtime`, outside the CLI's
`dist/` assets. It emits the runtime archive and a per-archive `.sha256` file. The
normal CLI is built/released separately.
Build the lightweight runtime using the same server JAR with `--without-jre`
instead of `--jre-home`:
```sh
npm run package:managed-runtime -- \
--target darwin-arm64 \
--server-jar packages/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \
--without-jre \
--out-dir /output/managed-runtime
```
This emits `qwen-code-managed-runtime-lite-darwin-arm64.tar.gz` and its checksum.
The lightweight builder needs no Java input. For an optional complete distribution:
```sh
npm run package:standalone -- \
--target darwin-arm64 \
--node-archive /verified/node-v22-darwin-arm64.tar.gz \
--managed-server-jar packages/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \
--managed-jre-home /verified/java-21-home \
--out-dir /output/managed-local
```
Use the complete extracted runtime home containing `bin/java`, `lib/modules`,
`release`, and `legal/`. A macOS JRE may place this under `Contents/Home`.
The builder checks Java major version, OS and architecture, includes its
licenses, and records the server SHA-256 and matching CLI version in
the payload manifest. The complete standalone uses `lib/managed-runtime/` and
emits `SHA256SUMS`. Verify publisher checksums of runtime inputs before building.
Windows and Bun managed execution is refused; the launcher requires Node 22+.
For an explicit runtime location,
`QWEN_MANAGED_LOCAL_RUNTIME_DIR=/absolute/managed-runtime` points the local
CLI directly at a directory containing `manifest.json` and `server.jar`
(plus `jre/` for the bundled edition).
It takes precedence over the colocated payload and versioned Qwen-home install.
An existing colocated payload takes precedence over the Qwen-home install.
A selected invalid or incomplete payload is never replaced by a different one.
A missing explicitly selected payload, wrong CLI version, target,
Java major version or server checksum fails before children start. Only a missing
default cache triggers JAR installation; existing damaged payloads are not replaced.
Maven is never run during application startup.
## Publish the first-use JAR
The `Release Managed server runtime` workflow builds from an already published
CLI release tag, using the SDK and server from that exact source revision. A
manual run must be dispatched from `main` and defaults to a dry run. It verifies
the CLI and Java module, builds the `central-release` profile, and retains the
JAR and checksum as workflow artifacts without changing public repositories.
After verifying the dry run, release operators can run the same tag with
`dry_run=false` through the protected `production-release` environment. The
workflow sets the server artifact version to the CLI version, publishes
`com.alibaba:qwen-managed-agent-server:<cli-version>` to Maven Central with
sources, Javadoc and signatures, and distributes the same primary JAR plus its
`.sha256` through that GitHub Release and the existing versioned OSS mirror.
Auxiliary operator JARs are not attached to the Central publication. Existing
assets are compared byte-for-byte and never replaced with different content.
Published CLI release events also run this workflow automatically.
The CLI downloads from OSS/GitHub, not Maven Central. It does not resolve the
SDK as a separate user dependency: the executable server JAR includes its Java
dependencies. Runtime assets must exist for the exact CLI release before the
first-use download succeeds. This PR's local validation does not publish those
assets or bypass the production release environment.
See the bilingual [design](../design/2026-10-20-managed-local-serve.md) for
ownership boundaries and validation criteria, and the
[Java server reference](../../packages/managed-agent-server/README.md) for
operator-oriented MySQL deployments.
+218
View File
@@ -0,0 +1,218 @@
# 本地运行 Managed Session
[English](managed-local-serve.md) | [简体中文](managed-local-serve.zh-CN.md)
Managed 本地模式使用持久化嵌入式 H2 数据库。本机安装 Java 21 后,首次 Managed
启动会自动下载匹配的 Java 服务 JAR。复用运行本地 Qwen 的 Node、
Hosted Harness、原生 worker 入口和生产 WebShell。用户无需安装 MySQL、Docker、
Maven 或 systemd。可选的预装完整 runtime 包含 Java;默认下载和轻量版要求本机
Java 21。
此模式处于实验阶段,实现支持 arm64、x64 的原生 macOS 和 Linux。各平台分别
记录验收结果;存在打包目标不代表该平台已通过 E2E。Managed runtime archive
需要按下文显式构建;普通 CLI 包不含 Java payload。可选的完整 Managed
standalone archive 则包含所有组件。
## 启动与停止
先使用包含 Managed 本地支持的 Qwen 版本。旧版本需要升级 CLI,仅安装 runtime
不能补上启动器。安装 Java 21,将 `JAVA_HOME` 指向其 home,或将可执行文件放在
PATH 上,然后运行:
```sh
qwen serve --managed --open
```
创建应用数据或启动服务前,Qwen 检查 Java 21,再从官方阿里云 OSS 发行镜像下载
`qwen-managed-agent-server-<cli-version>.jar` 及其校验文件,失败时回退相同 GitHub
Release。校验后将完整 runtime 原子安装到
`<Qwen home>/managed-runtime/<cli-version>/<platform>-<arch>/`。后续启动直接
使用缓存,无需联网。普通 Qwen 命令不会触发下载。只下载服务 JAR,不安装 Java,
也不运行 Maven。匹配版本尚未发布或下载失败时,不创建应用数据或服务子进程。
可以稍后重试,或预装匹配的 runtime。
需要离线使用或附带 Java 时,向发行构建者取得匹配的
`qwen-code-managed-runtime-<platform>-<arch>.tar.gz` 及其 `.sha256` 文件,启动前
校验并解压:
```sh
shasum -a 256 -c qwen-code-managed-runtime-darwin-arm64.tar.gz.sha256
mkdir -p "${QWEN_HOME:-$HOME/.qwen}"
tar -xzf qwen-code-managed-runtime-darwin-arm64.tar.gz -C "${QWEN_HOME:-$HOME/.qwen}"
qwen serve --managed --open
```
archive 安装在 `<Qwen home>/managed-runtime/<cli-version>/<platform>-<arch>/`。
Qwen 自动查找精确匹配其版本和平台的目录。runtime 不再附带另一份 Node、CLI、
Harness、worker 或 WebShell 安装。
需要更小的离线 payload 时,选择 `qwen-code-managed-runtime-lite-<platform>-<arch>.tar.gz`,
按相同方式校验和解压,只包含服务 JAR 和 manifest。请在新的版本/平台目录中
安装其中一种版本,不要把轻量版和带 Java 版叠加解压。轻量启动器在 JAVA_HOME
非空时使用 `JAVA_HOME/bin/java`,否则选择 PATH 中第一个可执行的 `java`。要求
版本恰为 Java 21,在创建应用状态前检查实际可执行文件。JAVA_HOME 配置错误时
直接报错,即使 PATH 中有另一份可用 Java。例如:
```sh
JAVA_HOME=/absolute/java-21-home qwen serve --managed --open
```
macOS 的 JAVA_HOME 通常以 `Contents/Home` 结尾。Java 缺失或版本不兼容时,在下载
JAR 前提示安装/配置错误,Qwen 不会下载 Java。带 Java 版始终使用自己的 Java,payload
损坏时不会回退本机 Java。
也可使用完整 `qwen-code-managed-local-<platform>-<arch>.tar.gz`,解压后运行
`./qwen-code/bin/qwen serve --managed --open`。
应用监听 `127.0.0.1:4170`。可以用 `--port 0` 选择空闲端口,或指定固定端口。
保持终端打开;Ctrl+C 会关闭普通 daemon、Java 服务和 Harness。运维部署可以自行使用
systemd,此命令不依赖它。
公开端口被占用时沿用普通 daemon 在相邻端口上的有界重试,请使用启动器实际打印的
浏览器地址。
这一条命令同时启动普通 daemon 与 Managed Agent。同一个 WebShell 使用已有的
统一的项目导航同时显示普通与托管工作区及其会话,托管工作区带有标识,不再提供执行方式 tab。“新建任务”跟随所选工作区或当前会话。Local 会话使用命令启动目录,Managed 会话使用应用创建的
Workspace 或显式导入的本机项目。两者共享一个需要认证的浏览器 origin,无需再启动 daemon 命令或打开
第二个页面。
在“添加工作区”中,提交前可选择“本地(daemon)”或“托管(Managed Agent)”,
默认跟随当前会话所属服务。工作区行与“新建任务”显示该服务,新任务继承所选
工作区。daemon 已有的 SSH 工作区支持仍然可用;Managed SSH 登记尚未接入此
统一弹窗。
终端会打印带 token fragment 的浏览器地址,请把它当作本地密码。浏览器为每次
API 请求携带 token,Java 签名密钥和内部凭据不会传给浏览器。监听仅限 loopback。
无图形界面时省略 `--open`,在该机器打开打印的地址,或自行使用认证隧道。
融合应用当前支持本机 loopback 页面。完整界面已有的 Mobile access / Local Control
入口尚未接入 Managed boot,配对的 LAN 监听无法打开此融合页面。移动端共享需要
后续单独接入;普通 daemon 继续保留既有 Local Control 行为。
Harness 使用正常的 Qwen 模型配置与 provider 凭据。第一次发送 prompt 前,按
普通 Qwen CLI 流程配置模型。文件和 Shell 使用 `default` 审批模式,需要审批的
操作会在 Managed UI 中等待批准。
重启后继续已有 Session 时,可能需要等待原 SQL writer 租约到期(默认 60 秒)。
保留原数据目录,等待后重试;重启进程不会清除所有权。
## Workspace 与持久数据
私有数据目录默认是 `<Qwen home>/managed-local`,通常为
`~/.qwen/managed-local`。可以覆盖:
```sh
qwen serve --managed --managed-data-dir /absolute/private/data --open
```
目录必须位于所有 Git 工作树外,每次只允许一个启动器使用,Java 还会持有独占
数据库文件锁。私有服务端口和凭据跨重启保持稳定。私有端口被其他应用占用时会
拒绝启动;应使用新的数据目录或停止冲突服务,不要直接修改持久事实。
启动通过现有幂等 Workspace API 创建应用管理的默认 Workspace 和 OWNER 授权。
使用已有本机项目时,打开“工作区 → 选择已有目录”,浏览或输入绝对路径后添加
文件夹。服务会自动登记项目父目录并授予 OWNER 权限,已有文件保留原处,
新任务从所选项目启动。也可创建空 Workspace。
启动目录仍供 Local 会话使用,不会自动导入。Managed 本地模式继续拒绝
`--workspace`,请从界面选择项目。`.qwen-managed-storage.json` 维护标记保留在
Git 工作树之外,不要编辑、删除、stash 或提交。导入的项目文件位于私有 Managed
数据目录之外,需要单独备份。
Local 会话历史继续使用普通 Qwen 存储,与 Managed 数据目录独立,仅备份 Managed
目录不会包含 Local 会话历史。
请整体保存数据目录,包括 `state.json`、`database/`、`runtime/`、`worker-home/`
和 `workspaces/`。文件系统备份前先完成或取消活动工作、关闭相关 Session,再
停止应用。原持久 worker 可以在控制面退出后继续存在,需要确认所有 Workspace
写入者已停止。执行结果未知时,离线备份前应先完成现有原所有者恢复流程。
凭据、数据库、worker 历史和 Workspace 身份属于同一次安装,仅恢复部分数据可能
导致恢复接入关闭。启动不会
重建丢失标记或替换损坏凭据,服务诊断日志位于 `logs/`。
macOS 支持原生 worker 和同一次系统启动内的持久接管。本地启动器仍禁用可信
整机重启恢复。未知工具结果沿用原所有者恢复规则,不会盲目重放。此模式不增加
登录时自动启动或阻止笔记本睡眠的功能。
## 构建发行包
以下命令面向发行构建者,最终用户不需要执行。使用 Node 22+、Java 21、Maven
3.8.9+ 从同一份源码构建 CLI 和 Java 服务:
```sh
corepack pnpm install --frozen-lockfile
npm run build
npm run typecheck
npm run bundle
npm run prepare:package
mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install
mvn -f packages/managed-agent-server/pom.xml verify
npm run package:managed-runtime -- \
--target darwin-arm64 \
--server-jar packages/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \
--jre-home /verified/java-21-home \
--out-dir /output/managed-runtime
```
runtime 构建器只要求匹配的服务 JAR、Java runtime 和本 checkout 中的 CLI 版本,
不依赖编译好的 CLI 资源或 Node archive。默认输出到
`packages/managed-agent-server/target/managed-runtime`,位于 CLI 的 `dist/` 资源树
之外。输出 runtime archive 及单独的 `.sha256`
文件。普通 CLI 独立构建和发行。
构建轻量 runtime 时,使用相同的服务 JAR,以 `--without-jre` 替代 `--jre-home`:
```sh
npm run package:managed-runtime -- \
--target darwin-arm64 \
--server-jar packages/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \
--without-jre \
--out-dir /output/managed-runtime
```
输出 `qwen-code-managed-runtime-lite-darwin-arm64.tar.gz` 及其校验和,轻量构建器不
需要 Java 输入。也可构建可选的完整发行包:
```sh
npm run package:standalone -- \
--target darwin-arm64 \
--node-archive /verified/node-v22-darwin-arm64.tar.gz \
--managed-server-jar packages/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \
--managed-jre-home /verified/java-21-home \
--out-dir /output/managed-local
```
传入完整解压的 runtime home,包含 `bin/java`、`lib/modules`、`release` 和
`legal/`。macOS JRE 可能将它放在 `Contents/Home`。构建器检查 Java 主版本、
操作系统与架构,保留许可证,在 payload manifest 记录服务 SHA-256 和匹配的
CLI 版本。完整 standalone 使用 `lib/managed-runtime/` 并输出 `SHA256SUMS`。
构建前应校验 runtime 输入的发行方校验和。拒绝 Windows 和 Bun Managed 执行,
启动器要求 Node 22+。
需要显式选择 runtime 位置时,可设置
`QWEN_MANAGED_LOCAL_RUNTIME_DIR=/absolute/managed-runtime`,让本地 CLI 使用
直接包含 `manifest.json`、`server.jar`(带 Java 版还有 `jre/`)的目录。它优先于相邻 payload 和
Qwen home 下的版本化安装。存在相邻 payload 时,它优先于 Qwen home 安装。
选中的 payload 无效或不完整时不会改用另一份。显式选中的 payload 缺失,或 CLI
版本、平台、Java 主版本、服务校验和不匹配时,都会在启动子进程前失败。仅默认
缓存缺失会触发 JAR 安装;已有损坏 payload 不会被替换。应用启动时绝不执行 Maven。
## 发布首次使用的 JAR
`Release Managed server runtime` workflow 从已公开的 CLI release tag 构建,使用
相同源码修订的 SDK 和服务。手动运行必须从 `main` 发起,默认是 dry-run。它验证
CLI 和 Java 模块,构建 `central-release` profile,并将 JAR 与校验文件保留为
workflow artifact,不修改公开仓库。
验证 dry-run 后,发行运维可通过受保护的 `production-release` environment,用
相同 tag 和 `dry_run=false` 运行。workflow 将服务制品版本设为 CLI 版本,向
Maven Central 发布 `com.alibaba:qwen-managed-agent-server:<cli-version>` 及源码、
Javadoc 和签名,再将相同主 JAR 及其 `.sha256` 分发到该 GitHub Release 和现有的
版本化 OSS 镜像。辅助运维 JAR 不附加到 Central 发布。已有产物逐字节比较,绝不
用不同内容替换。公开的 CLI release 事件也会自动运行该 workflow。
CLI 从 OSS/GitHub 下载,不从 Maven Central 下载,也不要求用户单独解析 SDK:
可执行服务 JAR 包含其 Java 依赖。精确匹配 CLI release 的 runtime 产物必须已存在,
首次下载才能成功。本 PR 的本地验证不会发布这些产物,也不会绕过生产发行环境。
所有权边界和验收条件见双语[设计文档](../design/2026-10-20-managed-local-serve.zh-CN.md),
面向运维的 MySQL 部署见 [Java 服务参考](../../packages/managed-agent-server/README.md)。
+1
View File
@@ -123,6 +123,7 @@
"prepare:package": "node scripts/prepare-package.js",
"package:hosted-installation": "node scripts/build-hosted-installation-assets.js",
"package:standalone": "node scripts/create-standalone-package.js",
"package:managed-runtime": "node scripts/create-managed-runtime-package.js",
"package:standalone:release": "node scripts/build-standalone-release.js",
"verify:installation-release": "node scripts/verify-installation-release.js",
"release:version": "node scripts/version.js",
+35 -3
View File
@@ -10,7 +10,11 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import yargs, { type Argv } from 'yargs';
import { maybeOpenWebShellBrowser, serveCommand } from './serve.js';
import {
maybeOpenWebShellBrowser,
serveCommand,
validateManagedLocalFlags,
} from './serve.js';
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));
@@ -54,6 +58,34 @@ function buildParser(): Argv {
}
describe('serve command args', () => {
it('refuses flags whose ordinary-daemon behavior is unavailable in Managed mode', () => {
expect(() =>
validateManagedLocalFlags(['--managed', '--port=0', '--no-open']),
).not.toThrow();
for (const flag of [
'--workspace',
'--max-sessions=2',
'--profile=hosted-harness',
'--token=secret',
'--no-require-auth',
]) {
expect(() => validateManagedLocalFlags(['--managed', flag])).toThrow(
'not supported with --managed',
);
}
});
it('parses the isolated Managed mode and private data directory', () => {
const parsed = buildParser().parseSync(
'--managed --managed-data-dir /private/data --port 0',
);
expect(parsed['managed']).toBe(true);
expect(parsed['managed-data-dir']).toBe('/private/data');
expect(parsed['port']).toBe(0);
expect(buildParser().parseSync('')['managed']).toBe(false);
expect(() => buildParser().parseSync('--managed-data-dir')).toThrow(
/Not enough arguments/u,
);
});
it('documents the complete IPv4 loopback range', async () => {
// Remove whitespace before checking so a long option name forcing yargs
// to wrap the description does not split the CIDR literal across lines.
@@ -70,12 +102,12 @@ describe('serve command args', () => {
const help = squash(await buildParser().getHelp());
expect(help).toContain(
squash(
'--managed-runtime-broker-url Private Broker URL for --profile hosted-harness; required together with token for Workspace tool turns. --',
'--managed-runtime-broker-url Private Broker URL for --profile hosted-harness; required together with token for Workspace tool turns. Falls back to QWEN_MANAGED_RUNTIME_BROKER_URL. --',
),
);
expect(help).toContain(
squash(
'--managed-runtime-broker-token Private Broker credential for --profile hosted-harness; required together with URL for Workspace tool turns. --',
'--managed-runtime-broker-token Private Broker credential for --profile hosted-harness; required together with URL for Workspace tool turns. Falls back to QWEN_MANAGED_RUNTIME_BROKER_TOKEN. --',
),
);
expect(help).not.toContain('ReservedBroker');
+108 -6
View File
@@ -56,6 +56,30 @@ function blockForever(): Promise<never> {
const DEFAULT_SERVE_HOSTNAME = '127.0.0.1';
export function validateManagedLocalFlags(args: readonly string[]): void {
const supported = new Set([
'--managed',
'--managed-data-dir',
'--port',
'--hostname',
'--open',
'--no-open',
'--open-with-auth',
'--no-open-with-auth',
'--web',
'--require-auth',
'--http-bridge',
]);
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. Use --port, --managed-data-dir, and --open.`,
);
}
}
/**
* Turn Local Control on through the daemon and print the pairing QR.
*
@@ -197,6 +221,8 @@ interface ServeArgs {
port: number;
hostname: string;
profile: 'default' | 'hosted-harness';
managed: boolean;
'managed-data-dir'?: string;
'hosted-harness-capability-digest'?: string;
token?: string;
'max-sessions': number;
@@ -300,6 +326,18 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
description:
'SHA-256 capability digest for the private Hosted Harness profile. Falls back to QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST.',
})
.option('managed', {
type: 'boolean',
default: false,
description:
'Start the daemon and Managed Agent with shared WebShell and embedded storage; downloads the server on first use and requires Java 21.',
})
.option('managed-data-dir', {
type: 'string',
requiresArg: true,
description:
'Private persistent data directory for --managed; defaults to <Qwen home>/managed-local.',
})
.option('token', {
type: 'string',
description:
@@ -701,13 +739,13 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
type: 'string',
requiresArg: true,
description:
'Private Broker URL for --profile hosted-harness; required together with token for Workspace tool turns.',
'Private Broker URL for --profile hosted-harness; required together with token for Workspace tool turns. Falls back to QWEN_MANAGED_RUNTIME_BROKER_URL.',
})
.option('managed-runtime-broker-token', {
type: 'string',
requiresArg: true,
description:
'Private Broker credential for --profile hosted-harness; required together with URL for Workspace tool turns.',
'Private Broker credential for --profile hosted-harness; required together with URL for Workspace tool turns. Falls back to QWEN_MANAGED_RUNTIME_BROKER_TOKEN.',
})
.option('writer-idle-timeout-ms', {
type: 'number',
@@ -807,6 +845,70 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
'Requires --rate-limit.',
}) as unknown as Argv<ServeArgs>,
handler: async (argv) => {
if (argv.managed) {
try {
const serveIndex = process.argv.indexOf('serve');
if (serveIndex >= 0)
validateManagedLocalFlags(process.argv.slice(serveIndex + 1));
if (
argv.hostname !== DEFAULT_SERVE_HOSTNAME ||
argv.profile !== 'default' ||
!argv.web ||
argv.workspace !== undefined ||
argv.token !== undefined ||
argv['tls-cert'] ||
argv['tls-key'] ||
argv['local-control'] ||
argv.join ||
argv['agent-host-server'] ||
argv.channel !== undefined ||
argv['experimental-paired-engines'] ||
argv['experimental-managed-agents'] ||
argv['experimental-managed-runtime-worker'] ||
argv['experimental-managed-runtime-auto-local'] ||
argv['experimental-managed-runtime-url'] !== undefined ||
argv['managed-runtime-broker-url'] !== undefined ||
argv['managed-runtime-broker-token'] !== undefined ||
argv['allow-origin']?.length ||
argv['enable-session-shell'] ||
!argv['http-bridge']
) {
throw new Error(
'--managed uses its own loopback services, persistent credentials, and application-owned Workspaces. Use --port, --managed-data-dir, and --open; other deployment, workspace-import, or execution modes cannot be combined.',
);
}
const { runManagedLocalServe } = await import(
'../serve/managed-local-serve.js'
);
const handle = await runManagedLocalServe({
port: argv.port,
dataDirectory: argv['managed-data-dir'],
});
await maybeOpenWebShellBrowser(
handle,
argv.open || argv['open-with-auth'],
argv['open-with-auth'],
);
process.exit(await handle.finished);
} catch (error) {
writeStderrLine(
`qwen serve: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
if (argv['managed-data-dir'] !== undefined) {
writeStderrLine('qwen serve: --managed-data-dir requires --managed.');
process.exit(1);
}
const managedRuntimeBrokerUrl =
argv['managed-runtime-broker-url'] ??
process.env['QWEN_MANAGED_RUNTIME_BROKER_URL'];
const managedRuntimeBrokerToken =
argv['managed-runtime-broker-token'] ??
process.env['QWEN_MANAGED_RUNTIME_BROKER_TOKEN'];
delete process.env['QWEN_MANAGED_RUNTIME_BROKER_URL'];
delete process.env['QWEN_MANAGED_RUNTIME_BROKER_TOKEN'];
const agentHostEnrollmentToken =
process.env['QWEN_AGENT_HOST_ENROLLMENT_TOKEN']?.trim();
delete process.env['QWEN_AGENT_HOST_ENROLLMENT_TOKEN'];
@@ -1063,14 +1165,14 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
argv['experimental-managed-runtime-token'],
}
: {}),
...(argv['managed-runtime-broker-url'] !== undefined
...(managedRuntimeBrokerUrl !== undefined
? {
managedRuntimeBrokerUrl: argv['managed-runtime-broker-url'],
managedRuntimeBrokerUrl,
}
: {}),
...(argv['managed-runtime-broker-token'] !== undefined
...(managedRuntimeBrokerToken !== undefined
? {
managedRuntimeBrokerToken: argv['managed-runtime-broker-token'],
managedRuntimeBrokerToken,
}
: {}),
...(argv['hosted-harness-capability-digest'] !== undefined
+7 -3
View File
@@ -677,6 +677,8 @@ describe('serve fast path argument parsing', () => {
(name) => name.length > 1 && !options.alias[name]?.length,
);
const sampleArgvByOption = new Map<string, string[]>([
['managed', ['--managed']],
['managed-data-dir', ['--managed-data-dir', '/tmp/qwen-managed-data']],
['port', ['--port', '0']],
['hostname', ['--hostname', '127.0.0.1']],
['token', ['--token', 'token']],
@@ -790,14 +792,16 @@ describe('serve fast path argument parsing', () => {
]);
const expectedFallbackOptions = new Set([
// The fast path exists to start a plain daemon without loading the full
// CLI. A managed Agent Host is a different mode — it enrols, polls and
// launches an executor — so these hand off rather than being taught to
// the fast path.
// CLI. Managed local serve and Agent Host run different process
// lifecycles, so these hand off rather than being taught to the fast
// path.
'agent-host-allow-http',
'agent-host-name',
'agent-host-server',
'agent-host-workspace-id',
'channel',
'managed',
'managed-data-dir',
'profile',
'hosted-harness-capability-digest',
'experimental-paired-engines',
@@ -0,0 +1,212 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installManagedLocalRuntime } from './managed-local-download.js';
vi.mock('../utils/stdioHelpers.js', () => ({ writeStderrLine: vi.fn() }));
describe('Managed server first-use download', () => {
const version = '0.25.4-preview.1';
const target = 'darwin-arm64';
const name = `qwen-managed-agent-server-${version}.jar`;
const jar = Buffer.concat([
Buffer.from('504b0304', 'hex'),
Buffer.from('server fixture'),
]);
const digest = createHash('sha256').update(jar).digest('hex');
let root: string;
let directory: string;
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
const response = (url: string) =>
url.endsWith('.sha256')
? new Response(`${digest} ${name}\n`)
: new Response(jar);
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'managed-download-'));
directory = path.join(root, version, target);
fetchMock = vi.fn<typeof fetch>(async (url) => response(String(url)));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const install = () => installManagedLocalRuntime(version, target, directory);
it('downloads the exact CLI version once, checks it, and installs a system-Java payload', async () => {
await install();
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
`https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/v${version}/${name}.sha256`,
`https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/v${version}/${name}`,
]);
expect(await readFile(path.join(directory, 'server.jar'))).toEqual(jar);
expect(
JSON.parse(await readFile(path.join(directory, 'manifest.json'), 'utf8')),
).toEqual({
schemaVersion: 1,
cliVersion: version,
target,
javaMajor: 21,
javaSource: 'system',
serverSha256: digest,
});
expect((await readdir(directory)).sort()).toEqual([
'manifest.json',
'server.jar',
]);
if (process.platform !== 'win32') {
expect((await lstat(directory)).mode & 0o777).toBe(0o700);
expect(
(await lstat(path.join(directory, 'server.jar'))).mode & 0o777,
).toBe(0o600);
}
fetchMock.mockRejectedValue(new Error('offline'));
await install();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('falls back to the same version on GitHub when the OSS release is unavailable', async () => {
fetchMock.mockImplementation(async (url) =>
String(url).includes('aliyuncs.com')
? new Response('', { status: 404 })
: response(String(url)),
);
await install();
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
`https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/v${version}/${name}.sha256`,
`https://github.com/QwenLM/qwen-code/releases/download/v${version}/${name}.sha256`,
`https://github.com/QwenLM/qwen-code/releases/download/v${version}/${name}`,
]);
expect(await readFile(path.join(directory, 'server.jar'))).toEqual(jar);
});
it('discards a corrupt mirror download before installing a verified fallback', async () => {
fetchMock.mockImplementation(async (url) =>
String(url).includes('aliyuncs.com') && !String(url).endsWith('.sha256')
? new Response('corrupt')
: response(String(url)),
);
await install();
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(await readFile(path.join(directory, 'server.jar'))).toEqual(jar);
expect(await readdir(path.dirname(directory))).toEqual([target]);
});
it('serializes simultaneous installations and reuses the winner without downloading again', async () => {
await Promise.all([install(), install()]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(await readFile(path.join(directory, 'server.jar'))).toEqual(jar);
}, 10_000);
it('cleans abandoned staging directories and safely retries a failed installation', async () => {
await mkdir(
path.join(path.dirname(directory), `.${target}-download-abandoned`),
{ recursive: true },
);
fetchMock.mockRejectedValue(new Error('connection interrupted'));
await expect(install()).rejects.toThrow(`CLI ${version}, ${target}`);
expect(await readdir(path.dirname(directory))).toEqual([]);
fetchMock.mockImplementation(async (url) => response(String(url)));
await install();
expect(await readFile(path.join(directory, 'server.jar'))).toEqual(jar);
});
it('does not replace an existing payload even if it is incomplete', async () => {
await mkdir(directory, { recursive: true });
await writeFile(
path.join(directory, 'server.jar'),
'existing damaged payload',
);
await install();
expect(fetchMock).not.toHaveBeenCalled();
expect(await readFile(path.join(directory, 'server.jar'), 'utf8')).toBe(
'existing damaged payload',
);
});
it.each([
'mismatch',
'wrong-name',
'oversized-checksum',
'wrong-jar',
'oversized-jar',
'broken-stream',
])('refuses %s without publishing any partial runtime', async (failure) => {
fetchMock.mockImplementation(async (url) => {
if (String(url).endsWith('.sha256')) {
if (failure === 'mismatch')
return new Response(`${'0'.repeat(64)} ${name}\n`);
if (failure === 'wrong-name')
return new Response(`${digest} another.jar\n`);
if (failure === 'oversized-checksum')
return new Response('x'.repeat(1025));
if (failure === 'wrong-jar')
return new Response(
`${createHash('sha256').update('not a jar').digest('hex')} ${name}\n`,
);
return response(String(url));
}
if (failure === 'wrong-jar') return new Response('not a jar');
if (failure === 'oversized-jar')
return new Response(jar, {
headers: { 'content-length': String(256 * 1024 * 1024 + 1) },
});
if (failure === 'broken-stream')
return new Response(
new ReadableStream({
start(controller) {
controller.error(new Error('truncated body'));
},
}),
);
return new Response(jar);
});
await expect(install()).rejects.toThrow(
'Could not install Managed runtime',
);
expect(await readdir(path.dirname(directory))).toEqual([]);
});
it('bounds both mirror attempts and cleans up when the download times out', async () => {
const timeout = vi.spyOn(AbortSignal, 'timeout');
fetchMock.mockImplementation(async (_url, options) => {
expect(options?.signal).toBeInstanceOf(AbortSignal);
expect(options?.headers).toEqual({
'User-Agent': `qwen-code/${version}`,
});
throw new DOMException('fixture timeout', 'TimeoutError');
});
await expect(install()).rejects.toThrow('fixture timeout');
expect(timeout.mock.calls).toEqual([[300_000], [300_000]]);
expect(await readdir(path.dirname(directory))).toEqual([]);
});
it.each(['../escape', 'latest', 'v0.25.3', '0.25.3/escape'])(
'refuses unsafe or unversioned release %s before filesystem or network changes',
async (invalid) => {
await expect(
installManagedLocalRuntime(invalid, target, directory),
).rejects.toThrow('release version');
expect(fetchMock).not.toHaveBeenCalled();
expect(await readdir(root)).toEqual([]);
},
);
});
@@ -0,0 +1,184 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import {
lstat,
mkdir,
mkdtemp,
open,
readFile,
readdir,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { ReadableStream } from 'node:stream/web';
import lockfile from 'proper-lockfile';
import { valid } from 'semver';
import { writeStderrLine } from '../utils/stdioHelpers.js';
async function download(
url: string,
destination: string,
limit: number,
signal: AbortSignal,
version: string,
): Promise<string> {
const response = await fetch(url, {
signal,
headers: { 'User-Agent': `qwen-code/${version}` },
});
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error(`Download returned HTTP ${response.status}.`);
}
if (Number(response.headers.get('content-length')) > limit) {
await response.body.cancel();
throw new Error('Download exceeds the size limit.');
}
const digest = createHash('sha256');
let received = 0;
await pipeline(
Readable.fromWeb(response.body as ReadableStream<Uint8Array>),
new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length;
if (received > limit) {
callback(new Error('Download exceeds the size limit.'));
return;
}
digest.update(chunk);
callback(null, chunk);
},
}),
createWriteStream(destination, { flags: 'wx', mode: 0o600 }),
);
return digest.digest('hex');
}
export async function installManagedLocalRuntime(
version: string,
target: string,
directory: string,
): Promise<void> {
if (
version.length > 128 ||
valid(version) !== version ||
!/^(?:darwin|linux)-(?:arm64|x64)$/u.test(target)
) {
throw new Error(
'Managed runtime download requires a release version and supported target.',
);
}
const parent = path.dirname(directory);
await mkdir(parent, { recursive: true, mode: 0o700 });
const release = await lockfile.lock(directory, {
realpath: false,
stale: 120_000,
retries: { retries: 600, minTimeout: 1000, maxTimeout: 1000 },
});
try {
try {
await lstat(directory);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const temporaryPrefix = `.${target}-download-`;
for (const entry of await readdir(parent)) {
if (entry.startsWith(temporaryPrefix)) {
await rm(path.join(parent, entry), { recursive: true, force: true });
}
}
const tag = encodeURIComponent(`v${version}`);
const name = `qwen-managed-agent-server-${version}.jar`;
const sources = [
`https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${tag}`,
`https://github.com/QwenLM/qwen-code/releases/download/${tag}`,
];
const errors: string[] = [];
for (const source of sources) {
const temporary = await mkdtemp(path.join(parent, temporaryPrefix));
try {
writeStderrLine(
`Downloading Managed server ${version} from ${new URL(source).hostname}...`,
);
const signal = AbortSignal.timeout(300_000);
const checksumFile = path.join(temporary, 'checksum');
await download(
`${source}/${name}.sha256`,
checksumFile,
1024,
signal,
version,
);
const checksum = (await readFile(checksumFile, 'utf8')).trim();
const expected = /^([a-f0-9]{64}) {2}(\S+)$/u.exec(checksum);
if (!expected || expected[2] !== name) {
throw new Error('Invalid Managed server checksum file.');
}
const serverJar = path.join(temporary, 'server.jar');
const actual = await download(
`${source}/${name}`,
serverJar,
256 * 1024 * 1024,
signal,
version,
);
if (actual !== expected[1])
throw new Error('Server checksum mismatch.');
const jar = await open(serverJar, 'r');
try {
const header = Buffer.alloc(4);
await jar.read(header, 0, header.length, 0);
if (header.toString('hex') !== '504b0304') {
throw new Error('Managed server is not a JAR.');
}
} finally {
await jar.close();
}
await rm(checksumFile);
await writeFile(
path.join(temporary, 'manifest.json'),
`${JSON.stringify(
{
schemaVersion: 1,
cliVersion: version,
target,
javaMajor: 21,
javaSource: 'system',
serverSha256: actual,
},
null,
2,
)}\n`,
{ mode: 0o600 },
);
await rename(temporary, directory);
writeStderrLine(
`Managed server ${version} installed. Subsequent starts use the local cache.`,
);
return;
} catch (error) {
errors.push(
`${new URL(source).hostname}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await rm(temporary, { recursive: true, force: true });
}
}
throw new Error(
`Could not install Managed runtime for CLI ${version}, ${target} at ${directory}. ${errors.join(' ')} Retry when the release is available, or preinstall a matching managed-runtime archive for offline use.`,
);
} finally {
await release();
}
}
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ModelsConfig } from '@qwen-code/qwen-code-core/models/modelsConfig.js';
import { loadSettings } from '../config/settings.js';
import { loadManagedLocalModels } from './managed-local-models.js';
vi.mock('../config/settings.js', () => ({ loadSettings: vi.fn() }));
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe('local Managed model catalog', () => {
beforeEach(() => {
vi.mocked(loadSettings).mockReturnValue({ merged: {} } as ReturnType<
typeof loadSettings
>);
});
it('uses deployment settings and returns only unique conversation model IDs and labels', () => {
const merged = {
model: { name: 'selected' },
security: { auth: { selectedType: 'openai' } },
providerProtocol: { custom: 'openai' },
modelProviders: {
custom: [
{ id: 'other', apiKey: 'secret-key', baseUrl: 'https://private/v1' },
{ id: 'selected', name: 'Selected model' },
{ id: 'shared', baseUrl: 'https://one/v1' },
{ id: 'shared', baseUrl: 'https://two/v1' },
{ id: 'cross-wire' },
{ id: 'fast', fastOnly: true },
{ id: 'image', imageOnly: true },
{ id: 'vision', visionOnly: true },
{ id: 'voice', voiceOnly: true },
{ id: 'realtime', realtimeOnly: true },
],
anthropic: [{ id: 'cross-wire' }],
},
};
vi.mocked(loadSettings).mockReturnValue({ merged } as unknown as ReturnType<
typeof loadSettings
>);
expect(loadManagedLocalModels()).toEqual({
defaultModelId: 'selected',
models: [
{ modelId: 'selected', label: 'Selected model' },
{ modelId: 'other', label: 'other' },
],
});
expect(loadSettings).toHaveBeenCalledWith(process.cwd(), {
skipLoadEnvironment: true,
skipWorkspaceSettings: true,
workspaceTrusted: false,
});
const harnessModels = new ModelsConfig({
modelProvidersConfig: merged.modelProviders,
providerProtocolConfig: merged.providerProtocol,
}).getAllConfiguredModels();
for (const model of loadManagedLocalModels().models) {
expect(
harnessModels.filter((entry) => entry.id === model.modelId),
).toHaveLength(1);
}
});
it('keeps the existing default fallback when no provider models are configured', () => {
expect(loadManagedLocalModels()).toEqual({
models: [],
defaultModelId: undefined,
});
});
it.each(['qwen-oauth', 'openai'])(
'keeps an unmatched %s default instead of silently selecting another provider',
(authType) => {
vi.mocked(loadSettings).mockReturnValue({
merged: {
security: { auth: { selectedType: authType } },
model: { name: 'default-not-in-catalog' },
modelProviders: { openai: [{ id: 'other' }] },
},
} as unknown as ReturnType<typeof loadSettings>);
expect(loadManagedLocalModels()).toEqual({
models: [{ modelId: 'other', label: 'other' }],
defaultModelId: undefined,
});
},
);
it('recognizes an environment-selected configured model using the CLI resolver', () => {
vi.stubEnv('OPENAI_MODEL', 'model-b');
vi.mocked(loadSettings).mockReturnValue({
merged: {
security: { auth: { selectedType: 'openai' } },
modelProviders: { openai: [{ id: 'model-a' }, { id: 'model-b' }] },
},
} as unknown as ReturnType<typeof loadSettings>);
expect(loadManagedLocalModels()).toEqual({
models: [
{ modelId: 'model-b', label: 'model-b' },
{ modelId: 'model-a', label: 'model-a' },
],
defaultModelId: 'model-b',
});
});
});
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core/core/contentGenerator.js';
import { ModelsConfig } from '@qwen-code/qwen-code-core/models/modelsConfig.js';
import { loadSettings } from '../config/settings.js';
import {
getAuthTypeFromEnv,
resolveCliGenerationConfig,
} from '../utils/modelConfigUtils.js';
export interface ManagedLocalModel {
modelId: string;
label: string;
}
export function loadManagedLocalModels(): {
models: ManagedLocalModel[];
defaultModelId?: string;
} {
const { merged } = loadSettings(process.cwd(), {
skipLoadEnvironment: true,
skipWorkspaceSettings: true,
workspaceTrusted: false,
});
const configured = new ModelsConfig({
modelProvidersConfig: merged.modelProviders,
providerProtocolConfig: merged.providerProtocol,
}).getAllConfiguredModels();
const selection = resolveCliGenerationConfig({
argv: {},
settings: merged,
selectedAuthType:
merged.security?.auth?.selectedType ?? getAuthTypeFromEnv(),
});
const models = configured
.filter(
(model) =>
model.authType !== AuthType.QWEN_OAUTH &&
!model.fastOnly &&
!model.voiceOnly &&
!model.visionOnly &&
!model.imageOnly &&
configured.filter((candidate) => candidate.id === model.id).length ===
1,
)
.map((model) => ({ modelId: model.id, label: model.label || model.id }));
const selected = models.findIndex(
(model) =>
model.modelId === selection.model &&
configured.some(
(candidate) =>
candidate.id === model.modelId &&
candidate.authType === selection.authType,
),
);
if (selected > 0) models.unshift(...models.splice(selected, 1));
return {
models,
defaultModelId: selected >= 0 ? selection.model : undefined,
};
}
@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import {
chmod,
cp,
mkdir,
mkdtemp,
realpath,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveManagedLocalRuntime } from './managed-local-runtime.js';
vi.mock('../utils/version.js', () => ({ getCliVersion: async () => '0.25.3' }));
describe.skipIf(process.platform === 'win32')(
'Managed local packaged runtime',
() => {
let directory: string;
let manifest: Record<string, unknown>;
beforeEach(async () => {
directory = await realpath(
await mkdtemp(path.join(os.tmpdir(), 'managed-local-runtime-')),
);
vi.stubEnv('QWEN_HOME', path.join(directory, 'home'));
vi.stubEnv('QWEN_MANAGED_LOCAL_RUNTIME_DIR', undefined);
vi.stubEnv('JAVA_HOME', undefined);
const jar = Buffer.from('test server bytes');
await mkdir(path.join(directory, 'jre', 'bin'), { recursive: true });
await writeFile(path.join(directory, 'cli.js'), '');
await writeFile(path.join(directory, 'server.jar'), jar);
await writeFile(
path.join(directory, 'jre', 'release'),
'JAVA_VERSION="21.0.12"\n',
);
await writeFile(
path.join(directory, 'jre', 'bin', 'java'),
'#!/bin/sh\nexit 0\n',
);
await chmod(path.join(directory, 'jre', 'bin', 'java'), 0o755);
manifest = {
schemaVersion: 1,
cliVersion: '0.25.3',
target: `${process.platform}-${process.arch}`,
javaMajor: 21,
serverSha256: createHash('sha256').update(jar).digest('hex'),
};
await save();
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
await rm(directory, { recursive: true, force: true });
});
const save = () =>
writeFile(
path.join(directory, 'manifest.json'),
JSON.stringify(manifest),
);
const resolve = () =>
resolveManagedLocalRuntime(path.join(directory, 'cli.js'), directory);
const install = async (destination: string) => {
await mkdir(destination, { recursive: true });
for (const file of ['manifest.json', 'server.jar', 'jre']) {
await cp(path.join(directory, file), path.join(destination, file), {
recursive: true,
});
}
return destination;
};
const installHome = () =>
install(
path.join(
directory,
'home',
'managed-runtime',
'0.25.3',
`${process.platform}-${process.arch}`,
),
);
const useSystemJava = async () => {
manifest['javaSource'] = 'system';
await save();
await rm(path.join(directory, 'jre'), { recursive: true });
};
const createJava = async (name: string, major = '21') => {
const home = path.join(directory, name);
const executable = path.join(home, 'bin', 'java');
await mkdir(path.dirname(executable), { recursive: true });
await writeFile(
executable,
`#!/bin/sh\nprintf '%s\\n' ' java.specification.version = ${major}' >&2\n`,
{ mode: 0o755 },
);
return { home, executable };
};
it('resolves the colocated Java binary instead of requiring Java on PATH', async () => {
vi.stubEnv('PATH', path.join(directory, 'empty'));
vi.stubEnv('JAVA_HOME', path.join(directory, 'missing'));
expect(await resolve()).toEqual({
java: path.join(directory, 'jre', 'bin', 'java'),
serverJar: path.join(directory, 'server.jar'),
cliEntry: path.join(directory, 'cli.js'),
});
});
it('uses canonical JAVA_HOME Java for an explicitly lightweight payload', async () => {
await useSystemJava();
const selected = await createJava('java home');
const link = path.join(directory, 'linked-java-home');
await symlink(selected.home, link);
vi.stubEnv('JAVA_HOME', link);
vi.stubEnv('PATH', path.join(directory, 'empty'));
expect((await resolve()).java).toBe(selected.executable);
});
it('uses the first executable Java on PATH when JAVA_HOME is unset', async () => {
await useSystemJava();
const disabled = await createJava('not-executable', '17');
await chmod(disabled.executable, 0o644);
const selected = await createJava('path-java');
vi.stubEnv(
'PATH',
[
path.dirname(disabled.executable),
path.dirname(selected.executable),
].join(path.delimiter),
);
expect((await resolve()).java).toBe(selected.executable);
});
it.each(['missing', '17', '25', '21.0.12', 'malformed'])(
'refuses %s JAVA_HOME without trying a valid Java on PATH',
async (major) => {
await useSystemJava();
const alternative = await createJava('valid-path-java');
const selected =
major === 'missing'
? { home: path.join(directory, 'missing') }
: await createJava('selected-java', major);
vi.stubEnv('JAVA_HOME', selected.home);
vi.stubEnv('PATH', path.dirname(alternative.executable));
await expect(resolve()).rejects.toThrow('requires local Java 21');
},
);
it('refuses the first wrong Java on PATH rather than finding another version', async () => {
await useSystemJava();
const first = await createJava('first', '17');
const second = await createJava('second');
vi.stubEnv(
'PATH',
[path.dirname(first.executable), path.dirname(second.executable)].join(
path.delimiter,
),
);
await expect(resolve()).rejects.toThrow('requires local Java 21');
});
it('provides a local-Java installation hint when PATH has no Java', async () => {
await useSystemJava();
vi.stubEnv('PATH', path.join(directory, 'empty'));
await expect(resolve()).rejects.toThrow(
'No executable java found on PATH',
);
});
it('bounds the version probe and refuses a Java executable that hangs', async () => {
await useSystemJava();
const selected = await createJava('hanging-java');
await writeFile(selected.executable, '#!/bin/sh\nexec /bin/sleep 30\n');
vi.stubEnv('JAVA_HOME', selected.home);
await expect(resolve()).rejects.toThrow('Could not verify Java 21');
}, 15_000);
it.each([undefined, 'bundled'])(
'does not use local Java for an incomplete bundled payload (%s)',
async (source) => {
if (source) manifest['javaSource'] = source;
await save();
await rm(path.join(directory, 'jre'), { recursive: true });
const alternative = await createJava('system-java');
vi.stubEnv('JAVA_HOME', alternative.home);
await expect(resolve()).rejects.toThrow('verification failed');
},
);
it('refuses an unknown Java source', async () => {
manifest['javaSource'] = 'auto';
await save();
await expect(resolve()).rejects.toThrow('must match CLI');
});
it.each(['cliVersion', 'target', 'javaMajor'])(
'refuses mismatched %s',
async (field) => {
manifest[field] = 'wrong';
await save();
await expect(resolve()).rejects.toThrow('must match CLI');
},
);
it('refuses a modified server or wrong Java runtime before spawn', async () => {
await writeFile(path.join(directory, 'server.jar'), 'changed');
await expect(resolve()).rejects.toThrow('checksum mismatch');
manifest['serverSha256'] = createHash('sha256')
.update('changed')
.digest('hex');
await save();
await writeFile(
path.join(directory, 'jre', 'release'),
'JAVA_VERSION="17.0.1"\n',
);
await expect(resolve()).rejects.toThrow('not Java 21');
});
it('provides an actionable missing-payload error', async () => {
await rm(path.join(directory, 'manifest.json'));
await expect(resolve()).rejects.toThrow('Managed local distribution');
});
it('discovers an exact version and platform runtime in Qwen home', async () => {
const installed = await installHome();
const runtime = await resolveManagedLocalRuntime(
path.join(directory, 'cli.js'),
);
expect(runtime).toEqual({
java: path.join(installed, 'jre', 'bin', 'java'),
serverJar: path.join(installed, 'server.jar'),
cliEntry: path.join(directory, 'cli.js'),
});
});
it('keeps a colocated payload authoritative even when it becomes invalid', async () => {
await installHome();
const colocated = await install(path.join(directory, 'managed-runtime'));
const entry = path.join(directory, 'cli.js');
expect((await resolveManagedLocalRuntime(entry)).serverJar).toBe(
path.join(colocated, 'server.jar'),
);
await rm(path.join(colocated, 'manifest.json'));
await expect(resolveManagedLocalRuntime(entry)).rejects.toThrow(
`invalid at ${colocated}`,
);
});
it('does not replace an explicitly selected missing runtime with an installed one', async () => {
await installHome();
await install(path.join(directory, 'managed-runtime'));
const selected = path.join(directory, 'missing');
vi.stubEnv('QWEN_MANAGED_LOCAL_RUNTIME_DIR', selected);
await expect(
resolveManagedLocalRuntime(path.join(directory, 'cli.js')),
).rejects.toThrow(`invalid at ${selected}`);
});
it('requires local Java before downloading a missing default runtime', async () => {
vi.stubEnv('PATH', path.join(directory, 'empty'));
const download = vi.fn();
vi.stubGlobal('fetch', download);
await expect(
resolveManagedLocalRuntime(path.join(directory, 'cli.js')),
).rejects.toThrow('requires local Java 21');
expect(download).not.toHaveBeenCalled();
});
it('downloads a missing default runtime and then uses it offline without replacing corrupt cache contents', async () => {
const selected = await createJava('local-java');
vi.stubEnv('JAVA_HOME', selected.home);
const jar = Buffer.from('504b030401020304', 'hex');
const digest = createHash('sha256').update(jar).digest('hex');
const download = vi.fn(
async (url: string) =>
new Response(
url.endsWith('.sha256')
? `${digest} qwen-managed-agent-server-0.25.3.jar\n`
: jar,
),
);
vi.stubGlobal('fetch', download);
const entry = path.join(directory, 'cli.js');
const runtime = await resolveManagedLocalRuntime(entry);
expect(runtime.java).toBe(selected.executable);
expect(runtime.serverJar).toBe(
path.join(
directory,
'home',
'managed-runtime',
'0.25.3',
`${process.platform}-${process.arch}`,
'server.jar',
),
);
expect(download).toHaveBeenCalledTimes(2);
download.mockRejectedValue(new Error('offline'));
expect(await resolveManagedLocalRuntime(entry)).toEqual(runtime);
await writeFile(runtime.serverJar, 'corrupt cached server');
await expect(resolveManagedLocalRuntime(entry)).rejects.toThrow(
'checksum mismatch',
);
expect(download).toHaveBeenCalledTimes(2);
});
it.each([
{ ...process.versions, node: '21.7.3' },
{ ...process.versions, bun: '1.2.3' },
])(
'refuses an unsupported JavaScript runtime before loading the payload',
async (versions) => {
vi.stubGlobal('process', { ...process, versions });
await expect(resolve()).rejects.toThrow('requires Node.js 22 or newer');
},
);
},
);
@@ -0,0 +1,176 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { constants, createReadStream } from 'node:fs';
import { access, lstat, readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { Storage } from '@qwen-code/qwen-code-core/config/storage.js';
import { getCliVersion } from '../utils/version.js';
import { installManagedLocalRuntime } from './managed-local-download.js';
export interface ManagedLocalRuntime {
java: string;
serverJar: string;
cliEntry: string;
}
const execFileAsync = promisify(execFile);
async function resolveSystemJava(): Promise<string> {
const javaHome = process.env['JAVA_HOME'];
const candidates = javaHome
? [path.resolve(javaHome, 'bin', 'java')]
: (process.env['PATH'] ?? '')
.split(path.delimiter)
.map((directory) => path.resolve(directory, 'java'));
let java: string | undefined;
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK);
if (!(await stat(candidate)).isFile()) continue;
java = await realpath(candidate);
break;
} catch {
continue;
}
}
const requirement =
'Managed local serve requires local Java 21. Set JAVA_HOME to a Java 21 home or put Java 21 on PATH.';
if (!java) {
throw new Error(
`${requirement} No executable java found ${javaHome ? 'in JAVA_HOME/bin' : 'on PATH'}.`,
);
}
try {
const { stdout, stderr } = await execFileAsync(
java,
['-XshowSettings:properties', '-version'],
{ timeout: 10_000, maxBuffer: 64 * 1024 },
);
if (
!/^\s*java\.specification\.version\s*=\s*21\s*$/mu.test(
`${stdout}\n${stderr}`,
)
) {
throw new Error('Wrong Java version');
}
} catch {
throw new Error(`${requirement} Could not verify Java 21 at ${java}.`);
}
return java;
}
export async function resolveManagedLocalRuntime(
cliEntry = process.argv[1],
runtimeDirectory = process.env['QWEN_MANAGED_LOCAL_RUNTIME_DIR'],
): Promise<ManagedLocalRuntime> {
if (process.platform !== 'darwin' && process.platform !== 'linux') {
throw new Error('Managed local serve supports native macOS and Linux.');
}
if (
'bun' in process.versions ||
Number(process.versions.node.split('.')[0]) < 22
) {
throw new Error(
'Managed local serve requires Node.js 22 or newer; Bun is unsupported.',
);
}
if (!cliEntry) throw new Error('Cannot determine the installed CLI entry.');
const entry = await realpath(cliEntry);
const version = await getCliVersion();
const target = `${process.platform}-${process.arch}`;
const installed = path.join(
Storage.getGlobalQwenDir(),
'managed-runtime',
version,
target,
);
let directory = runtimeDirectory;
let systemJava: string | undefined;
if (directory === undefined) {
const colocated = path.join(path.dirname(entry), 'managed-runtime');
try {
await lstat(colocated);
directory = colocated;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
directory = installed;
try {
await lstat(installed);
} catch (installedError) {
if ((installedError as NodeJS.ErrnoException).code !== 'ENOENT') {
throw installedError;
}
systemJava = await resolveSystemJava();
await installManagedLocalRuntime(version, target, installed);
}
}
}
directory = path.resolve(directory);
let manifest: unknown;
try {
manifest = JSON.parse(
await readFile(path.join(directory, 'manifest.json'), 'utf8'),
);
} catch {
throw new Error(
`Managed runtime is missing or invalid at ${directory}. Install the managed-runtime archive for CLI ${version}, ${target} into ${Storage.getGlobalQwenDir()}, ` +
'use the complete Managed local distribution, or set QWEN_MANAGED_LOCAL_RUNTIME_DIR to its payload directory.',
);
}
if (
!manifest ||
typeof manifest !== 'object' ||
!('schemaVersion' in manifest) ||
manifest.schemaVersion !== 1 ||
!('cliVersion' in manifest) ||
manifest.cliVersion !== version ||
!('target' in manifest) ||
manifest.target !== target ||
!('javaMajor' in manifest) ||
manifest.javaMajor !== 21 ||
('javaSource' in manifest &&
manifest.javaSource !== 'bundled' &&
manifest.javaSource !== 'system') ||
!('serverSha256' in manifest) ||
typeof manifest.serverSha256 !== 'string' ||
!/^[a-f0-9]{64}$/u.test(manifest.serverSha256)
) {
throw new Error(
`Managed runtime must match CLI ${version}, ${target}, and Java 21.`,
);
}
const serverJar = path.join(directory, 'server.jar');
let java = path.join(directory, 'jre', 'bin', 'java');
const digest = createHash('sha256');
try {
for await (const chunk of createReadStream(serverJar)) digest.update(chunk);
if (digest.digest('hex') !== manifest.serverSha256) {
throw new Error('Server checksum mismatch');
}
if (!(await stat(serverJar)).isFile()) throw new Error('Not a server JAR');
if ('javaSource' in manifest && manifest.javaSource === 'system') {
java = systemJava ?? (await resolveSystemJava());
} else {
await access(java, constants.X_OK);
const release = await readFile(
path.join(directory, 'jre', 'release'),
'utf8',
);
if (!/^JAVA_VERSION="21(?:[.\-+"])/mu.test(release)) {
throw new Error('Java runtime is not Java 21');
}
}
} catch (error) {
throw new Error(
`Managed runtime verification failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
return { java, serverJar, cliEntry: entry };
}
@@ -0,0 +1,245 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import type { ChildProcess } from 'node:child_process';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import {
managedLocalJavaEnvironment,
runManagedLocalServe,
} from './managed-local-serve.js';
import type { ManagedLocalState } from './managed-local-state.js';
import path from 'node:path';
const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
openState: vi.fn(),
startWeb: vi.fn(),
stdout: vi.fn(),
loadModels: vi.fn(),
}));
vi.mock('node:child_process', () => ({ spawn: mocks.spawn }));
vi.mock('./managed-local-runtime.js', () => ({
resolveManagedLocalRuntime: async () => ({
java: '/package/jre/bin/java',
serverJar: '/package/server.jar',
cliEntry: '/package/cli.js',
}),
}));
vi.mock('./managed-local-state.js', () => ({
openManagedLocalState: mocks.openState,
}));
vi.mock('./managed-local-web.js', () => ({
signedManagedHeaders: () => ({}),
}));
vi.mock('./managed-local-models.js', () => ({
loadManagedLocalModels: mocks.loadModels,
}));
vi.mock('./run-qwen-serve.js', () => ({
runQwenServe: mocks.startWeb,
}));
vi.mock('./web-shell-resolver.js', () => ({
resolveWebShellDir: () => '/package/web-shell',
}));
vi.mock('../utils/stdioHelpers.js', () => ({
writeStdoutLine: mocks.stdout,
writeStderrLine: vi.fn(),
}));
describe('Managed local service environment', () => {
afterEach(() => vi.unstubAllEnvs());
it('pins local boundaries and keeps independent durable credentials out of argv', () => {
vi.stubEnv('SPRING_DATASOURCE_URL', 'jdbc:mysql://foreign/database');
vi.stubEnv('QWEN_MANAGED_AGENT_AUTH_MODE', 'off');
vi.stubEnv('QWEN_MANAGED_AGENT_RUNTIME_SSH_ENABLED', 'true');
vi.stubEnv('QWEN_SERVER_TOKEN', 'foreign-token');
const state: ManagedLocalState = {
schemaVersion: 1,
installationId: 'local-id',
publicToken: 'public',
harnessToken: 'harness',
brokerToken: 'broker',
signingKey: 'signing',
writerBindingKey: 'writer',
runtimeCredentialKey: 'runtime-key',
capabilityDigest: 'sha256:digest',
javaPort: 5011,
harnessPort: 5012,
brokerPort: 5013,
};
const models = [{ modelId: 'a,comma-model', label: 'Local model' }];
const environment = managedLocalJavaEnvironment(
state,
'/private/data',
{
java: '/package/jre/bin/java',
serverJar: '/package/server.jar',
cliEntry: '/package/cli.js',
},
models,
);
expect(JSON.parse(environment['SPRING_APPLICATION_JSON']!)).toEqual({
'qwen.managed-agent.model-catalog': models,
'qwen.managed-agent.definition-models': ['a,comma-model'],
'qwen.managed-agent.definition-tool-profiles': [
'hosted-workspace-shell/1',
],
});
expect(environment['SPRING_DATASOURCE_URL']).toBeUndefined();
expect(
environment['QWEN_MANAGED_AGENT_RUNTIME_SSH_ENABLED'],
).toBeUndefined();
expect(environment['QWEN_SERVER_TOKEN']).toBeUndefined();
expect(environment['SPRING_PROFILES_ACTIVE']).toBe('managed-local');
expect(environment['QWEN_MANAGED_AGENT_AUTH_MODE']).toBe('signed');
expect(environment['QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY']).toBe('signing');
expect(
environment['QWEN_MANAGED_AGENT_LOCAL_DIRECTORY_IMPORT_ENABLED'],
).toBe('true');
expect(environment['QWEN_MANAGED_AGENT_SESSION_STORE_BINDING_KEY']).toBe(
'writer',
);
expect(environment['QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN']).toBe(
'broker',
);
expect(environment['QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY']).toBe(
'runtime-key',
);
expect(environment['QWEN_MANAGED_AGENT_HARNESS_TOKEN']).toBe('harness');
expect(
environment['QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY'],
).toBe('false');
expect(environment['QWEN_MANAGED_AGENT_APPROVAL_MODE']).toBe('default');
expect(environment['QWEN_HOME']).toBe(
path.join('/private/data', 'worker-home'),
);
expect(environment['QWEN_MANAGED_AGENT_RUNTIME_WORKER_ENTRY']).toBe(
'/package/cli.js',
);
});
});
describe('Managed local startup supervision', () => {
it.each([
{ interruption: 'child exit', defaultModelId: undefined },
{ interruption: 'termination signal', defaultModelId: 'model-b' },
])(
'closes a listener created after $interruption during binding',
async ({ interruption, defaultModelId }) => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'managed-start-'));
const release = vi.fn(async () => {});
const close = vi.fn(async () => {});
const children: ChildProcess[] = [];
const state = {
installationId: 'installation',
publicToken: 'public',
harnessToken: 'harness',
signingKey: 'signing',
capabilityDigest: 'sha256:digest',
javaPort: 5011,
harnessPort: 5012,
brokerPort: 5013,
};
const models = [{ modelId: 'model-b', label: 'Model B' }];
const modelPresets = [
{ agentId: 'qwen-code', modelId: 'model-b', label: 'Model B' },
];
mocks.loadModels.mockReturnValue({ models, defaultModelId });
await mkdir(path.join(directory, 'logs'));
mocks.stdout.mockClear();
mocks.openState.mockResolvedValue({ state, directory, release });
mocks.spawn.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
pid: 123_456 + children.length,
exitCode: null as number | null,
signalCode: null as NodeJS.Signals | null,
kill: vi.fn(() => {
Object.assign(child, { signalCode: 'SIGTERM' });
child.emit('exit', null, 'SIGTERM');
return true;
}),
}) as unknown as ChildProcess;
children.push(child);
return child;
});
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
hostedHarness: {
capabilityDigest: state.capabilityDigest,
lifecycleV2Profiles: ['hosted-workspace-shell/1'],
},
}),
),
)
.mockResolvedValueOnce(new Response('ready'))
.mockResolvedValueOnce(new Response('{}')),
);
mocks.startWeb.mockImplementation(async () => {
if (interruption === 'child exit') {
Object.assign(children[1], { exitCode: 1 });
children[1].emit('exit', 1, null);
} else {
process.emit('SIGTERM');
}
return {
url: 'http://127.0.0.1:9000',
server: new EventEmitter(),
close,
};
});
try {
await expect(runManagedLocalServe({ port: 0 })).rejects.toThrow(
/server exited|startup interrupted/u,
);
expect(close).toHaveBeenCalled();
expect(release).toHaveBeenCalledOnce();
expect(mocks.startWeb).toHaveBeenCalledWith(
expect.objectContaining({
workspace: process.cwd(),
requireAuth: true,
token: state.publicToken,
requireWebShell: true,
}),
expect.objectContaining({
installSignalHandlers: false,
preheatBridge: false,
managedLocalWeb: expect.objectContaining({
javaUrl: 'http://127.0.0.1:5011',
productScope: 'local:installation',
modelPresets: defaultModelId
? modelPresets
: [
{ agentId: 'qwen-code', label: 'Server default model' },
...modelPresets,
],
}),
}),
);
expect(
JSON.parse(mocks.spawn.mock.calls[1]![2].env.SPRING_APPLICATION_JSON)[
'qwen.managed-agent.model-catalog'
],
).toEqual(models);
expect(children[0].kill).toHaveBeenCalledWith('SIGTERM');
expect(mocks.stdout.mock.calls.flat().join('\n')).not.toContain(
'WebShell:',
);
} finally {
vi.unstubAllGlobals();
await rm(directory, { recursive: true, force: true });
}
},
);
});
@@ -0,0 +1,432 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
import { closeSync, openSync } from 'node:fs';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js';
import {
resolveManagedLocalRuntime,
type ManagedLocalRuntime,
} from './managed-local-runtime.js';
import {
openManagedLocalState,
type ManagedLocalState,
} from './managed-local-state.js';
import { signedManagedHeaders } from './managed-local-web.js';
import { runQwenServe, type RunHandle } from './run-qwen-serve.js';
import { resolveWebShellDir } from './web-shell-resolver.js';
import {
loadManagedLocalModels,
type ManagedLocalModel,
} from './managed-local-models.js';
export interface ManagedLocalServeOptions {
port: number;
dataDirectory?: string;
}
export function managedLocalJavaEnvironment(
state: ManagedLocalState,
directory: string,
runtime: ManagedLocalRuntime,
models: readonly ManagedLocalModel[] = [],
): NodeJS.ProcessEnv {
const environment = Object.fromEntries(
Object.entries(process.env).filter(
([name]) =>
!name.startsWith('SPRING_') &&
!name.startsWith('QWEN_MANAGED_AGENT_') &&
!name.startsWith('QWEN_HOSTED_') &&
name !== 'QWEN_SERVER_TOKEN',
),
);
return {
...environment,
SPRING_APPLICATION_JSON: JSON.stringify({
'qwen.managed-agent.model-catalog': models,
'qwen.managed-agent.definition-models': models.map(
(model) => model.modelId,
),
'qwen.managed-agent.definition-tool-profiles': [
'hosted-workspace-shell/1',
],
}),
SPRING_PROFILES_ACTIVE: 'managed-local',
SERVER_PORT: String(state.javaPort),
QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY: directory,
QWEN_MANAGED_AGENT_AUTH_MODE: 'signed',
QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY: state.signingKey,
QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER: '',
QWEN_MANAGED_AGENT_LOCAL_DIRECTORY_IMPORT_ENABLED: 'true',
QWEN_MANAGED_AGENT_SERVER_ADDRESS: '127.0.0.1',
QWEN_MANAGED_AGENT_SESSION_STORE_ENABLED: 'true',
QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL: `http://127.0.0.1:${state.javaPort}`,
QWEN_MANAGED_AGENT_SESSION_STORE_BINDING_KEY: state.writerBindingKey,
QWEN_MANAGED_AGENT_WORKSPACE_ID: createHash('sha256')
.update(path.join(directory, 'legacy'))
.digest('hex')
.slice(0, 16),
QWEN_MANAGED_AGENT_WORKSPACE_CWD: path.join(directory, 'legacy'),
QWEN_MANAGED_AGENT_WORKSPACE_CREATION_ROOT: path.join(
directory,
'workspaces',
),
QWEN_MANAGED_AGENT_HARNESS_ENABLED: 'true',
QWEN_MANAGED_AGENT_HARNESS_BASE_URL: `http://127.0.0.1:${state.harnessPort}`,
QWEN_MANAGED_AGENT_HARNESS_TOKEN: state.harnessToken,
QWEN_MANAGED_AGENT_CAPABILITY_DIGEST: state.capabilityDigest,
QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED: 'true',
QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED: 'true',
QWEN_MANAGED_AGENT_APPROVAL_MODE: 'default',
QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED: 'true',
QWEN_MANAGED_AGENT_RUNTIME_BROKER_HOST: '127.0.0.1',
QWEN_MANAGED_AGENT_RUNTIME_BROKER_PORT: String(state.brokerPort),
QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN: state.brokerToken,
QWEN_MANAGED_AGENT_RUNTIME_PROVISIONER: 'local-process',
QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS: 'true',
QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY: 'false',
QWEN_MANAGED_AGENT_RUNTIME_VERIFIED_WORKSPACE_RECOVERY_ENABLED: 'true',
QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY_ID: 'managed-local-v1',
QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY: state.runtimeCredentialKey,
QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY: path.join(directory, 'runtime'),
QWEN_MANAGED_AGENT_NODE_EXECUTABLE: process.execPath,
QWEN_MANAGED_AGENT_RUNTIME_WORKER_ENTRY: runtime.cliEntry,
QWEN_HOME: path.join(directory, 'worker-home'),
};
}
async function stopChild(
child: ChildProcess,
processGroup: boolean,
): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null || !child.pid)
return;
const exited = new Promise<void>((resolve) =>
child.once('exit', () => resolve()),
);
child.kill('SIGTERM');
const stopped = await Promise.race([
exited.then(() => true),
delay(35_000, false, { ref: false }),
]);
if (!stopped) {
// The Harness owns its ACP children. Java workers retain SQL ownership
// across a control-plane exit and must not be killed as a process group.
try {
if (processGroup) process.kill(-child.pid, 'SIGKILL');
else child.kill('SIGKILL');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
}
await exited;
}
}
export async function runManagedLocalServe(
options: ManagedLocalServeOptions,
): Promise<{
url: string;
resolvedToken: string;
webShellMounted: true;
finished: Promise<number>;
close(): Promise<void>;
}> {
if (
!Number.isInteger(options.port) ||
options.port < 0 ||
options.port > 65535
) {
throw new Error('--port must be an integer from 0 to 65535.');
}
const runtime = await resolveManagedLocalRuntime();
const webShellDir = resolveWebShellDir();
if (!webShellDir)
throw new Error('The Managed distribution requires built WebShell assets.');
const local = await openManagedLocalState(options.dataDirectory);
const { state, directory } = local;
const children: Array<{
process: ChildProcess;
group: boolean;
}> = [];
let frontDoor: RunHandle | undefined;
let ready = false;
let failure: Error | undefined;
let closing: Promise<void> | undefined;
let finish: (code: number) => void = () => {};
const finished = new Promise<number>((resolve) => {
finish = resolve;
});
const shutdown = (code: number): Promise<void> => {
closing ??= (async () => {
let exitCode = code;
const cleanup = async (action: () => Promise<unknown>) => {
try {
await action();
} catch (error) {
exitCode = 1;
writeStderrLine(
`Managed shutdown: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
await cleanup(async () => frontDoor?.close());
for (const child of [...children].reverse()) {
await cleanup(() => stopChild(child.process, child.group));
}
process.off('SIGINT', onSignal);
process.off('SIGTERM', onSignal);
process.off('SIGHUP', onSignal);
await cleanup(local.release);
finish(exitCode);
})();
return closing;
};
const onSignal = () => {
void shutdown(0);
};
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
process.on('SIGHUP', onSignal);
const start = (
name: string,
command: string,
args: string[],
env: NodeJS.ProcessEnv,
group: boolean,
) => {
if (closing) throw new Error('Managed startup interrupted.');
const logPath = path.join(directory, 'logs', `${name}.log`);
const fd = openSync(logPath, 'a', 0o600);
let child: ChildProcess;
try {
child = spawn(command, args, {
cwd: directory,
env,
stdio: ['ignore', fd, fd],
// Terminal SIGINT belongs to the launcher. Durable workers must not
// receive it through Java's inherited foreground process group.
detached: true,
});
} finally {
closeSync(fd);
}
children.push({ process: child, group });
const failed = (reason: string) => {
failure ??= new Error(`${name} ${reason}. See ${logPath}`);
if (ready && !closing) {
writeStderrLine(`qwen serve --managed: ${failure.message}`);
void shutdown(1);
}
};
child.once('error', (error) => failed(error.message));
child.once('exit', (code, signal) => failed(`exited (${signal ?? code})`));
};
const waitFor = async (name: string, probe: () => Promise<boolean>) => {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
if (failure) throw failure;
if (closing) throw new Error('Managed startup interrupted.');
try {
if (await probe()) return;
} catch {
/* process is still starting */
}
await delay(200);
}
throw new Error(
`${name} did not become ready. See ${path.join(directory, 'logs')}`,
);
};
try {
const { models, defaultModelId } = loadManagedLocalModels();
if (
[state.javaPort, state.harnessPort, state.brokerPort].includes(
options.port,
)
) {
throw new Error(
'Public --port conflicts with a persisted private service port. Choose another public port.',
);
}
writeStdoutLine(`Starting Managed local services. Data: ${directory}`);
const javaUrl = `http://127.0.0.1:${state.javaPort}`;
const harnessUrl = `http://127.0.0.1:${state.harnessPort}`;
start(
'harness',
process.execPath,
[
runtime.cliEntry,
'serve',
'--profile',
'hosted-harness',
'--hostname',
'127.0.0.1',
'--port',
String(state.harnessPort),
'--require-auth',
'--no-web',
'--workspace',
path.join(directory, 'harness'),
],
{
...process.env,
QWEN_SERVER_TOKEN: state.harnessToken,
QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST: state.capabilityDigest,
QWEN_HOSTED_LIFECYCLE_V2_PROFILES: 'hosted-workspace-shell/1',
QWEN_MANAGED_RUNTIME_BROKER_URL: `http://127.0.0.1:${state.brokerPort}`,
QWEN_MANAGED_RUNTIME_BROKER_TOKEN: state.brokerToken,
QWEN_RUNTIME_DIR: path.join(directory, 'harness'),
},
true,
);
start(
'server',
runtime.java,
['-jar', runtime.serverJar],
managedLocalJavaEnvironment(state, directory, runtime, models),
false,
);
await waitFor('Hosted Harness', async () => {
const response = await fetch(`${harnessUrl}/capabilities`, {
headers: { Authorization: `Bearer ${state.harnessToken}` },
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
await response.body?.cancel();
return false;
}
const body: unknown = await response.json();
return Boolean(
body &&
typeof body === 'object' &&
'hostedHarness' in body &&
body.hostedHarness &&
typeof body.hostedHarness === 'object' &&
'capabilityDigest' in body.hostedHarness &&
body.hostedHarness.capabilityDigest === state.capabilityDigest &&
'lifecycleV2Profiles' in body.hostedHarness &&
Array.isArray(body.hostedHarness.lifecycleV2Profiles) &&
body.hostedHarness.lifecycleV2Profiles.includes(
'hosted-workspace-shell/1',
),
);
});
await waitFor('Java control plane', async () => {
const response = await fetch(`${javaUrl}/actuator/health/readiness`, {
signal: AbortSignal.timeout(3000),
});
const ok = response.ok;
await response.body?.cancel();
return ok;
});
const tenantId = `local-${state.installationId}`;
const actorId = 'local-user';
const modelPresets: Array<{
agentId: string;
label: string;
modelId?: string;
}> = models.map(({ modelId, label }) => ({
agentId: 'qwen-code',
modelId,
label,
}));
if (models.length && !defaultModelId) {
modelPresets.unshift({
agentId: 'qwen-code',
label: 'Server default model',
});
}
const route = '/api/agent/web-shell/v1/workspaces/create';
const body = Buffer.from(
JSON.stringify({
displayName: 'Local workspace',
idempotencyKey: 'managed-local-default-workspace-v1',
}),
);
const bootstrap = await fetch(`${javaUrl}${route}`, {
method: 'POST',
headers: {
...signedManagedHeaders({
method: 'POST',
path: route,
body,
signingKey: state.signingKey,
tenantId,
actorId,
}),
'Content-Type': 'application/json',
},
body,
signal: AbortSignal.timeout(30_000),
});
if (!bootstrap.ok) {
const message = (await bootstrap.text()).slice(0, 2000);
throw new Error(
`Local Workspace admission failed (${bootstrap.status}): ${message}`,
);
}
await bootstrap.body?.cancel();
if (failure) throw failure;
if (closing) throw new Error('Managed startup interrupted.');
frontDoor = await runQwenServe(
{
port: options.port,
hostname: '127.0.0.1',
mode: 'http-bridge',
token: state.publicToken,
requireAuth: true,
workspace: process.cwd(),
serveWebShell: true,
requireWebShell: true,
},
{
installSignalHandlers: false,
preheatBridge: false,
managedLocalWeb: {
javaUrl,
signingKey: state.signingKey,
tenantId,
actorId,
productScope: `local:${state.installationId}`,
agentId: 'qwen-code',
modelPresets,
},
},
);
frontDoor.server.once('close', () => {
if (!closing) {
writeStderrLine('qwen serve --managed: daemon listener closed.');
void shutdown(1);
}
});
ready = true;
if (failure || closing) {
await frontDoor.close();
throw failure ?? new Error('Managed startup interrupted.');
}
writeStdoutLine(
`Managed workspaces: ${path.join(directory, 'workspaces')}`,
);
writeStdoutLine(
`WebShell: ${frontDoor.url}/#token=${encodeURIComponent(state.publicToken)}`,
);
return {
url: frontDoor.url,
resolvedToken: state.publicToken,
webShellMounted: true,
finished,
close: () => shutdown(0),
};
} catch (error) {
await shutdown(1);
throw error;
}
}
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
mkdtemp,
readFile,
rm,
stat,
writeFile,
chmod,
symlink,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { openManagedLocalState } from './managed-local-state.js';
describe.skipIf(process.platform === 'win32')(
'Managed local persistent state',
() => {
let temporary: string;
beforeEach(async () => {
temporary = await mkdtemp(path.join(os.tmpdir(), 'managed-local-state-'));
});
afterEach(async () => {
await rm(temporary, { recursive: true, force: true });
});
it('retains credentials, identity and ports on restart and excludes concurrent launchers', async () => {
const directory = path.join(temporary, 'data');
const first = await openManagedLocalState(directory);
try {
expect((await stat(directory)).mode & 0o777).toBe(0o700);
expect(
(await stat(path.join(directory, 'state.json'))).mode & 0o777,
).toBe(0o600);
expect(
new Set([
first.state.publicToken,
first.state.harnessToken,
first.state.brokerToken,
first.state.signingKey,
first.state.writerBindingKey,
first.state.runtimeCredentialKey,
]).size,
).toBe(6);
await expect(openManagedLocalState(directory)).rejects.toThrow(
'already in use',
);
} finally {
await first.release();
}
const second = await openManagedLocalState(directory);
try {
expect(second.state).toEqual(first.state);
} finally {
await second.release();
}
});
it('does not replace corrupted or missing state alongside persisted data', async () => {
const directory = path.join(temporary, 'data');
const first = await openManagedLocalState(directory);
await first.release();
const statePath = path.join(directory, 'state.json');
const original = await readFile(statePath, 'utf8');
await writeFile(statePath, '{"schemaVersion":1}', { mode: 0o600 });
await expect(openManagedLocalState(directory)).rejects.toThrow(
'Invalid local state',
);
expect(await readFile(statePath, 'utf8')).toBe('{"schemaVersion":1}');
await writeFile(statePath, original, { mode: 0o600 });
await rm(statePath);
await expect(openManagedLocalState(directory)).rejects.toThrow(
'restore the original',
);
await expect(stat(statePath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects public credential files and symbolic-link data directories', async () => {
const directory = path.join(temporary, 'data');
const first = await openManagedLocalState(directory);
await first.release();
await chmod(path.join(directory, 'state.json'), 0o644);
await expect(openManagedLocalState(directory)).rejects.toThrow(
'mode 0600',
);
const link = path.join(temporary, 'link');
await symlink(directory, link);
await expect(openManagedLocalState(link)).rejects.toThrow(
'symbolic link',
);
});
},
);
@@ -0,0 +1,221 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { randomBytes, randomUUID } from 'node:crypto';
import {
access,
chmod,
lstat,
mkdir,
readFile,
readdir,
realpath,
rename,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { createServer } from 'node:net';
import lockfile from 'proper-lockfile';
import { Storage } from '@qwen-code/qwen-code-core/config/storage.js';
export interface ManagedLocalState {
schemaVersion: 1;
installationId: string;
publicToken: string;
signingKey: string;
harnessToken: string;
brokerToken: string;
writerBindingKey: string;
runtimeCredentialKey: string;
capabilityDigest: string;
javaPort: number;
harnessPort: number;
brokerPort: number;
}
const HEX_SECRETS = [
'publicToken',
'signingKey',
'harnessToken',
'brokerToken',
'writerBindingKey',
] as const;
export function validateManagedLocalState(value: unknown): ManagedLocalState {
if (!value || typeof value !== 'object')
throw new Error('Invalid local state');
const state = value as Record<string, unknown>;
if (
state['schemaVersion'] !== 1 ||
typeof state['installationId'] !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(
state['installationId'],
) ||
HEX_SECRETS.some(
(key) =>
typeof state[key] !== 'string' || !/^[0-9a-f]{64}$/u.test(state[key]),
) ||
typeof state['runtimeCredentialKey'] !== 'string' ||
!/^[A-Za-z0-9+/]{43}=$/u.test(state['runtimeCredentialKey']) ||
typeof state['capabilityDigest'] !== 'string' ||
!/^sha256:[0-9a-f]{64}$/u.test(state['capabilityDigest']) ||
['javaPort', 'harnessPort', 'brokerPort'].some(
(key) =>
!Number.isInteger(state[key]) ||
Number(state[key]) < 1 ||
Number(state[key]) > 65535,
) ||
new Set([state['javaPort'], state['harnessPort'], state['brokerPort']])
.size !== 3
)
throw new Error(
'Invalid local state; restore the original state.json and data together.',
);
return value as ManagedLocalState;
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
reject(new Error('Cannot allocate a local service port'));
return;
}
server.close((error) => (error ? reject(error) : resolve(address.port)));
});
});
}
export async function openManagedLocalState(
directory = path.join(Storage.getGlobalQwenDir(), 'managed-local'),
): Promise<{
directory: string;
state: ManagedLocalState;
release(): Promise<void>;
}> {
const requested = path.resolve(directory);
await mkdir(requested, { recursive: true, mode: 0o700 });
if ((await lstat(requested)).isSymbolicLink())
throw new Error('Managed data directory cannot be a symbolic link.');
const canonical = await realpath(requested);
for (let ancestor = canonical; ; ancestor = path.dirname(ancestor)) {
let inGit = false;
try {
await access(path.join(ancestor, '.git'));
inGit = true;
} catch {
/* absent */
}
if (inGit)
throw new Error(
'Managed data must be outside every Git worktree. Use --managed-data-dir outside the project.',
);
if (path.dirname(ancestor) === ancestor) break;
}
const existing = await readdir(canonical);
if (
existing.length &&
!existing.includes('state.json') &&
existing.some((entry) => entry !== '.launcher.lock')
) {
throw new Error(
'Managed data has no state.json; restore the original state and database together.',
);
}
await chmod(canonical, 0o700);
const release = await lockfile
.lock(canonical, {
realpath: true,
retries: 0,
stale: 120_000,
lockfilePath: path.join(canonical, '.launcher.lock'),
})
.catch(() => {
throw new Error(
'Managed data directory is already in use; stop its launcher before retrying.',
);
});
try {
const file = path.join(canonical, 'state.json');
let state: ManagedLocalState;
const entries = await readdir(canonical);
if (entries.includes('state.json')) {
const metadata = await lstat(file);
if (
!metadata.isFile() ||
metadata.isSymbolicLink() ||
metadata.size > 16 * 1024 ||
(metadata.mode & 0o077) !== 0
) {
throw new Error(
'Managed state.json must be a private regular file (mode 0600).',
);
}
state = validateManagedLocalState(
JSON.parse(await readFile(file, 'utf8')),
);
} else {
if (entries.some((name) => name !== '.launcher.lock')) {
throw new Error(
'Managed data has no state.json; restore the original state and database together.',
);
}
const ports = new Set<number>();
while (ports.size < 3) ports.add(await freePort());
const [javaPort, harnessPort, brokerPort] = [...ports] as [
number,
number,
number,
];
const secret = () => randomBytes(32).toString('hex');
state = {
schemaVersion: 1,
installationId: randomUUID(),
publicToken: secret(),
signingKey: secret(),
harnessToken: secret(),
brokerToken: secret(),
writerBindingKey: secret(),
runtimeCredentialKey: randomBytes(32).toString('base64'),
capabilityDigest: `sha256:${secret()}`,
javaPort,
harnessPort,
brokerPort,
};
const temporary = path.join(canonical, `state-${randomUUID()}.tmp`);
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
mode: 0o600,
flag: 'wx',
});
await rename(temporary, file);
}
for (const child of [
'database',
'runtime',
'worker-home',
'harness',
'workspaces',
'legacy',
'logs',
]) {
const target = path.join(canonical, child);
await mkdir(target, { recursive: true, mode: 0o700 });
const metadata = await lstat(target);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`Managed ${child} must be a private directory.`);
}
await chmod(target, 0o700);
}
return { directory: canonical, state, release };
} catch (error) {
await release();
throw error;
}
}
@@ -0,0 +1,371 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash, createHmac } from 'node:crypto';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { createServer, request as httpRequest } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import { createServeApp } from './server.js';
import { getServeAppLifecycle } from './serve-app-lifecycle.js';
import {
signedManagedHeaders,
mountManagedLocalWeb,
} from './managed-local-web.js';
const SIGNING_KEY = '0123456789abcdef0123456789abcdef';
describe('signedManagedHeaders', () => {
afterEach(() => vi.restoreAllMocks());
it('matches the Java signature over raw UTF-8, encoded query and idempotency key', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
expect(
signedManagedHeaders({
method: 'POST',
path: '/api/agent/web-shell/v1/sessions/create?a=1%2F2&b=hello+world',
body: Buffer.from('{"message":"汉字\\n"}'),
signingKey: SIGNING_KEY,
tenantId: 'local',
actorId: 'owner',
idempotencyKey: 'request-1',
}),
).toEqual({
'X-Qwen-Tenant-Id': 'local',
'X-Qwen-Actor-Id': 'owner',
'X-Qwen-Signature-Timestamp': '1700000000',
'X-Qwen-Signature':
'v1=245591f2cf83a9019a7c039cff1ec31a3142597761b4d1a9d54de509a8e0f02e',
'Idempotency-Key': 'request-1',
});
});
});
describe('Managed local front door', () => {
let directory: string;
let java: ReturnType<typeof createServer>;
let web: { url: string; close(): Promise<void> } | undefined;
let handler: (request: IncomingMessage, response: ServerResponse) => void;
const calls: Array<{
method?: string;
url?: string;
headers: IncomingMessage['headers'];
body: Buffer;
}> = [];
let javaUrl: string;
beforeEach(async () => {
calls.length = 0;
directory = await mkdtemp(path.join(tmpdir(), 'qwen-managed-web-'));
await mkdir(path.join(directory, 'assets'));
await writeFile(
path.join(directory, 'index.html'),
'<html><head><title>Qwen</title></head><body>app</body></html>',
);
await writeFile(path.join(directory, 'assets', 'app-12345678.js'), 'app;');
handler = (_request, response) => {
response.setHeader('content-type', 'application/json');
response.end('{"ok":true}');
};
java = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
calls.push({
method: request.method,
url: request.url,
headers: request.headers,
body: Buffer.concat(chunks),
});
handler(request, response);
});
await new Promise<void>((resolve) => java.listen(0, '127.0.0.1', resolve));
const address = java.address();
if (typeof address !== 'object' || !address)
throw new Error('Missing port');
javaUrl = `http://127.0.0.1:${address.port}`;
let port = 0;
const app = createServeApp(
{
hostname: '127.0.0.1',
mode: 'http-bridge',
port,
workspace: directory,
token: 'browser-token',
requireAuth: true,
},
() => port,
{ webShellDir: directory, managedLocalWeb: options() },
);
const server = createServer(app);
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve),
);
const publicAddress = server.address();
if (!publicAddress || typeof publicAddress === 'string')
throw new Error('Missing public port');
port = publicAddress.port;
web = {
url: `http://127.0.0.1:${port}`,
async close() {
await getServeAppLifecycle(app)?.close();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
});
function options() {
return {
javaUrl,
signingKey: SIGNING_KEY,
tenantId: 'local',
actorId: 'owner',
productScope: 'local:installation-1',
agentId: 'qwen-code',
modelPresets: [{ agentId: 'agent_local', label: 'Local model' }],
};
}
function get(url: string, init?: RequestInit) {
return fetch(`${web!.url}${url}`, {
...init,
headers: { Authorization: 'Bearer browser-token', ...init?.headers },
});
}
afterEach(async () => {
await web?.close();
web = undefined;
java.closeAllConnections();
await new Promise<void>((resolve) => java.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
});
it('serves public compiled assets with only a Managed marker and same-origin CSP', async () => {
const response = await fetch(
`${web!.url}/?daemon=https://attacker.example&fanout=https://other.example`,
);
const html = await response.text();
expect(html).toContain('<meta name="qwen-managed-app" content="true">');
expect(html).not.toContain('browser-token');
expect(html).not.toContain(SIGNING_KEY);
expect(response.headers.get('content-security-policy')).not.toContain(
'attacker',
);
expect(response.headers.get('content-security-policy')).not.toContain(
'other.example',
);
const asset = await fetch(`${web!.url}/assets/app-12345678.js`);
expect(await asset.text()).toBe('app;');
expect(calls).toHaveLength(0);
});
it('requires the front-door bearer for capabilities and public boot options', async () => {
for (const url of ['/managed/boot', '/capabilities']) {
const unauthenticated = await fetch(`${web!.url}${url}`);
expect(unauthenticated.status).toBe(401);
}
const response = await get('/managed/boot');
expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.json()).toEqual({
mode: 'managed',
productScope: 'local:installation-1',
agentId: 'qwen-code',
enableWorkspaceBinding: true,
modelPresets: [{ agentId: 'agent_local', label: 'Local model' }],
});
const capabilities = await (await get('/capabilities')).json();
expect(capabilities).toHaveProperty('workspaces');
expect(capabilities).toHaveProperty('features');
expect(calls).toHaveLength(0);
});
it('preserves exact raw bytes and query while replacing browser identity and private headers', async () => {
handler = (_request, response) => {
response.setHeader('content-type', 'application/json');
response.setHeader('set-cookie', 'private=value');
response.setHeader('x-qwen-runtime-broker-token', 'private-secret');
response.end('{"ok":true}');
};
const body = Buffer.from(' { "message" : "汉字\\n" }\n');
const target = '/api/agent/web-shell/v1/sessions/create?opaque=a%2Fb+c';
const response = await get(target, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Qwen-Tenant-Id': 'attacker',
'X-Qwen-Actor-Id': 'administrator',
'X-Qwen-Signature': 'forged',
'X-Qwen-Writer-Token': 'private-secret',
Cookie: 'secret=value',
'Idempotency-Key': 'command-1',
Origin: web!.url,
},
body,
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(response.headers.get('set-cookie')).toBeNull();
expect(response.headers.get('x-qwen-runtime-broker-token')).toBeNull();
const call = calls[0]!;
expect(call.url).toBe(target);
expect(call.body.equals(body)).toBe(true);
expect(call.headers['x-qwen-tenant-id']).toBe('local');
expect(call.headers['x-qwen-actor-id']).toBe('owner');
for (const name of [
'authorization',
'cookie',
'origin',
'x-qwen-writer-token',
]) {
expect(call.headers[name]).toBeUndefined();
}
const canonical = `qwen-broker-auth-v1\nPOST\n/api/agent/web-shell/v1/sessions/create\nopaque=a%2Fb+c\nlocal\nowner\n${call.headers['x-qwen-signature-timestamp']}\n${createHash('sha256').update(body).digest('hex')}\ncommand-1`;
expect(call.headers['x-qwen-signature']).toBe(
`v1=${createHmac('sha256', SIGNING_KEY).update(canonical).digest('hex')}`,
);
});
it('keeps ordinary health reads and JSON request handling in the daemon', async () => {
const response = await get('/health');
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('application/json');
const malformed = await get('/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{',
});
expect(malformed.status).toBe(400);
expect(calls).toHaveLength(0);
});
it('allows only exact artifact content reads and preserves range validators', async () => {
const target =
'/v1/agents/sessions/session-1/artifacts/artifact-1/content?revision=abc';
const response = await get(target, {
headers: { 'If-Match': '"abc"', Range: 'bytes=0-2' },
});
expect(response.status).toBe(200);
expect(calls[0]?.url).toBe(target);
expect(calls[0]?.headers['if-match']).toBe('"abc"');
expect(calls[0]?.headers.range).toBe('bytes=0-2');
expect((await get(target, { method: 'HEAD' })).status).toBe(200);
expect(calls[1]?.method).toBe('HEAD');
for (const [method, url] of [
['POST', target],
[
'GET',
'/v1/agents/sessions/session-1/artifacts/artifact-1/content/extra',
],
['GET', '/v1/agents/sessions/session-1/artifacts'],
['GET', '/internal/managed-session-store/v1/write'],
['GET', '/v1/agents'],
['GET', '/api/agent/web-shell/v10/sessions/query'],
]) {
const denied = await get(url!, {
method,
headers: { Accept: 'text/html' },
});
expect(denied.status).toBe(404);
expect(denied.headers.get('content-type')).toContain('application/json');
}
expect(calls).toHaveLength(2);
});
it('rejects foreign browser origins and DNS-rebinding Host values', async () => {
expect(
(
await get('/managed/boot', {
headers: { Origin: 'https://attacker.example' },
})
).status,
).toBe(403);
const status = await new Promise<number | undefined>((resolve, reject) => {
const request = httpRequest(
`${web!.url}/managed/boot`,
{
headers: {
Host: 'attacker.example',
Authorization: 'Bearer browser-token',
},
},
(response) => {
response.resume();
response.once('end', () => resolve(response.statusCode));
},
);
request.once('error', reject);
request.end();
});
expect(status).toBe(403);
expect(calls).toHaveLength(0);
});
it('bounds signed bodies and refuses repeated idempotency headers before Java', async () => {
const target = '/api/agent/web-shell/v1/sessions/create';
const oversized = await get(target, {
method: 'POST',
body: Buffer.alloc(10 * 1024 * 1024 + 1),
});
expect(oversized.status).toBe(413);
const status = await new Promise<number | undefined>((resolve, reject) => {
const request = httpRequest(
`${web!.url}${target}`,
{
method: 'POST',
headers: {
Authorization: 'Bearer browser-token',
'Idempotency-Key': ['one', 'two'],
},
},
(response) => {
response.resume();
response.once('end', () => resolve(response.statusCode));
},
);
request.once('error', reject);
request.end();
});
expect(status).toBe(400);
expect(calls).toHaveLength(0);
});
it('streams SSE before completion and cancels Java when the browser disconnects', async () => {
let disconnected = false;
handler = (_request, response) => {
response.setHeader('content-type', 'text/event-stream');
response.write('data: {"ready":true}\n\n');
response.once('close', () => {
disconnected = true;
});
};
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(() => undefined);
await vi.waitFor(() => expect(disconnected).toBe(true));
});
it('refuses non-loopback Java targets', () => {
expect(() =>
mountManagedLocalWeb(express(), {
...options(),
javaUrl: 'http://attacker.example',
}),
).toThrow('loopback');
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash, createHmac } from 'node:crypto';
import { request as httpRequest } from 'node:http';
import express from 'express';
import type { Application, ErrorRequestHandler } from 'express';
import { isLoopbackAddress } from './loopback-binds.js';
export interface ManagedLocalWebOptions {
javaUrl: string;
signingKey: string;
tenantId: string;
actorId: string;
productScope: string;
agentId: string;
modelPresets?: ReadonlyArray<{
agentId: string;
label: string;
modelId?: string;
}>;
}
export function signedManagedHeaders(options: {
method: string;
path: string;
body: Buffer;
signingKey: string;
tenantId: string;
actorId: string;
idempotencyKey?: string;
}): Record<string, string> {
const queryIndex = options.path.indexOf('?');
const timestamp = String(Math.floor(Date.now() / 1000));
const canonical = [
'qwen-broker-auth-v1',
options.method,
queryIndex < 0 ? options.path : options.path.slice(0, queryIndex),
queryIndex < 0 ? '' : options.path.slice(queryIndex + 1),
options.tenantId,
options.actorId,
timestamp,
createHash('sha256').update(options.body).digest('hex'),
options.idempotencyKey ?? '',
].join('\n');
return {
'X-Qwen-Tenant-Id': options.tenantId,
'X-Qwen-Actor-Id': options.actorId,
'X-Qwen-Signature-Timestamp': timestamp,
'X-Qwen-Signature': `v1=${createHmac('sha256', options.signingKey).update(canonical).digest('hex')}`,
...(options.idempotencyKey === undefined
? {}
: { 'Idempotency-Key': options.idempotencyKey }),
};
}
const RESPONSE_HEADERS = [
'content-type',
'content-length',
'content-disposition',
'content-range',
'accept-ranges',
'etag',
'retry-after',
'x-qwen-request-id',
'x-qwen-idempotent-replay',
];
const ARTIFACT_CONTENT =
/^\/v1\/agents\/sessions\/[^/?]+\/artifacts\/[^/?]+\/content$/u;
export function mountManagedLocalWeb(
app: Application,
options: ManagedLocalWebOptions,
): void {
const java = new URL(options.javaUrl);
if (
java.protocol !== 'http:' ||
!isLoopbackAddress(java.hostname) ||
java.username ||
java.password ||
java.pathname !== '/' ||
java.search ||
java.hash
) {
throw new Error('Managed local Java URL must be a loopback HTTP origin.');
}
app.get('/managed/boot', (_req, res) => {
res.set('Cache-Control', 'no-store').json({
mode: 'managed',
productScope: options.productScope,
agentId: options.agentId,
enableWorkspaceBinding: true,
modelPresets: options.modelPresets,
});
});
app.use(
['/api/agent/web-shell/v1', ARTIFACT_CONTENT],
express.raw({ type: () => true, limit: 10 * 1024 * 1024, inflate: false }),
(req, res) => {
const rawPath = req.originalUrl.split('?')[0]!;
if (
!(
/^\/api\/agent\/web-shell\/v1\/[a-z0-9_-]+(?:\/[a-z0-9_-]+)*\/?$/iu.test(
rawPath,
) ||
((req.method === 'GET' || req.method === 'HEAD') &&
ARTIFACT_CONTENT.test(rawPath))
)
) {
res.status(404).json({ error: 'not_found' });
return;
}
let idempotencyHeaders = 0;
for (let index = 0; index < req.rawHeaders.length; index += 2) {
if (req.rawHeaders[index]?.toLowerCase() === 'idempotency-key') {
idempotencyHeaders++;
}
}
if (idempotencyHeaders > 1) {
res.status(400).json({ error: 'invalid_request' });
return;
}
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const idempotencyKey = req.get('idempotency-key');
const headers: Record<string, string> = {
...signedManagedHeaders({
method: req.method,
path: req.originalUrl,
body,
signingKey: options.signingKey,
tenantId: options.tenantId,
actorId: options.actorId,
idempotencyKey,
}),
'content-length': String(body.byteLength),
};
for (const name of ['content-type', 'accept', 'if-match', 'range']) {
const value = req.get(name);
if (value !== undefined) headers[name] = value;
}
const upstream = httpRequest(
java,
{ method: req.method, path: req.originalUrl, headers },
(response) => {
res.status(response.statusCode ?? 502);
for (const name of RESPONSE_HEADERS) {
const value = response.headers[name];
if (value !== undefined) res.setHeader(name, value);
}
res.setHeader('cache-control', 'no-store');
res.setHeader('x-content-type-options', 'nosniff');
response.on('error', () => res.destroy());
response.pipe(res);
},
);
upstream.on('error', () => {
if (res.destroyed) return;
if (res.headersSent) res.destroy();
else res.status(502).json({ error: 'managed_service_unavailable' });
});
res.once('close', () => upstream.destroy());
upstream.end(body);
},
);
app.use(
['/api/agent', '/v1/agents', '/internal/managed-session-store'],
(_req, res) => {
res.status(404).json({ error: 'not_found' });
},
);
const handleError: ErrorRequestHandler = (error, _req, res, _next) => {
res
.status(error?.type === 'entity.too.large' ? 413 : 400)
.json({ error: 'invalid_request' });
};
app.use(handleError);
}
@@ -235,6 +235,26 @@ const allowedProcessEnvAccesses = normalizeAllowances([
},
},
],
[
'packages/cli/src/serve/managed-local-runtime.ts',
{
reason:
'The foreground Managed launcher resolves its version-matched runtime and Java from deployment-owned process settings before any Workspace exists.',
accesses: {
'key:JAVA_HOME': 1,
'key:PATH': 1,
'key:QWEN_MANAGED_LOCAL_RUNTIME_DIR': 1,
},
},
],
[
'packages/cli/src/serve/managed-local-serve.ts',
{
reason:
'The foreground Managed launcher starts its private Java and Hosted Harness processes from its own deployment environment, before any Workspace environment is resolved.',
accesses: { whole: 2 },
},
],
[
'packages/cli/src/serve/managed-mcp-runtime.ts',
{
@@ -20246,6 +20246,61 @@ describe('runQwenServe channel worker supervisor', () => {
expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid);
});
it('lets the supervising application own signals while mounting Managed routes on the ordinary daemon', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-unified-managed-')),
);
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
const listeners = signals.map((signal) => process.rawListeners(signal));
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
workspace: tmpDir,
token: 'unified-token',
mode: 'http-bridge',
requireAuth: true,
serveWebShell: false,
},
{
bridge: makeFakeBridge(),
installSignalHandlers: false,
managedLocalWeb: {
javaUrl: 'http://127.0.0.1:5011',
signingKey: 'private-signing-key',
tenantId: 'local',
actorId: 'owner',
agentId: 'qwen-code',
productScope: 'local:installation',
},
},
);
try {
for (const [index, signal] of signals.entries()) {
const added = process
.rawListeners(signal)
.filter((listener) => !listeners[index].includes(listener));
expect(added.some((listener) => listener.name === 'onSignal')).toBe(
false,
);
}
expect((await fetch(`${handle.url}/managed/boot`)).status).toBe(401);
const headers = { Authorization: 'Bearer unified-token' };
const boot = await fetch(`${handle.url}/managed/boot`, { headers });
expect(boot.status).toBe(200);
expect(await boot.json()).toMatchObject({
mode: 'managed',
productScope: 'local:installation',
});
const capabilities = await fetch(`${handle.url}/capabilities`, {
headers,
});
expect(await capabilities.json()).toHaveProperty('workspaces');
} finally {
await handle.close();
}
});
it('force-kills channel worker, bridge, and pidfile on a second shutdown signal', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-force-')),
+13 -6
View File
@@ -14,6 +14,7 @@ import { execFile } from 'node:child_process';
import { lookup } from 'node:dns/promises';
import * as fs from 'node:fs';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import type { ManagedLocalWebOptions } from './managed-local-web.js';
import * as https from 'node:https';
import { isIP } from 'node:net';
import * as path from 'node:path';
@@ -2120,6 +2121,9 @@ function buildProviderSetupInputs(
}
export interface RunQwenServeDeps {
/** The application launcher owns signals when it supervises other services. */
installSignalHandlers?: boolean;
managedLocalWeb?: ManagedLocalWebOptions;
/** Only CLI entrypoints opt into replacing their own daemon process. */
updateRestartArgv?: readonly string[];
/** Bridge instance; tests inject a fake. Defaults to a fresh real one. */
@@ -2131,9 +2135,9 @@ export interface RunQwenServeDeps {
hostname: string,
) => Promise<{ address: string; family: number }>;
/**
* Whether to start the real ACP child eagerly after listen. Production
* keeps this on; tests can disable it so boot-path assertions do not wait
* on a real child bridge.
* Whether to start the real ACP child eagerly after listen. The ordinary
* CLI keeps this on; the Managed application starts Local ACP on demand.
* Tests can disable it to avoid waiting on a real child bridge.
*/
preheatBridge?: boolean;
/**
@@ -8522,6 +8526,7 @@ async function runQwenServeImpl(
};
const app = runtime.createServeApp(opts, () => actualPort, {
managedLocalWeb: deps.managedLocalWeb,
restartForUpdate,
maxChannelControlWorkspaces: MAX_CHANNEL_CONTROL_WORKSPACES,
serveAppLifecycle,
@@ -10917,9 +10922,11 @@ async function runQwenServeImpl(
`\`qwen daemon stop --port ${actualPort}\`.`,
);
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
process.on('SIGHUP', onSignal);
if (deps.installSignalHandlers !== false) {
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
process.on('SIGHUP', onSignal);
}
process.on('uncaughtExceptionMonitor', onUncaughtExceptionMonitor);
// The per-attempt boot-error listener was removed by handleListening.
+28
View File
@@ -107,6 +107,10 @@ import {
mountWebShellSpaFallback,
} from './web-shell-static.js';
import { mountMcpAppSandbox } from './mcp-app-sandbox.js';
import {
mountManagedLocalWeb,
type ManagedLocalWebOptions,
} from './managed-local-web.js';
import {
mountWorkspaceMemoryRoutes,
mountWorkspaceQualifiedMemoryRoutes,
@@ -531,6 +535,7 @@ function getRuntimeEffectiveEnv(
}
export interface ServeAppDeps {
managedLocalWeb?: ManagedLocalWebOptions;
restartForUpdate?: (launcher: string) => Promise<void>;
/** Bridge instance; tests inject a fake. Defaults to a fresh real one. */
bridge?: AcpSessionBridge;
@@ -2369,6 +2374,7 @@ export function createServeApp(
webShellDir,
webShellFrameAncestors,
opts.clientMcpOverWs === true,
deps.managedLocalWeb !== undefined,
);
(app.locals as { stopMcpAppSandbox?: () => void }).stopMcpAppSandbox =
mountMcpAppSandbox(app, (origin, req) => {
@@ -2509,6 +2515,27 @@ export function createServeApp(
healthRoutes.register(app);
}
if (deps.managedLocalWeb) {
if (
opts.hostname !== '127.0.0.1' ||
!opts.requireAuth ||
!opts.token ||
opts.profile === 'hosted-harness'
) {
throw new Error(
'Managed local WebShell requires an authenticated loopback daemon.',
);
}
app.use(['/managed/boot', '/api/agent', '/v1/agents'], (req, res, next) => {
if (listenerIdentityOf(req).kind !== 'primary') {
res.status(403).json({ error: 'primary_listener_required' });
return;
}
next();
});
mountManagedLocalWeb(app, deps.managedLocalWeb);
}
installJsonBodyParser(app);
const hostedHarness =
@@ -4205,6 +4232,7 @@ export function createServeApp(
webShellDir,
webShellFrameAncestors,
opts.clientMcpOverWs === true,
deps.managedLocalWeb !== undefined,
);
}
@@ -288,6 +288,41 @@ describe('public PWA HTTP routes', () => {
await rm(directory, { recursive: true, force: true });
});
it('adds the Managed marker only to Managed HTML and excludes remote daemon origins', async () => {
await writeFile(
path.join(directory, 'index.html'),
'<html><head><title>Shell</title></head><body>app</body></html>',
);
const managed = express();
mountWebShellAssets(managed, directory, [], false, true);
mountWebShellSpaFallback(managed, directory, [], false, true);
const response = await request(managed)
.get('/?daemon=https://attacker.example&fanout=https://other.example')
.expect(200);
expect(response.text).toContain(
'<meta name="qwen-managed-app" content="true">',
);
expect(response.headers['cache-control']).toBe('no-cache');
expect(response.headers['content-security-policy']).not.toContain(
'attacker.example',
);
expect(response.headers['content-security-policy']).not.toContain(
'other.example',
);
const deepLink = await request(managed)
.get('/nested-view?daemon=https://attacker.example')
.set('Accept', 'text/html')
.expect(200);
expect(deepLink.text).toContain(
'<meta name="qwen-managed-app" content="true">',
);
expect(deepLink.headers['content-security-policy']).not.toContain(
'attacker.example',
);
const ordinary = await request(app).get('/').expect(200);
expect(ordinary.text).not.toContain('qwen-managed-app');
});
it.each([false, true])(
'limits desktop relay CSP to the opt-in endpoint (enabled=%s)',
async (enabled) => {
+37 -4
View File
@@ -5,6 +5,7 @@
*/
import * as path from 'node:path';
import { readFile } from 'node:fs/promises';
import express from 'express';
import type { Application, NextFunction, Request, Response } from 'express';
import { writeStderrLine } from '../utils/stdioHelpers.js';
@@ -172,14 +173,21 @@ function createSendIndex(
webShellDir: string,
frameAncestors: readonly string[] = [],
desktopRelayEnabled = false,
managedApp = false,
): (req: Request, res: Response) => void {
const indexPath = path.join(webShellDir, 'index.html');
return (req: Request, res: Response): void => {
const csp = buildWebShellCsp(frameAncestors, [
...remoteDaemonConnectOrigins(requestedDaemonParam(req.originalUrl)),
...requestedFanoutParams(req.originalUrl).flatMap(
remoteDaemonConnectOrigins,
),
...(!managedApp
? [
...remoteDaemonConnectOrigins(
requestedDaemonParam(req.originalUrl),
),
...requestedFanoutParams(req.originalUrl).flatMap(
remoteDaemonConnectOrigins,
),
]
: []),
...(desktopRelayEnabled ? ['http://127.0.0.1:47821'] : []),
]);
res
@@ -204,6 +212,27 @@ function createSendIndex(
if (frameAncestors.length === 0) {
res.set('X-Frame-Options', 'DENY');
}
if (managedApp) {
void readFile(indexPath, 'utf8')
.then((html) => {
res
.type('html')
.send(
html.replace(
/<\/head>/iu,
'<meta name="qwen-managed-app" content="true"></head>',
),
);
})
.catch(() => {
if (!res.headersSent) {
res.status(500).type('text/plain').send('Failed to load Web Shell');
} else {
res.end();
}
});
return;
}
// `dotfiles: 'allow'` is required because the resolved path may pass
// through a dotfile directory (e.g. ~/.nvm/.../web-shell/index.html).
// The `send` library defaults to 'ignore' which returns a 404 for any
@@ -261,11 +290,13 @@ export function mountWebShellAssets(
webShellDir: string,
frameAncestors: readonly string[] = [],
desktopRelayEnabled = false,
managedApp = false,
): void {
const sendIndex = createSendIndex(
webShellDir,
frameAncestors,
desktopRelayEnabled,
managedApp,
);
app.use(
'/assets',
@@ -374,11 +405,13 @@ export function mountWebShellSpaFallback(
webShellDir: string,
frameAncestors: readonly string[] = [],
desktopRelayEnabled = false,
managedApp = false,
): void {
const sendIndex = createSendIndex(
webShellDir,
frameAncestors,
desktopRelayEnabled,
managedApp,
);
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
+79 -2
View File
@@ -16,6 +16,78 @@ authentication and writer credentials" below.
设计说明:[English](../../docs/design/2026-09-19-managed-agent-spring-server.md) |
[简体中文](../../docs/design/2026-09-19-managed-agent-spring-server.zh-CN.md)
## Local embedded profile
The installed CLI starts local Managed mode with `qwen serve --managed --open`.
It checks local Java 21, downloads a matching server JAR on first use, and reuses
the verified versioned cache on subsequent starts. An explicitly preinstalled
runtime can instead bundle Java 21 for offline use. It needs no MySQL, Docker,
Maven or systemd. The ordinary server profile keeps its
existing MySQL defaults. Design:
[English](../../docs/design/2026-10-20-managed-local-serve.md) |
[简体中文](../../docs/design/2026-10-20-managed-local-serve.zh-CN.md).
The `central-release` Maven profile attaches sources, Javadoc and GPG signatures
for the main executable server JAR, without attaching the auxiliary operator
JARs. Ordinary builds keep the existing attachments and do not activate Central
publishing. The protected `Release Managed server runtime` workflow sets the
artifact version from an existing CLI release tag and publishes identical JAR
bytes to Central, GitHub Release assets and the official OSS mirror. The CLI's
first-use download uses OSS/GitHub and requires no Maven installation. See the
[release procedure](../../docs/users/managed-local-serve.md#publish-the-first-use-jar)
and its [Chinese version](../../docs/users/managed-local-serve.zh-CN.md#发布首次使用的-jar).
The launcher selects the `managed-local` Spring profile and supplies an
existing canonical, owner-only `QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY`.
The directory and its private subdirectories must be owned by the current
user with mode `0700`; symlinks, noncanonical paths and data-directory paths
containing `;` are refused. The profile creates these directories:
| Path | Persistent contents |
| ------------------------ | ----------------------------------------------------------------------------- |
| `database/managed.mv.db` | Embedded H2 database with the canonical Managed schema |
| `runtime/` | Durable local Runtime worker state |
| `workspaces/` | Application-owned Workspace storage roots and their original identity markers |
| `legacy/` | Private fallback cwd for unbound Sessions |
H2 uses MySQL compatibility mode, lowercase catalog metadata,
`DB_CLOSE_ON_EXIT=FALSE` and `WRITE_DELAY=0`. Its embedded file lock and the
launcher's data-directory lock admit one local control plane. The connection
pool retains an idle connection to keep that file lock held. No H2 TCP server,
`AUTO_SERVER`, or Web console is enabled. Database URL overrides are refused;
the profile keeps the existing Flyway admission and structure validation and
never repairs foreign, partial, history-only or unsupported catalogs.
The launcher supplies the existing Harness URL/token/digest, Session Store
base URL and binding key, Broker token and credential encryption key, Node
executable and worker entry. `QWEN_MANAGED_AGENT_WORKSPACE_ID` is the first
16 hexadecimal characters of the SHA-256 of the canonical `legacy/` path;
it identifies the fallback Runtime and Session Store, independently of
the bound Workspace IDs created through the API. Public API requests use signed authentication;
the front door retains the signing key and fixed tenant/actor identity.
Java, Harness and Broker listeners must stay on loopback. Readiness is
`GET /actuator/health/readiness`, which includes the database and Spring's
readiness state. The public front door opens after readiness and the
authenticated Harness capability probe succeed.
The profile enables verified, durable local-process workers, file tools and
Shell with `default` approval mode. The launcher creates the first Workspace
through the existing signed WebShell creation API using a persistent
idempotency key. It creates a new application-owned Workspace; the shell's
current Git checkout is not copied, symlinked or imported. The existing
Workspace ownership and grant checks still apply. Tool publication remains
disabled unless separately configured; ordinary persisted tool receipts do
not require an object store.
Keep the database, Runtime state, Workspace roots, credentials and worker
`QWEN_HOME/file-history` together across restarts. Credentials never rotate
merely because the application restarts. The local profile defaults trusted
whole-host reboot recovery to `false`; macOS supports same-boot worker
adoption only. Linux operators may explicitly enable
`QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY` under its existing
trusted-host contract. Missing or conflicting physical identity markers
remain closed to execution; startup does not re-register or repair them.
## Java package layout
Business components live in `managedagent.session`, `workspace`, `artifact`,
@@ -755,7 +827,9 @@ An uncertain response must be retried with the same key and name. The page prese
For a trusted single-user deployment bound to loopback, enable
`QWEN_MANAGED_AGENT_LOCAL_DIRECTORY_IMPORT_ENABLED=true` alongside Workspace files,
durable local-process execution, verified recovery, and the trusted actor header.
durable local-process execution and verified recovery. Authenticate with signed
requests or OPEN mode plus a trusted actor header. The single-user
`qwen serve --managed` launcher enables this capability with signed authentication.
Then **Workspace → Choose existing directory** browses this computer directly,
without a pre-registered parent or grant. Import keeps the project in place,
automatically registers its projects parent outside Git worktrees, grants the
@@ -763,7 +837,10 @@ current actor OWNER access, and sets the project as the initial working director
The parent shares file access and its physical-owner lease across projects; this
is not a filesystem sandbox. Existing owned roots are reused while sessions run.
Private Runtime state and creation roots must stay outside imported parents.
Remote listeners and signed/shared deployments cannot enable this local feature.
Remote listeners cannot enable this local feature, including with the insecure
bind override. Signed mode retains signature validation and does not accept a
trusted actor header in its place. This capability is for a trusted single-user
host deployment.
The actor-authenticated directory query accepts `localPath` (empty starts at the
host home directory), and create accepts an absolute `localPath` with the name
+88 -1
View File
@@ -15,9 +15,29 @@
<version>0.1.0-alpha</version>
<name>Qwen Managed Agent Server</name>
<description>Standalone Spring control plane for Qwen Managed Agents.</description>
<url>https://github.com/QwenLM/qwen-code/tree/main/packages/managed-agent-server</url>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
<distribution>repo</distribution>
</license>
</licenses>
<scm>
<url>https://github.com/QwenLM/qwen-code</url>
<connection>scm:git:https://github.com/QwenLM/qwen-code.git</connection>
<developerConnection>scm:git:ssh://[email protected]/QwenLM/qwen-code.git</developerConnection>
</scm>
<developers>
<developer>
<name>Qwen Code contributors</name>
<url>https://github.com/QwenLM/qwen-code/graphs/contributors</url>
</developer>
</developers>
<properties>
<java.version>21</java.version>
<managed.operator-tools.attach>true</managed.operator-tools.attach>
<druid.version>1.2.25</druid.version>
<qwencode-sdk.version>0.1.0-alpha</qwencode-sdk.version>
<fastjson2.version>2.0.65</fastjson2.version>
@@ -107,7 +127,7 @@
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -152,6 +172,7 @@
<goals><goal>repackage</goal></goals>
<configuration>
<classifier>workspace-bundle</classifier>
<attach>${managed.operator-tools.attach}</attach>
<mainClass>com.alibaba.qwen.code.managedagent.persistence.WorkspaceRecoveryMain</mainClass>
</configuration>
</execution>
@@ -160,6 +181,7 @@
<goals><goal>repackage</goal></goals>
<configuration>
<classifier>workspace-migration</classifier>
<attach>${managed.operator-tools.attach}</attach>
<mainClass>com.alibaba.qwen.code.managedagent.workspace.runtime.WorkspaceMigrationMain</mainClass>
</configuration>
</execution>
@@ -168,6 +190,7 @@
<goals><goal>repackage</goal></goals>
<configuration>
<classifier>workspace-maintenance-recovery</classifier>
<attach>${managed.operator-tools.attach}</attach>
<mainClass>com.alibaba.qwen.code.managedagent.persistence.WorkspaceMaintenanceRecoveryMain</mainClass>
</configuration>
</execution>
@@ -176,6 +199,7 @@
<goals><goal>repackage</goal></goals>
<configuration>
<classifier>csi-session</classifier>
<attach>${managed.operator-tools.attach}</attach>
<mainClass>com.alibaba.qwen.code.managedagent.persistence.WorkspaceCsiSessionMain</mainClass>
</configuration>
</execution>
@@ -184,6 +208,7 @@
<goals><goal>repackage</goal></goals>
<configuration>
<classifier>csi-runtime</classifier>
<attach>${managed.operator-tools.attach}</attach>
<mainClass>com.alibaba.qwen.code.managedagent.persistence.WorkspaceCsiRuntimeMain</mainClass>
</configuration>
</execution>
@@ -192,6 +217,7 @@
<goals><goal>repackage</goal></goals>
<configuration>
<classifier>operator-recovery</classifier>
<attach>${managed.operator-tools.attach}</attach>
<mainClass>com.alibaba.qwen.code.managedagent.workspace.runtime.WorkspaceRecoveryCommand</mainClass>
</configuration>
</execution>
@@ -280,6 +306,67 @@
</build>
<profiles>
<profile>
<id>central-release</id>
<properties>
<managed.operator-tools.attach>false</managed.operator-tools.attach>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.11.0</version>
<extensions>true</extensions>
<configuration>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>published</waitUntil>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.4.0</version>
<executions>
<execution>
<id>attach-sources</id>
<goals><goal>jar-no-fork</goal></goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.12.0</version>
<executions>
<execution>
<id>attach-javadocs</id>
<goals><goal>jar</goal></goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.2.8</version>
<configuration>
<gpgArguments>
<arg>--pinentry-mode</arg>
<arg>loopback</arg>
</gpgArguments>
</configuration>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals><goal>sign</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>fault-gates</id>
<properties>
@@ -226,6 +226,10 @@ public class WebShellAgentController {
"unsupported_approval_mode",
"Approval mode selection requires a bound Workspace.");
}
if (selection == null && request.modelId() != null) {
throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature",
"Model selection requires a bound Workspace.");
}
if (selection == null && request.skills() != null && !request.skills().isEmpty()) {
throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature",
"Session Skills require a bound Workspace.");
@@ -241,7 +245,7 @@ public class WebShellAgentController {
: service.createWorkspaceSession(tenant.tenantId(),
tenant.requireActorId(), request.idempotencyKey(),
request.agentId(), null, request.title(), null,
request.input(), selection, request.approvalMode(), request.planMode(), request.skills()));
request.input(), selection, request.approvalMode(), request.planMode(), request.skills(), request.modelId()));
return ResponseEntity.accepted().body(admission);
}
@@ -123,10 +123,10 @@ public class BrokerSecurity {
this.signingKey = null;
}
if (properties.getRuntimeBroker().isLocalDirectoryImportEnabled()
&& (mode != Mode.OPEN || !publicLoopback
|| StringUtils.isBlank(properties.getTrustedActorHeader()))) {
throw new IllegalStateException("Local directory import requires a loopback OPEN listener"
+ " with a trusted actor header for a single-user deployment.");
&& (!publicLoopback || (mode == Mode.OPEN
&& StringUtils.isBlank(properties.getTrustedActorHeader())))) {
throw new IllegalStateException("Local directory import requires a loopback listener"
+ " with signed authentication or a trusted actor header for a single-user deployment.");
}
this.allowedDrift = auth.getAllowedDrift() == null
? Duration.ofMinutes(5) : auth.getAllowedDrift();
@@ -0,0 +1,109 @@
package com.alibaba.qwen.code.managedagent.config;
import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Set;
@Configuration
@Profile("managed-local")
public class ManagedLocalConfiguration {
@Bean(destroyMethod = "close")
DruidDataSource managedLocalDataSource(DataSourceProperties source,
ServerProperties server, ManagedAgentProperties properties,
Environment environment) throws IOException {
Path directory = Path.of(environment.getRequiredProperty(
"QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY"));
requirePrivateDirectory(directory);
if (directory.toString().contains(";")) {
throw new IllegalStateException("Managed local data directory cannot contain ';'");
}
if (!jdbcUrl(directory).equals(source.getUrl())) {
throw new IllegalStateException("Managed local requires its private file H2 database");
}
if (environment.getProperty("spring.h2.console.enabled", Boolean.class, false)) {
throw new IllegalStateException("Managed local does not expose an H2 Web console");
}
if (server.getAddress() == null || !server.getAddress().isLoopbackAddress()
|| !BrokerSecurity.isLoopback(properties.getInternalServer().getAddress())
|| !BrokerSecurity.isLoopback(properties.getRuntimeBroker().getHost())) {
throw new IllegalStateException("Managed local listeners must use loopback addresses");
}
requireLoopbackUrl(properties.getHarness().getBaseUrl());
requireLoopbackUrl(properties.getSessionStore().getBaseUrl());
var broker = properties.getRuntimeBroker();
if (!"signed".equals(properties.getAuth().getMode())
|| !"local-process".equals(broker.getProvisioner())
|| !broker.isDurableLocalProcess() || !broker.isVerifiedWorkspaceRecoveryEnabled()
|| broker.getSsh().isEnabled() || !broker.getSsh().getNodes().isEmpty()
|| !broker.getWorkspaceMounts().isEmpty() || !broker.getResidentWorkspaces().isEmpty()
|| !directory.resolve("runtime").toString().equals(broker.getStateDirectory())
|| !directory.resolve("legacy").toString().equals(broker.getWorkspaceCwd())
|| !directory.resolve("workspaces").toString().equals(broker.getWorkspaceCreationRoot())) {
throw new IllegalStateException("Managed local requires signed authentication and private local Workspaces");
}
for (String name : new String[] {"database", "runtime", "workspaces", "legacy"}) {
Path child = directory.resolve(name);
if (!Files.exists(child, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectory(child, PosixFilePermissions.asFileAttribute(
PosixFilePermissions.fromString("rwx------")));
}
requirePrivateDirectory(child);
}
try (var files = Files.list(directory.resolve("database"))) {
if (files.anyMatch(Files::isSymbolicLink)) {
throw new IllegalStateException("Managed local database files cannot be symbolic links");
}
}
DruidDataSource dataSource = new DruidDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl(source.getUrl());
dataSource.setUsername(source.getUsername());
dataSource.setPassword(source.getPassword());
dataSource.setInitialSize(1);
dataSource.setMinIdle(1);
dataSource.setMaxActive(20);
dataSource.setMaxWait(30_000);
dataSource.setKeepAlive(true);
dataSource.setValidationQuery("SELECT 1");
return dataSource;
}
static String jdbcUrl(Path directory) {
return "jdbc:h2:file:" + directory.resolve("database/managed")
+ ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_ON_EXIT=FALSE;WRITE_DELAY=0";
}
private static void requirePrivateDirectory(Path directory) throws IOException {
if (!directory.isAbsolute() || !directory.equals(directory.toRealPath())
|| !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)
|| !Files.getOwner(directory).getName().equals(System.getProperty("user.name"))
|| !Files.getPosixFilePermissions(directory).equals(
PosixFilePermissions.fromString("rwx------"))) {
throw new IllegalStateException("Managed local data directories must be canonical owner-only directories");
}
}
private static void requireLoopbackUrl(String value) {
URI uri = URI.create(value);
if (!"http".equals(uri.getScheme())
|| uri.getHost() == null
|| !Set.of("127.0.0.1", "localhost", "[::1]").contains(uri.getHost())
|| uri.getPort() < 1 || uri.getUserInfo() != null
|| uri.getQuery() != null || uri.getFragment() != null
|| !(uri.getPath().isEmpty() || "/".equals(uri.getPath()))) {
throw new IllegalStateException("Managed local service URLs must use loopback HTTP endpoints");
}
}
}
@@ -421,7 +421,7 @@ public final class ApiModels {
String environmentId, String title,
@Size(max = 100) List<@Valid InputBlock> input,
Map<String, Object> metadata,
JsonNode workspace, String approvalMode, Boolean planMode,
JsonNode workspace, String approvalMode, Boolean planMode, String modelId,
@Size(max = 256) List<@NotNull Map<String, Object>> skills) {
}
@@ -429,7 +429,12 @@ public final class ApiModels {
}
public record WebShellSessionOptions(String approvalMode, List<String> availableApprovalModes,
boolean planModeSupported, boolean inputAttachments, boolean skillsSupported) {
boolean planModeSupported, boolean inputAttachments, boolean skillsSupported,
boolean modelSelectionSupported) {
public WebShellSessionOptions(String approvalMode, List<String> availableApprovalModes,
boolean planModeSupported, boolean inputAttachments, boolean skillsSupported) {
this(approvalMode, availableApprovalModes, planModeSupported, inputAttachments, skillsSupported, false);
}
public WebShellSessionOptions(String approvalMode, List<String> availableApprovalModes,
boolean planModeSupported, boolean inputAttachments) {
this(approvalMode, availableApprovalModes, planModeSupported, inputAttachments, false);
@@ -438,6 +438,18 @@ public class ManagedAgentStore implements AgentStateStore {
String title, List<Map<String, Object>> input, String payloadDigest, WorkspaceSelection selection,
String requestedApprovalMode, boolean approvalSelectionSupported, Boolean requestedPlanMode,
boolean planModeSupported, List<Map<String, Object>> skills, boolean publicChildrenSupported) {
return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest,
agentId, requestedRevision, title, input, payloadDigest, selection, requestedApprovalMode,
approvalSelectionSupported, requestedPlanMode, planModeSupported, skills, publicChildrenSupported, null);
}
@Override
@Transactional
public Admission insertWorkspaceSessionCommand(String tenantId, String actorId,
String idempotencyKey, String requestDigest, String agentId, String requestedRevision,
String title, List<Map<String, Object>> input, String payloadDigest, WorkspaceSelection selection,
String requestedApprovalMode, boolean approvalSelectionSupported, Boolean requestedPlanMode,
boolean planModeSupported, List<Map<String, Object>> skills, boolean publicChildrenSupported, String modelId) {
if (!input.isEmpty() && !workspaceFilesEnabled) {
throw workspaceExecutionUnavailable();
}
@@ -449,6 +461,11 @@ public class ManagedAgentStore implements AgentStateStore {
return replayWorkspaceCommand(tenantId, actorId,
requestDigest, existing.getFirst(), false);
}
if (modelId != null && (!"qwen-code".equals(agentId) || !workspaceFilesEnabled
|| modelCatalog.stream().noneMatch(option -> option.modelId().equals(modelId)))) {
throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_model",
"The model is not published for this Agent.");
}
AgentPin pin = selectApprovalMode(resolveAgentPin(tenantId, agentId,
requestedRevision, true), requestedApprovalMode, approvalSelectionSupported);
boolean planMode = Boolean.TRUE.equals(requestedPlanMode);
@@ -496,7 +513,7 @@ public class ManagedAgentStore implements AgentStateStore {
var effectiveSkills = AgentDefinitionExtensions.sessionSkills(merged);
Admission admission = insertSession(tenantId, "CREATE_SESSION", idempotencyKey,
requestDigest, agentId, title, input, payloadDigest,
workspace, actorId, pin, null, planMode);
workspace, actorId, pin, null, planMode, modelId);
if (workspaceChildAgentsEnabled && publicChildrenSupported && !resident && !sshWorkspace
&& "qwen-code".equals(agentId) && pin.digest() == null
&& WorkspaceToolProfiles.SHELL.equals(pin.toolProfile())) {
@@ -1296,6 +1313,14 @@ public class ManagedAgentStore implements AgentStateStore {
String requestDigest, String agentId, String title, List<Map<String, Object>> input,
String payloadDigest, ResolvedBinding resolved, String actorId, AgentPin pin,
WorkspaceCsiRegistration registration, boolean planMode) {
return insertSession(tenantId, operation, idempotencyKey, requestDigest, agentId, title,
input, payloadDigest, resolved, actorId, pin, registration, planMode, null);
}
private Admission insertSession(String tenantId, String operation, String idempotencyKey,
String requestDigest, String agentId, String title, List<Map<String, Object>> input,
String payloadDigest, ResolvedBinding resolved, String actorId, AgentPin pin,
WorkspaceCsiRegistration registration, boolean planMode, String modelId) {
ContextBinding workspace = resolved == null ? null
: resolved.binding();
long now = clock.millis();
@@ -1320,9 +1345,9 @@ public class ManagedAgentStore implements AgentStateStore {
+ " context_revision, workspace_config_ref,"
+ " workspace_policy_ref, tool_profile,"
+ " creator_actor_key, owner_actor_key, runtime_request_key, approval_mode,"
+ " plan_enabled, selected_approval_mode, approval_revision) VALUES"
+ " plan_enabled, selected_approval_mode, approval_revision, model_id, model_revision) VALUES"
+ " (?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?,"
+ " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId, sessionId, agentId, pin.revision(), pin.digest(),
title, now, now,
workspace == null ? null : workspace.getWorkspaceId(),
@@ -1338,7 +1363,7 @@ public class ManagedAgentStore implements AgentStateStore {
: workspace == null ? null : pin.toolProfile(),
actorKey, actorKey,
runtimeRequestKey, workspace != null || pin.digest() != null ? pin.approvalMode() : "yolo",
planMode, planMode ? pin.approvalMode() : null, planMode ? 1 : 0);
planMode, planMode ? pin.approvalMode() : null, planMode ? 1 : 0, modelId, modelId == null ? 0 : 1);
jdbc.update("INSERT INTO ma_event_stream"
+ " (tenant_id, session_id, stream_kind, stream_id,"
+ " covered_sequence, gmt_modified) VALUES"
@@ -6837,13 +6862,26 @@ public class ManagedAgentStore implements AgentStateStore {
String tenantId, String sessionId, boolean legacyMutation) {
try {
return jdbc.queryForObject(
SESSION_WITH_STREAM + " WHERE ma_session.session_kind = 'PUBLIC' AND ma_session.tenant_id ="
"SELECT " + ManagedSchemaProjection.SESSION.columns("ma_session")
+ " FROM ma_session WHERE ma_session.session_kind = 'PUBLIC' AND ma_session.tenant_id ="
+ " ? AND ma_session.session_id = ? FOR UPDATE",
(rows, rowNumber) -> {
if (legacyMutation) {
ManagedLegacySessionGuard.requireLegacyMutation(rows.getBoolean("csi_guard"));
}
return sessionMapper.mapRow(rows, rowNumber);
// H2 can retain pre-wait outer-join values after locking the Session.
// Read its stream only after acquiring that lock.
List<StreamCounters> streams = jdbc.query(
"SELECT last_sequence, replay_floor_sequence FROM ma_event_stream"
+ " WHERE tenant_id = ? AND session_id = ? AND stream_kind ="
+ " 'SESSION' AND stream_id = X'' FOR UPDATE",
(stream, index) -> new StreamCounters(stream.getLong("last_sequence"),
stream.getLong("replay_floor_sequence")),
tenantId, sessionId);
if (streams.isEmpty()) {
throw new IllegalStateException("Session stream is unavailable.");
}
return readSession(rows, streams.getFirst());
},
tenantId,
sessionId);
@@ -221,6 +221,20 @@ public interface AgentStateStore {
approvalSelectionSupported, planMode, planModeSupported, skills);
}
@org.springframework.transaction.annotation.Transactional
default Admission insertWorkspaceSessionCommand(String tenantId, String actorId,
String idempotencyKey, String requestDigest, String agentId, String requestedRevision,
String title, List<Map<String, Object>> input, String payloadDigest, WorkspaceSelection selection,
String approvalMode, boolean approvalSelectionSupported, Boolean planMode, boolean planModeSupported,
List<Map<String, Object>> skills, boolean publicChildrenSupported, String modelId) {
if (modelId != null) {
throw new UnsupportedOperationException("Initial model selection is unavailable");
}
return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest,
agentId, requestedRevision, title, input, payloadDigest, selection, approvalMode,
approvalSelectionSupported, planMode, planModeSupported, skills, publicChildrenSupported);
}
Admission replayWorkspaceSessionCommand(String tenantId, String actorId,
String idempotencyKey, String requestDigest);
@@ -255,12 +255,14 @@ public class ManagedAgentService {
var options = store.workspaceSessionOptions(tenantId, agentId);
if ("qwen-code".equals(agentId) && store.workspaceShellEnabled() && harness.supportsApprovalSelection()) {
return new WebShellSessionOptions(options.approvalMode(), List.of("default", "auto-edit", "yolo"),
harness.supportsPlanMode(), harness.supportsInputAttachments(), harness.supportsSessionSkills());
harness.supportsPlanMode(), harness.supportsInputAttachments(), harness.supportsSessionSkills(),
store.workspaceFilesEnabled() && !store.modelOptions().isEmpty() && harness.supportsModelSelection());
}
return new WebShellSessionOptions(options.approvalMode(), options.availableApprovalModes(),
harness.supportsPlanMode() && harness.supportsAgentDefinitionApprovalSelection()
&& options.planModeSupported(),
harness.supportsInputAttachments(), harness.supportsSessionSkills());
harness.supportsInputAttachments(), harness.supportsSessionSkills(),
"qwen-code".equals(agentId) && store.workspaceFilesEnabled() && !store.modelOptions().isEmpty() && harness.supportsModelSelection());
}
public WebShellSessionModels queryWebShellSessionModels(String tenantId, String actorId,
@@ -346,6 +348,15 @@ public class ManagedAgentService {
String agentRevision, String title, Map<String, Object> metadata,
List<InputBlock> blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode,
List<Map<String, Object>> skills) {
return createWorkspaceSession(tenantId, actorId, idempotencyKey, agentId, agentRevision,
title, metadata, blocks, selection, approvalMode, planMode, skills, null);
}
public CommandAdmission createWorkspaceSession(String tenantId,
String actorId, String idempotencyKey, String agentId,
String agentRevision, String title, Map<String, Object> metadata,
List<InputBlock> blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode,
List<Map<String, Object>> skills, String modelId) {
validateIdempotencyKey(idempotencyKey);
var additions = AgentDefinitionExtensions.sessionSkills(skills);
if (actorId == null || actorId.isEmpty()) {
@@ -374,6 +385,9 @@ public class ManagedAgentService {
if (!additions.isEmpty()) {
semantic.put("skills", additions);
}
if (modelId != null) {
semantic.put("modelId", modelId);
}
String requestDigest = digests.digest(semantic);
String payloadDigest = input.isEmpty() ? null
: SubmitHarnessTurn.computePayloadDigest(input);
@@ -385,6 +399,10 @@ public class ManagedAgentService {
dispatch(tenantId, recorded.get(), true);
return response(recorded.get());
}
if (modelId != null && (!"qwen-code".equals(agentId) || !harness.supportsModelSelection())) {
throw new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature",
"Initial model selection is unavailable for this Agent.");
}
if (!additions.isEmpty() && !harness.supportsSessionSkills()) {
throw new ManagedAgentException(HttpStatus.CONFLICT, "unsupported_feature",
"The Hosted Harness does not support Session Skills.");
@@ -401,7 +419,7 @@ public class ManagedAgentService {
effectiveTitle, input, payloadDigest, selection, approvalMode,
harness.supportsApprovalSelection() && ("qwen-code".equals(agentId)
|| harness.supportsAgentDefinitionApprovalSelection()),
planMode, harness.supportsPlanMode(), additions, harness.supportsPublicChildAgents());
planMode, harness.supportsPlanMode(), additions, harness.supportsPublicChildAgents(), modelId);
} catch (DuplicateKeyException error) {
admission = store.replayWorkspaceSessionCommand(tenantId,
actorId, idempotencyKey, requestDigest);
@@ -0,0 +1,42 @@
spring:
datasource:
url: 'jdbc:h2:file:${QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY}/database/managed;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_ON_EXIT=FALSE;WRITE_DELAY=0'
driver-class-name: 'org.h2.Driver'
username: 'qwen'
password: ''
h2:
console:
enabled: false
management:
endpoint:
health:
probes:
enabled: true
group:
readiness:
include: 'readinessState,db'
qwen:
managed-agent:
auth:
mode: 'signed'
harness:
enabled: true
workspace-files-enabled: true
workspace-shell-enabled: true
approval-mode: 'default'
session-store:
enabled: true
base-url: '${QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL:http://127.0.0.1:${server.port:8080}}'
workspace-id: '${QWEN_MANAGED_AGENT_WORKSPACE_ID}'
runtime-broker:
enabled: true
provisioner: 'local-process'
durable-local-process: true
verified-workspace-recovery-enabled: true
trusted-local-reboot-recovery: '${QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY:false}'
workspace-id: '${QWEN_MANAGED_AGENT_WORKSPACE_ID}'
workspace-cwd: '${QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY}/legacy'
workspace-creation-root: '${QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY}/workspaces'
state-directory: '${QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY}/runtime'
@@ -2,8 +2,8 @@
"openapi": "3.1.0",
"info": {
"title": "Qwen Managed Agent Public and WebShell API",
"version": "1.87.0",
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
"version": "1.88.0",
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
},
"servers": [
{
@@ -6821,6 +6821,11 @@
"minLength": 1,
"maxLength": 128
},
"modelId": {
"type": "string",
"minLength": 1,
"description": "Optional host-catalog model for a bound built-in qwen-code Session. Requires modelSelectionSupported. Persisted atomically before the first Turn and included in creation idempotency; omission preserves the default model and old digest."
},
"environmentId": {
"type": ["string", "null"],
"description": "Planned; phase one rejects non-blank values."
@@ -11912,6 +11917,10 @@
"type": "boolean",
"description": "Optional deployment capability; missing means false."
},
"modelSelectionSupported": {
"type": "boolean",
"description": "Optional capability for modelId on Workspace creation. Missing means false."
},
"skillsSupported": {
"type": "boolean",
"description": "Optional deployment capability; missing means false. Requires a compatible Harness and durable Session Store."
@@ -28,6 +28,7 @@ import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStoreModels.
import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStoreModels.WriterGrant;
import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry;
import com.alibaba.qwen.code.runtimebroker.JdbcRepositoryContract;
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -94,6 +95,47 @@ class ManagedAgentMySqlIT {
}
}
@ParameterizedTest
@ValueSource(strings = {"default", "auto-edit", "yolo"})
@Order(0)
void freezesInitialCatalogModelAndApprovalInOneTransaction(String mode) {
var source = dataSource();
Flyway.configure().dataSource(source).locations("classpath:db/managed-schema").load().migrate();
JdbcTemplate jdbc = new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(source);
String tenant = "initial-model-" + UUID.randomUUID();
jdbc.update("INSERT INTO ma_workspace (tenant_id,workspace_id,workspace_generation,storage_id,"
+ "display_name,config_ref,policy_ref,state) VALUES (?,'ws',1,'storage','Workspace',?,?,'ACTIVE')",
tenant, WorkspaceExecutionProfile.CONFIG_REF,
WorkspaceExecutionProfile.POLICY_REF);
jdbc.update("INSERT INTO ma_workspace_access (tenant_id,workspace_id,actor_id,role) VALUES (?,'ws',?,'OWNER')",
tenant, "actor".getBytes(StandardCharsets.UTF_8));
var properties = new ManagedAgentProperties();
properties.getHarness().setWorkspaceFilesEnabled(true);
properties.getRuntimeBroker().setWorkspaceMounts(List.of(
new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(tenant, "storage", "/tmp/initial-model")));
properties.setModelCatalog(List.of("model-a", "model-b").stream().map(id -> {
var option = new ManagedAgentProperties.ModelOption();
option.setModelId(id);
return option;
}).toList());
var store = new ManagedAgentStore(jdbc, new ObjectMapper(), Clock.systemUTC(), ignored -> {},
new ManagedWorkspaceRegistry(jdbc), properties);
var transactions = new TransactionTemplate(new DataSourceTransactionManager(source));
for (String model : List.of("model-a", "model-b")) {
var input = List.<Map<String, Object>>of(Map.of("type", "input_text", "text", "first"));
var admitted = transactions.execute(status -> store.insertWorkspaceSessionCommand(tenant, "actor", model,
"digest-" + model, "qwen-code", null, null, input,
com.alibaba.qwen.code.daemon.SubmitHarnessTurn.computePayloadDigest(input),
new com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection("ws", "."),
mode, true, null, false, List.of(), false, model));
assertThat(jdbc.queryForMap("SELECT model_id,model_revision,approval_mode,approval_revision FROM ma_turn"
+ " WHERE tenant_id=? AND session_id=?", tenant, admitted.sessionId()))
.containsEntry("model_id", model).containsEntry("model_revision", 1L)
.containsEntry("approval_mode", mode).containsEntry("approval_revision", 0L);
assertThat(store.requireSession(tenant, admitted.sessionId()).agentDefinitionDigest()).isNull();
}
}
private static final AgentDefinitionRequest DEFINITION =
new AgentDefinitionRequest(
Map.of("id", "test-model"),
@@ -4,6 +4,7 @@ import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException;
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
import com.alibaba.qwen.code.managedagent.model.ApiModels.InputBlock;
import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelChangeResult;
import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelSelection;
import com.alibaba.qwen.code.managedagent.model.RequestDigests;
@@ -75,6 +76,87 @@ class ManagedModelSelectionTest {
"qwen-code", null, null, null, List.of(), new WorkspaceSelection("ws", "."))).sessionId();
}
@ParameterizedTest
@ValueSource(strings = {"default", "auto-edit", "yolo"})
void creationFreezesPublishedModelAndApprovalBeforeFirstTurn(String mode) {
when(harness.supportsApprovalSelection()).thenReturn(true);
when(harness.isWorkspaceFilesAvailable()).thenReturn(true);
for (String model : List.of("model-a", "model-b")) {
String key = "create-" + model;
var input = List.of(new InputBlock(
"input_text", "first"));
var admitted = transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", key,
"qwen-code", null, null, null, input, new WorkspaceSelection("ws", "."), mode, null, null, model));
var created = store.requireSession(tenant, admitted.sessionId());
assertThat(created.agentId()).isEqualTo("qwen-code");
assertThat(created.agentDefinitionDigest()).isNull();
assertThat(created.approvalMode()).isEqualTo(mode);
var turn = store.findTurn(tenant, admitted.sessionId(), admitted.turnId()).orElseThrow();
assertThat(store.findTurnModelSelection(tenant, admitted.sessionId(), turn.promptId()))
.isEqualTo(new WebShellModelSelection(model, 1));
assertThat(jdbc.queryForMap("SELECT approval_mode, approval_revision FROM ma_turn"
+ " WHERE tenant_id=? AND session_id=?", tenant, admitted.sessionId()))
.containsEntry("approval_mode", mode).containsEntry("approval_revision", 0L);
when(harness.supportsModelSelection()).thenReturn(false);
assertThat(transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", key,
"qwen-code", null, null, null, input, new WorkspaceSelection("ws", "."), mode, null, null, model))
.sessionId()).isEqualTo(admitted.sessionId());
assertCode(() -> transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", key,
"qwen-code", null, null, null, input, new WorkspaceSelection("ws", "."), mode, null, null,
model.equals("model-a") ? "model-b" : "model-a")), "idempotency_conflict");
when(harness.supportsModelSelection()).thenReturn(true);
}
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void initialModelSelectionPreservesPublicChildAdmission(boolean childrenSupported) {
var properties = new ManagedAgentProperties();
properties.getHarness().setWorkspaceFilesEnabled(true);
properties.getHarness().setWorkspaceShellEnabled(true);
properties.getRuntimeBroker().setWorkspaceMounts(List.of(
new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(tenant, "storage", "/tmp/model-selection")));
properties.setModelCatalog(List.of(option("model-a")));
var enabled = new ManagedAgentStore(jdbc, json, Clock.systemUTC(), ignored -> {}, registry, properties);
when(harness.isWorkspaceFilesAvailable()).thenReturn(true);
when(harness.supportsApprovalSelection()).thenReturn(true);
when(harness.supportsPublicChildAgents()).thenReturn(childrenSupported);
var input = List.of(new InputBlock("input_text", "first"));
var admitted = transaction().execute(status -> service(enabled).createWorkspaceSession(tenant, "actor", "combined",
"qwen-code", null, null, null, input, new WorkspaceSelection("ws", "."), "auto-edit", null, null, "model-a"));
var created = enabled.requireSession(tenant, admitted.sessionId());
assertThat(created.publicChildAgentsVersion()).isEqualTo(childrenSupported ? 1 : 0);
assertThat(created.approvalMode()).isEqualTo("auto-edit");
var turn = enabled.findTurn(tenant, admitted.sessionId(), admitted.turnId()).orElseThrow();
assertThat(enabled.findTurnModelSelection(tenant, admitted.sessionId(), turn.promptId()))
.isEqualTo(new WebShellModelSelection("model-a", 1));
when(harness.supportsPublicChildAgents()).thenReturn(!childrenSupported);
assertThat(transaction().execute(status -> service(enabled).createWorkspaceSession(tenant, "actor", "combined",
"qwen-code", null, null, null, input, new WorkspaceSelection("ws", "."), "auto-edit", null, null, "model-a"))
.sessionId()).isEqualTo(admitted.sessionId());
assertThat(enabled.requireSession(tenant, admitted.sessionId()).publicChildAgentsVersion())
.isEqualTo(childrenSupported ? 1 : 0);
}
@Test
void initialModelRefusesUnsupportedCatalogCapabilityAndCustomAgentWithoutCreatingSession() {
int before = jdbc.queryForObject("SELECT COUNT(*) FROM ma_session WHERE tenant_id=?", Integer.class, tenant);
assertCode(() -> transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "unknown",
"qwen-code", null, null, null, List.of(), new WorkspaceSelection("ws", "."), null, null, null,
"unknown")), "unsupported_model");
when(harness.supportsModelSelection()).thenReturn(false);
assertCode(() -> transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "unsupported",
"qwen-code", null, null, null, List.of(), new WorkspaceSelection("ws", "."), null, null, null,
"model-a")), "unsupported_feature");
when(harness.supportsModelSelection()).thenReturn(true);
assertCode(() -> transaction().execute(status -> service.createWorkspaceSession(tenant, "actor", "custom",
"agent-custom", null, null, null, List.of(), new WorkspaceSelection("ws", "."), null, null, null,
"model-a")), "unsupported_feature");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session WHERE tenant_id=?", Integer.class, tenant))
.isEqualTo(before);
assertThat(service.queryWebShellSessionOptions(tenant, "actor", "qwen-code").modelSelectionSupported()).isTrue();
}
@Test
void publishesOnlyHostCatalogAndPersistsSelectionWithoutChangingAgentIdentity() {
var before = store.requireSession(tenant, session);
@@ -285,6 +367,8 @@ class ManagedModelSelectionTest {
private ManagedAgentStore store(boolean catalog) {
var properties = new ManagedAgentProperties();
properties.getHarness().setWorkspaceFilesEnabled(true);
properties.getRuntimeBroker().setWorkspaceMounts(List.of(
new ManagedAgentProperties.RuntimeBroker.WorkspaceMount(tenant, "storage", "/tmp/model-selection")));
if (catalog) {
properties.setModelCatalog(List.of(option("model-a"), option("model-b")));
}
@@ -103,6 +103,52 @@ class SignatureAuthFilterTest {
assertThat(stale.getContentAsString()).contains("invalid_signature");
}
@Test
void localDirectoryImportStillRequiresSignedTenantAndActor() throws Exception {
ManagedAgentProperties properties = new ManagedAgentProperties();
properties.getAuth().setMode("signed");
properties.getAuth().setSigningKey(KEY);
properties.getRuntimeBroker().setLocalDirectoryImportEnabled(true);
ServerProperties server = new ServerProperties();
server.setAddress(InetAddress.getByName("127.0.0.1"));
SignatureAuthFilter filter = filter(new BrokerSecurity(properties, server, new WebMvcProperties()));
byte[] body = "{\"localPath\":\"/projects/example\"}".getBytes(StandardCharsets.UTF_8);
for (String path : new String[] {"/api/agent/web-shell/v1/workspaces/directories/query",
"/api/agent/web-shell/v1/workspaces/create"}) {
String timestamp = now();
String signature = sign("POST", path, "tenant-a", "actor-a", timestamp, body, null);
MockHttpServletRequest valid = request("POST", path);
valid.setContent(body);
valid.addHeader(SignatureAuthFilter.ACTOR_HEADER, "actor-a");
valid.addHeader(SignatureAuthFilter.TIMESTAMP_HEADER, timestamp);
valid.addHeader(SignatureAuthFilter.SIGNATURE_HEADER, signature);
MockFilterChain accepted = new MockFilterChain();
filter.doFilter(valid, new MockHttpServletResponse(), accepted);
assertThat(((HttpServletRequest) accepted.getRequest()).getUserPrincipal())
.isInstanceOfSatisfying(AuthenticatedTenantActor.class, actor -> {
assertThat(actor.tenantId()).isEqualTo("tenant-a");
assertThat(actor.actorId()).isEqualTo("actor-a");
});
MockHttpServletRequest unsigned = request("POST", path);
unsigned.setContent(body);
unsigned.addHeader(SignatureAuthFilter.ACTOR_HEADER, "actor-a");
MockHttpServletResponse missing = new MockHttpServletResponse();
filter.doFilter(unsigned, missing, new MockFilterChain());
assertThat(missing.getStatus()).isEqualTo(401);
MockHttpServletRequest forged = request("POST", path);
forged.setContent(body);
forged.addHeader(SignatureAuthFilter.ACTOR_HEADER, "other-actor");
forged.addHeader(SignatureAuthFilter.TIMESTAMP_HEADER, timestamp);
forged.addHeader(SignatureAuthFilter.SIGNATURE_HEADER, signature);
MockHttpServletResponse denied = new MockHttpServletResponse();
filter.doFilter(forged, denied, new MockFilterChain());
assertThat(denied.getStatus()).isEqualTo(401);
assertThat(denied.getContentAsString()).contains("invalid_signature");
}
}
@Test
void doesNotCoverTheInternalSurfaceAndIgnoresAMissingTenant()
throws Exception {
@@ -24,10 +24,31 @@ class BrokerSecurityTest {
assertThatCode(() -> security(properties, "127.0.0.1")).doesNotThrowAnyException();
properties.getAuth().setAllowInsecureBind(true);
assertThatThrownBy(() -> security(properties, "0.0.0.0")).hasMessageContaining("single-user");
}
@Test
void localDirectoryImportAcceptsSignedLoopbackAuthority() throws Exception {
ManagedAgentProperties properties = new ManagedAgentProperties();
properties.getRuntimeBroker().setLocalDirectoryImportEnabled(true);
properties.getAuth().setMode("signed");
properties.getAuth().setSigningKey("test-signing-key-at-least-32-characters");
properties.setTrustedActorHeader("");
assertThatThrownBy(() -> security(properties, "127.0.0.1")).hasMessageContaining("single-user");
properties.getAuth().setSigningKey(KEY);
for (String address : new String[] {"127.0.0.1", "::1"}) {
assertThat(security(properties, address).getMode())
.as(address).isEqualTo(BrokerSecurity.Mode.SIGNED);
}
}
@Test
void localDirectoryImportRefusesSignedRemoteListenersEvenWithInsecureOverride() throws Exception {
ManagedAgentProperties properties = new ManagedAgentProperties();
properties.getRuntimeBroker().setLocalDirectoryImportEnabled(true);
properties.getAuth().setMode("signed");
properties.getAuth().setSigningKey(KEY);
properties.getAuth().setAllowInsecureBind(true);
for (String address : new String[] {"0.0.0.0", "10.0.0.8", null}) {
assertThatThrownBy(() -> security(properties, address))
.as(address).hasMessageContaining("single-user");
}
}
@Test
@@ -0,0 +1,240 @@
package com.alibaba.qwen.code.managedagent.config;
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema;
import org.apache.commons.codec.digest.DigestUtils;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.env.MockEnvironment;
import java.net.InetAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.fail;
@EnabledOnOs({OS.LINUX, OS.MAC})
class ManagedLocalConfigurationTest {
@TempDir
Path temporary;
@Test
void bindsTheLocalProfileAndMigratesItsFileDatabase() throws Exception {
Path directory = temporary.toRealPath();
String workspaceId = DigestUtils.sha256Hex(directory.resolve("legacy").toString()).substring(0, 16);
new ApplicationContextRunner()
.withInitializer(new ConfigDataApplicationContextInitializer())
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withUserConfiguration(ProfileConfiguration.class)
.withPropertyValues("spring.profiles.active=managed-local",
"QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY=" + directory,
"QWEN_MANAGED_AGENT_WORKSPACE_ID=" + workspaceId)
.run(context -> {
assertThat(context).hasNotFailed().hasSingleBean(DruidDataSource.class);
assertThat(context.getBean(DruidDataSource.class).getUrl())
.isEqualTo(ManagedLocalConfiguration.jdbcUrl(directory));
assertThat(ManagedSchema.schemaVersion(context.getBean(DruidDataSource.class))).isEqualTo(17);
var managed = context.getBean(ManagedAgentProperties.class);
assertThat(managed.getRuntimeBroker().getWorkspaceId()).isEqualTo(workspaceId);
assertThat(managed.getSessionStore().getWorkspaceId()).isEqualTo(workspaceId);
assertThat(context.getEnvironment().getProperty("spring.h2.console.enabled")).isEqualTo("false");
assertThat(context.getEnvironment().getProperty("management.endpoint.health.probes.enabled"))
.isEqualTo("true");
});
}
@Test
void preservesCommittedStateAndRollsBackAnUncommittedTransactionAcrossReopen() throws Exception {
Path directory = temporary.toRealPath();
try (var source = source(directory)) {
migrate(source);
try (Connection connection = source.getConnection()) {
insert(connection, "committed");
connection.setAutoCommit(false);
insert(connection, "uncommitted");
}
}
try (var source = source(directory)) {
migrate(source);
assertSessions(source, "committed");
ManagedSchema.validate(source);
}
for (String name : new String[] {"database", "runtime", "workspaces", "legacy"}) {
assertThat(Files.getPosixFilePermissions(directory.resolve(name)))
.isEqualTo(PosixFilePermissions.fromString("rwx------"));
}
}
@Test
@Timeout(60)
void locksOutAnotherJvmAndRecoversCommittedStateAfterTheOwnerIsKilled() throws Exception {
Path directory = temporary.toRealPath();
Path log = directory.resolve("database-process.log");
Process child = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(),
"-cp", System.getProperty("java.class.path"), DatabaseProcess.class.getName(), directory.toString())
.redirectErrorStream(true).redirectOutput(log.toFile()).start();
try {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
while (!Files.readString(log).contains("LOCAL_DATABASE_READY")) {
if (!child.isAlive() || System.nanoTime() >= deadline) {
fail("Database process did not become ready: " + Files.readString(log));
}
Thread.sleep(50);
}
assertThatThrownBy(() -> {
try (var ignored = DriverManager.getConnection(ManagedLocalConfiguration.jdbcUrl(directory), "qwen", "")) {
fail("A second process opened the private database");
}
}).hasMessageContaining("Database may be already in use");
child.destroyForcibly();
assertThat(child.waitFor(10, TimeUnit.SECONDS)).isTrue();
try (var source = source(directory)) {
migrate(source);
assertSessions(source, "committed");
ManagedSchema.validate(source);
}
} finally {
child.destroyForcibly();
child.waitFor(10, TimeUnit.SECONDS);
}
}
@Test
void refusesAForeignFileDatabaseWithoutChangingItsData() throws Exception {
Path directory = temporary.toRealPath();
try (var source = source(directory)) {
var jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE foreign_data (payload VARCHAR(32))");
jdbc.update("INSERT INTO foreign_data VALUES ('preserve-me')");
assertThatThrownBy(() -> migrate(source)).isInstanceOf(IllegalStateException.class);
assertThat(jdbc.queryForObject("SELECT payload FROM foreign_data", String.class)).isEqualTo("preserve-me");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'public'",
Integer.class)).isEqualTo(1);
}
}
@Test
void refusesAChangedDatabaseUrlAndExposedListenersBeforeDatabaseCreation() throws Exception {
Path directory = temporary.toRealPath();
var source = properties(directory);
source.setUrl(source.getUrl() + ";AUTO_SERVER=TRUE");
assertThatThrownBy(() -> configure(directory, source, server(), managed(directory)))
.hasMessageContaining("private file H2");
var server = server();
server.setAddress(InetAddress.getByName("0.0.0.0"));
assertThatThrownBy(() -> configure(directory, properties(directory), server, managed(directory)))
.hasMessageContaining("loopback");
assertThat(Files.exists(directory.resolve("database"))).isFalse();
}
@Test
void refusesPublicPermissionsAndSymlinkedPrivateState() throws Exception {
Path directory = temporary.toRealPath();
Files.setPosixFilePermissions(directory, PosixFilePermissions.fromString("rwxr-xr-x"));
assertThatThrownBy(() -> source(directory)).hasMessageContaining("owner-only");
Files.setPosixFilePermissions(directory, PosixFilePermissions.fromString("rwx------"));
Files.createSymbolicLink(directory.resolve("database"), directory.resolve("other"));
assertThatThrownBy(() -> source(directory)).isInstanceOf(Exception.class);
}
private static DruidDataSource source(Path directory) throws Exception {
return configure(directory, properties(directory), server(), managed(directory));
}
private static DruidDataSource configure(Path directory, DataSourceProperties source,
ServerProperties server, ManagedAgentProperties managed) throws Exception {
return new ManagedLocalConfiguration().managedLocalDataSource(source, server, managed,
new MockEnvironment().withProperty("QWEN_MANAGED_AGENT_LOCAL_DATA_DIRECTORY", directory.toString()));
}
private static DataSourceProperties properties(Path directory) {
var source = new DataSourceProperties();
source.setUrl(ManagedLocalConfiguration.jdbcUrl(directory));
source.setUsername("qwen");
source.setPassword("");
return source;
}
private static ServerProperties server() throws Exception {
var server = new ServerProperties();
server.setAddress(InetAddress.getByName("127.0.0.1"));
return server;
}
private static ManagedAgentProperties managed(Path directory) {
var properties = new ManagedAgentProperties();
properties.getAuth().setMode("signed");
properties.getSessionStore().setBaseUrl("http://127.0.0.1:8080");
var broker = properties.getRuntimeBroker();
broker.setVerifiedWorkspaceRecoveryEnabled(true);
broker.setStateDirectory(directory.resolve("runtime").toString());
broker.setWorkspaceCwd(directory.resolve("legacy").toString());
broker.setWorkspaceCreationRoot(directory.resolve("workspaces").toString());
return properties;
}
private static void migrate(DruidDataSource source) {
new ManagedSchemaConfiguration().freshManagedSchema(source).migrate(
Flyway.configure().dataSource(source).locations(ManagedSchema.FLYWAY_LOCATION).load());
}
private static void insert(Connection connection, String session) throws Exception {
try (var statement = connection.prepareStatement("INSERT INTO ma_session"
+ " (tenant_id, session_id, agent_id, status, gmt_create, gmt_modified)"
+ " VALUES ('local', ?, 'qwen-code', 'ACTIVE', 1, 1)")) {
statement.setString(1, session);
statement.executeUpdate();
}
}
private static void assertSessions(DruidDataSource source, String session) {
assertThat(new JdbcTemplate(source).queryForList("SELECT session_id FROM ma_session", String.class))
.containsExactly(session);
}
@TestConfiguration(proxyBeanMethods = false)
@EnableConfigurationProperties({ServerProperties.class, ManagedAgentProperties.class})
@Import({ManagedLocalConfiguration.class, ManagedSchemaConfiguration.class})
static class ProfileConfiguration {
}
public static final class DatabaseProcess {
private DatabaseProcess() {
}
public static void main(String[] args) throws Exception {
try (var source = source(Path.of(args[0]))) {
migrate(source);
try (var connection = source.getConnection()) {
insert(connection, "committed");
connection.setAutoCommit(false);
insert(connection, "uncommitted");
System.out.println("LOCAL_DATABASE_READY");
System.out.flush();
Thread.currentThread().join();
}
}
}
}
}
@@ -0,0 +1,54 @@
package com.alibaba.qwen.code.managedagent.persistence;
import org.junit.jupiter.api.AfterEach;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
class ManagedAgentEventSequenceConcurrencyMySqlIT extends ManagedAgentEventSequenceConcurrencyTest {
private IsolatedManagedSchemaMySqlTestDatabase database;
@Override
protected DataSource dataSource() {
database = new IsolatedManagedSchemaMySqlTestDatabase();
return database.source();
}
@AfterEach
void removeOwnedDatabase() {
if (database != null) {
database.close();
}
}
@Override
protected Long currentSessionId(JdbcTemplate jdbc) {
return jdbc.queryForObject("SELECT CONNECTION_ID()", Long.class);
}
@Override
protected void awaitSessionLockWait(JdbcTemplate jdbc, Long blockerId) {
String sql;
if (jdbc.queryForObject("SELECT VERSION()", String.class).contains("MariaDB")) {
sql = "SELECT COUNT(*) FROM information_schema.INNODB_LOCK_WAITS w"
+ " JOIN information_schema.INNODB_LOCKS l ON l.LOCK_ID = w.REQUESTED_LOCK_ID"
+ " JOIN information_schema.INNODB_TRX t ON t.TRX_ID = w.BLOCKING_TRX_ID"
+ " WHERE t.TRX_MYSQL_THREAD_ID = ?"
+ " AND l.LOCK_TABLE = CONCAT('`', DATABASE(), '`.`ma_session`')";
} else {
sql = "SELECT COUNT(*) FROM performance_schema.data_lock_waits w"
+ " JOIN performance_schema.threads t ON t.thread_id = w.blocking_thread_id"
+ " JOIN performance_schema.data_locks l ON l.engine_lock_id = w.requesting_engine_lock_id"
+ " WHERE t.processlist_id = ? AND l.object_schema = DATABASE()"
+ " AND l.object_name = 'ma_session'";
}
await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
assertThat(jdbc.queryForObject(sql, Long.class, blockerId))
.isEqualTo(1));
}
}
@@ -0,0 +1,158 @@
package com.alibaba.qwen.code.managedagent.persistence;
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
import com.alibaba.qwen.code.managedagent.model.StoreModels.Admission;
import com.alibaba.qwen.code.managedagent.model.StoreModels.HarnessEvent;
import com.alibaba.qwen.code.managedagent.model.StoreModels.ProjectedEvent;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
class ManagedAgentEventSequenceConcurrencyTest {
private static final String TENANT = "tenant-sequence";
private static final String OWNER = "owner-sequence";
private static final String EPOCH = "epoch-sequence";
private JdbcTemplate jdbc;
private TransactionTemplate transactions;
private ManagedAgentStore store;
protected DataSource dataSource() {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:event-sequence-" + UUID.randomUUID()
+ ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"
+ ";LOCK_TIMEOUT=10000");
return source;
}
@BeforeEach
void setUp() {
DataSource source = dataSource();
ManagedSchema.initialize(source);
jdbc = new ManagedSchemaJdbcTemplate(source);
transactions = new TransactionTemplate(
new DataSourceTransactionManager(source));
store = new ManagedAgentStore(jdbc, new ObjectMapper(),
Clock.systemUTC(), ignored -> {},
new ManagedWorkspaceRegistry(jdbc), new ManagedAgentProperties());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
@Timeout(20)
void waitingHarnessBatchKeepsCommittedPublicSequence(boolean projected)
throws Exception {
Admission turn = createTurn();
long before = store.requireSession(TENANT, turn.sessionId()).lastSequence();
try (var executor = Executors.newSingleThreadExecutor()) {
Future<?> waiting = transactions.execute(status -> {
Long blockerId = currentSessionId(jdbc);
jdbc.queryForObject("SELECT version FROM ma_session WHERE"
+ " tenant_id = ? AND session_id = ? FOR UPDATE",
Long.class, TENANT, turn.sessionId());
Future<?> pending = executor.submit(() -> transactions.executeWithoutResult(
ignored -> store.recordHarnessEvents(TENANT, turn.sessionId(),
turn.turnId(), OWNER, EPOCH,
List.of(event(2, projected)))));
awaitSessionLockWait(jdbc, blockerId);
store.recordHarnessEvents(TENANT, turn.sessionId(), turn.turnId(),
OWNER, EPOCH, List.of(event(1, true)));
return pending;
});
assertThat(waiting).isNotNull();
waiting.get(5, TimeUnit.SECONDS);
}
long expected = before + (projected ? 2 : 1);
assertThat(store.requireSession(TENANT, turn.sessionId()).lastSequence())
.isEqualTo(expected);
assertThat(store.findTurn(TENANT, turn.sessionId(), turn.turnId()))
.get().satisfies(record -> assertThat(record.harnessLastEventId()).isEqualTo(2));
transactions.executeWithoutResult(status -> store.recordHarnessEvents(TENANT,
turn.sessionId(), turn.turnId(), OWNER, EPOCH, List.of(event(3, true))));
assertThat(store.findEvents(TENANT, turn.sessionId(), before, 10))
.extracting(record -> record.sequence())
.containsExactlyElementsOf(projected
? List.of(before + 1, before + 2, before + 3)
: List.of(before + 1, before + 2));
assertThat(store.requireSession(TENANT, turn.sessionId()).lastSequence())
.isEqualTo(expected + 1);
}
@Test
void missingSessionStreamKeepsItsUnavailableError() {
Admission turn = createTurn();
jdbc.update("DELETE FROM ma_public_event WHERE tenant_id = ? AND session_id = ?",
TENANT, turn.sessionId());
jdbc.update("DELETE FROM ma_event_stream WHERE tenant_id = ? AND session_id = ?"
+ " AND stream_kind = 'SESSION' AND stream_id = X''",
TENANT, turn.sessionId());
assertThatThrownBy(() -> transactions.executeWithoutResult(status ->
store.recordHarnessEvents(TENANT, turn.sessionId(), turn.turnId(),
OWNER, EPOCH, List.of(event(1, true)))))
.isExactlyInstanceOf(IllegalStateException.class)
.hasMessage("Session stream is unavailable.");
}
protected Long currentSessionId(JdbcTemplate template) {
return template.queryForObject("SELECT SESSION_ID()", Long.class);
}
protected void awaitSessionLockWait(JdbcTemplate template, Long blockerId) {
await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
assertThat(template.queryForList("SELECT executing_statement FROM"
+ " information_schema.sessions WHERE blocker_id = ?"
+ " AND session_state = 'BLOCKED'",
String.class, blockerId))
.singleElement().satisfies(statement ->
assertThat(statement).contains("ma_session", "FOR UPDATE")));
}
private Admission createTurn() {
Admission turn = transactions.execute(status -> {
Admission session = store.insertSessionCommand(TENANT,
"CREATE_SESSION", "create", "create-digest", "qwen-code",
"1", null, List.of(), null);
Admission admitted = store.insertTurnCommand(TENANT,
"SUBMIT_TURN", "submit", "submit-digest", session.sessionId(),
List.of(Map.of("type", "text", "text", "hello")), "payload");
assertThat(store.claimTurn(TENANT, session.sessionId(),
admitted.turnId(), OWNER, Duration.ofMinutes(1))).isPresent();
store.recordAdmission(TENANT, session.sessionId(), admitted.turnId(),
OWNER, EPOCH, 0);
return admitted;
});
assertThat(turn).isNotNull();
return turn;
}
private static HarnessEvent event(long sourceId, boolean projected) {
return new HarnessEvent(sourceId, "boot:" + EPOCH + ":" + sourceId,
projected ? new ProjectedEvent("item.output_text.delta",
Map.of("text", "event-" + sourceId), false, null, null, null) : null);
}
}
@@ -161,9 +161,9 @@ class Issue13181QueryBudgetTest {
.containsExactly(0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 1L);
// Hot path 1 is bounded the same way hot path 3 is: the whole
// batch's statement count is pinned, so any added per-row read
// shows here.
// shows here. Session and stream counters lock separately once per batch.
assertThat(totals)
.containsExactly(60L, 35L, 35L, 35L, 35L, 35L, 35L, 35L, 43L);
.containsExactly(61L, 36L, 36L, 36L, 36L, 36L, 36L, 36L, 44L);
// Batch 1 covers session.created plus nine tool events; the last
// batch sees all 81 items (80 tool plus the text delta's assistant).
assertThat(itemRowsRead)
@@ -343,9 +343,9 @@ class Issue13181QueryBudgetTest {
assertThat(fixture.ledger.count("insert into ma_snapshot"))
.isZero();
// The empty tick — the scheduler's hottest path — is pinned whole:
// the event read, the session lock, the progress lock, and the
// snapshot read.
assertThat(fixture.ledger.total()).isEqualTo(4);
// the event read, the session lock, the stream counter lock, the
// progress lock, and the snapshot read.
assertThat(fixture.ledger.total()).isEqualTo(5);
// The not-aged exit must leave the deferral marker untouched —
// clearing or re-stamping it would stop the aged reselection from
// ever converging the snapshot.
@@ -0,0 +1,235 @@
// @vitest-environment jsdom
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 {
hasManagedAppMarker,
ManagedStandaloneApp,
} from './ManagedStandaloneApp';
const BOOT = {
mode: 'managed',
productScope: 'local:installation-1',
agentId: 'qwen-code',
enableWorkspaceBinding: true,
};
describe('Managed standalone application', () => {
let container: HTMLDivElement;
let root: Root;
const fetch = vi.fn();
const renderShell = vi.fn((provider: ManagedAgentProvider) => (
<div>Local and Managed: {provider.kind}</div>
));
beforeEach(() => {
renderShell.mockClear();
fetch.mockReset();
vi.stubGlobal('fetch', fetch);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.querySelector('meta[name="qwen-managed-app"]')?.remove();
window.history.replaceState(null, '', '/');
vi.unstubAllGlobals();
});
it('selects managed mode only from the server HTML marker', () => {
window.history.replaceState(
null,
'',
'/?managedProvider=java&managed=true',
);
expect(hasManagedAppMarker()).toBe(false);
const marker = document.createElement('meta');
marker.name = 'qwen-managed-app';
marker.content = 'true';
document.head.appendChild(marker);
expect(hasManagedAppMarker()).toBe(true);
});
it('uses authenticated same-origin boot and Java provider without accepting browser identity', async () => {
window.history.replaceState(
null,
'',
'/?daemon=https://attacker.example&tenant=other&actor=other',
);
fetch.mockResolvedValue(
new Response(
JSON.stringify({
...BOOT,
baseUrl: 'https://attacker.example',
tenantId: 'attacker',
signingKey: 'untrusted',
}),
),
);
await act(async () => {
root.render(
<ManagedStandaloneApp daemonToken="browser-token" language="zh-CN">
{renderShell}
</ManagedStandaloneApp>,
);
});
expect(fetch).toHaveBeenCalledWith(
`${window.location.origin}/managed/boot`,
expect.objectContaining({
headers: { Authorization: 'Bearer browser-token' },
cache: 'no-store',
redirect: 'error',
}),
);
expect(container.textContent).toBe('Local and Managed: java');
const provider = renderShell.mock.calls.at(-1)?.[0];
expect(provider?.workspaceBinding?.agentId).toBe('qwen-code');
expect(provider?.storageKey).toBe(
JSON.stringify([
window.location.origin,
BOOT.productScope,
BOOT.agentId,
'workspace-binding-v1',
]),
);
fetch.mockResolvedValueOnce(Response.json({ data: [], hasMore: false }));
await provider?.listSessions({ clientId: 'browser-client' });
expect(fetch.mock.calls.at(-1)?.[0]).toBe(
`${window.location.origin}/api/agent/web-shell/v1/sessions/query`,
);
const request = fetch.mock.calls.at(-1)?.[1] as RequestInit;
const headers = new Headers(request.headers);
expect(headers.get('authorization')).toBe('Bearer browser-token');
expect(headers.get('x-qwen-tenant-id')).toBeNull();
expect(headers.get('x-qwen-actor-id')).toBeNull();
expect(headers.get('x-qwen-signature')).toBeNull();
});
it('keeps the composed shell and provider stable across parent renders', async () => {
fetch.mockResolvedValue(Response.json(BOOT));
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token">
{renderShell}
</ManagedStandaloneApp>,
),
);
const provider = renderShell.mock.calls.at(-1)?.[0];
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token" language="zh-CN">
{renderShell}
</ManagedStandaloneApp>,
),
);
expect(renderShell.mock.calls.at(-1)?.[0]).toBe(provider);
expect(fetch).toHaveBeenCalledOnce();
});
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 }));
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token">
{renderShell}
</ManagedStandaloneApp>,
),
);
expect(
renderShell.mock.calls.at(-1)?.[0]?.workspaceBinding?.modelPresets,
).toEqual(modelPresets);
});
it('keeps multiple local model choices on the built-in Agent', async () => {
const modelPresets = [
{ agentId: 'qwen-code', modelId: 'model-a', label: 'A' },
{ agentId: 'qwen-code', modelId: 'model-b', label: 'B' },
];
fetch.mockResolvedValue(Response.json({ ...BOOT, modelPresets }));
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token">
{renderShell}
</ManagedStandaloneApp>,
),
);
expect(
renderShell.mock.calls.at(-1)?.[0]?.workspaceBinding?.modelPresets,
).toEqual(modelPresets);
});
it('waits for authenticated boot before composing the shell', async () => {
fetch.mockReturnValue(new Promise(() => {}));
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token" language="zh-CN">
{renderShell}
</ManagedStandaloneApp>,
),
);
expect(container.querySelector('[role="status"]')?.textContent).toBe(
'正在连接…',
);
expect(renderShell).not.toHaveBeenCalled();
});
it.each([
new Response('unavailable', { status: 503 }),
new Response(JSON.stringify({ ...BOOT, mode: 'daemon' })),
new Response(JSON.stringify({ ...BOOT, productScope: '' })),
new Response(JSON.stringify({ ...BOOT, agentId: '' })),
new Response(JSON.stringify({ ...BOOT, enableWorkspaceBinding: false })),
Response.json({ ...BOOT, modelPresets: null }),
Response.json({
...BOOT,
modelPresets: [{ modelId: 'model-a', label: 'A' }],
}),
Response.json({ ...BOOT, modelPresets: [{ agentId: ' ', label: 'A' }] }),
Response.json({ ...BOOT, modelPresets: [{ agentId: 'a', label: ' ' }] }),
Response.json({
...BOOT,
modelPresets: [
{ agentId: 'a', label: 'A' },
{ agentId: 'a', label: 'B' },
],
}),
])(
'fails closed on unavailable or invalid boot options',
async (response) => {
fetch.mockResolvedValue(response);
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token">
{renderShell}
</ManagedStandaloneApp>,
),
);
expect(container.querySelector('[role="alert"]')).not.toBeNull();
expect(renderShell).not.toHaveBeenCalled();
},
);
it('retries failed boot before composing the unified shell', async () => {
fetch
.mockResolvedValueOnce(new Response('', { status: 503 }))
.mockResolvedValueOnce(new Response(JSON.stringify(BOOT)));
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token">
{renderShell}
</ManagedStandaloneApp>,
),
);
expect(renderShell).not.toHaveBeenCalled();
await act(async () => container.querySelector('button')?.click());
expect(container.textContent).toBe('Local and Managed: java');
expect(fetch).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,139 @@
import { useEffect, useState, type ReactNode } from 'react';
import type {
ManagedAgentProvider,
ManagedModelPreset,
} from './components/managed/managed-agent-provider';
import { createJavaManagedAgentProvider } from './components/managed/java-managed-agent-provider';
import { RootErrorFallback } from './components/RootErrorFallback';
import type { WebShellLanguage } from './i18n';
import { buildSessionPathname, parseSessionId } from './utils/sessionPath';
export function hasManagedAppMarker(): boolean {
return (
document
.querySelector('meta[name="qwen-managed-app"]')
?.getAttribute('content') === 'true'
);
}
export function ManagedStandaloneApp({
daemonToken,
language = 'en',
children,
}: {
daemonToken?: string;
language?: WebShellLanguage;
children: (provider: ManagedAgentProvider) => ReactNode;
}) {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<{
provider?: ManagedAgentProvider;
error?: Error;
}>({});
useEffect(() => {
const controller = new AbortController();
setState({});
void fetch(`${window.location.origin}/managed/boot`, {
headers: daemonToken ? { Authorization: `Bearer ${daemonToken}` } : {},
cache: 'no-store',
redirect: 'error',
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok)
throw new Error('Managed application is unavailable.');
const boot: unknown = await response.json();
if (
typeof boot !== 'object' ||
boot === null ||
!('mode' in boot) ||
boot.mode !== 'managed' ||
!('productScope' in boot) ||
typeof boot.productScope !== 'string' ||
!boot.productScope.trim() ||
!('agentId' in boot) ||
typeof boot.agentId !== 'string' ||
!boot.agentId.trim() ||
!('enableWorkspaceBinding' in boot) ||
boot.enableWorkspaceBinding !== true
) {
throw new Error('Managed application returned invalid boot options.');
}
const rawPresets = 'modelPresets' in boot ? boot.modelPresets : [];
if (
!Array.isArray(rawPresets) ||
rawPresets.some(
(item: unknown) =>
typeof item !== 'object' ||
item === null ||
!('agentId' in item) ||
typeof item.agentId !== 'string' ||
!item.agentId.trim() ||
('modelId' in item &&
(typeof item.modelId !== 'string' || !item.modelId.trim())) ||
!('label' in item) ||
typeof item.label !== 'string' ||
!item.label.trim(),
) ||
new Set(
(rawPresets as ManagedModelPreset[]).map(
(item) => item.modelId ?? item.agentId,
),
).size !== rawPresets.length
) {
throw new Error(
'Managed application returned invalid model presets.',
);
}
const modelPresets = rawPresets as ManagedModelPreset[];
if (!controller.signal.aborted) {
const provider = createJavaManagedAgentProvider({
baseUrl: window.location.origin,
productScope: boot.productScope,
agentId: boot.agentId,
enableWorkspaceBinding: true,
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.
if (parseSessionId(url.pathname) !== undefined) {
url.pathname = buildSessionPathname(url.pathname, undefined);
}
url.searchParams.delete('workspace');
url.searchParams.delete('context');
window.history.replaceState(null, '', url);
}
setState({ provider });
}
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setState({
error: error instanceof Error ? error : new Error(String(error)),
});
}
});
return () => controller.abort();
}, [daemonToken, attempt]);
if (state.error) {
return (
<RootErrorFallback
error={state.error}
language={language}
onRetry={() => setAttempt((value) => value + 1)}
/>
);
}
if (!state.provider) {
return (
<div role="status" style={{ padding: '2rem' }}>
{language === 'zh-CN' ? '正在连接…' : 'Connecting…'}
</div>
);
}
return children(state.provider);
}
@@ -5,6 +5,8 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DaemonHttpError } from '@qwen-code/sdk/daemon';
import type { WebShellProps } from '../App';
import { ManagedStandaloneApp } from '../ManagedStandaloneApp';
import { inferStandaloneBasePath } from '../utils/sessionPath';
const mocks = vi.hoisted(() => ({
connection: {
@@ -68,6 +70,7 @@ describe('WorkspaceSessionProvider targets', () => {
let root: Root;
beforeEach(() => {
window.history.replaceState(null, '', '/');
mocks.connection = {
status: 'connected',
sessionId: 'session-a',
@@ -104,6 +107,8 @@ describe('WorkspaceSessionProvider targets', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
window.history.replaceState(null, '', '/');
vi.unstubAllGlobals();
});
async function renderTarget(
@@ -455,6 +460,128 @@ describe('WorkspaceSessionProvider targets', () => {
expect(mocks.providerProps.at(-1)).toMatchObject({ sessionId: undefined });
});
async function renderManagedShell() {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({
mode: 'managed',
productScope: 'local:installation-1',
agentId: 'qwen-code',
enableWorkspaceBinding: true,
}),
),
);
await act(async () =>
root.render(
<ManagedStandaloneApp daemonToken="browser-token">
{(managedAgentProvider) => (
<WorkspaceSessionProvider
urlNavigation={{
basePath: inferStandaloneBasePath(window.location.pathname),
}}
webShellProps={{ managedAgentProvider }}
/>
)}
</ManagedStandaloneApp>,
),
);
}
it.each([
'/session/old-runtime?workspace=missing',
'/app/session/old-runtime?workspace=missing',
'/session/old-runtime?context=standalone',
'/session/old-runtime?context=live',
])(
'restores Managed history without a stale Local target from %s',
async (path) => {
window.history.replaceState(
null,
'',
`${path}&managed=1&managedSession=gateway-session`,
);
await renderManagedShell();
expect(mocks.providerMounts).toBe(1);
expect(mocks.providerProps.at(-1)).toMatchObject({
sessionId: undefined,
sessionContext: undefined,
workspaceCwd: undefined,
});
expect(mocks.appProps.at(-1)?.['managedAgentProvider']).toMatchObject({
kind: 'java',
});
expect(mocks.getStandaloneSession).not.toHaveBeenCalled();
expect(window.location.pathname).toBe(
path.startsWith('/app/') ? '/app' : '/',
);
const params = new URLSearchParams(window.location.search);
expect(params.get('managed')).toBe('1');
expect(params.get('managedSession')).toBe('gateway-session');
expect(params.has('workspace')).toBe(false);
expect(params.has('context')).toBe(false);
},
);
it('restores a Managed settings page without a stale Local history target', async () => {
window.history.replaceState(
{
__qwenWebShellNavigation: {
basePath: '',
source: `${window.location.origin}/session/old-runtime?workspace=missing`,
},
},
'',
'/settings?managed=1&managedSession=gateway-session',
);
await renderManagedShell();
expect(mocks.providerMounts).toBe(1);
expect(mocks.providerProps.at(-1)?.['sessionId']).toBeUndefined();
expect(window.location.pathname).toBe('/settings');
expect(
new URLSearchParams(window.location.search).get('managedSession'),
).toBe('gateway-session');
});
it('keeps an explicit Local session target when Managed is also available', async () => {
window.history.replaceState(
null,
'',
'/session/session-b?workspace=b&managed=0&managedSession=gateway-session',
);
await renderManagedShell();
expect(mocks.providerProps.at(-1)).toMatchObject({
sessionId: 'session-b',
workspaceCwd: '/work/b',
});
expect(mocks.appProps.at(-1)).toMatchObject({
initialSelectedWorkspaceCwd: '/work/b',
managedAgentProvider: expect.objectContaining({ kind: 'java' }),
});
expect(window.location.pathname).toBe('/session/session-b');
expect(new URLSearchParams(window.location.search).get('workspace')).toBe(
'b',
);
expect(new URLSearchParams(window.location.search).get('managed')).toBe(
'0',
);
});
it('keeps the unavailable workspace refusal for an explicit Local session', async () => {
window.history.replaceState(
null,
'',
'/session/session-missing?workspace=missing&managed=0',
);
await renderManagedShell();
expect(mocks.providerMounts).toBe(0);
expect(container.textContent).toContain('Workspace not found');
expect(window.location.pathname).toBe('/session/session-missing');
expect(new URLSearchParams(window.location.search).get('managed')).toBe(
'0',
);
});
it('drops an unavailable explicit context for a primary new session', async () => {
await act(async () => {
root.render(
@@ -223,38 +223,45 @@ describe('WorkspaceBindingCreator', () => {
displayName: 'Remote project',
};
it('prepares an editable handoff with the permitted Workspace, cwd and Agent without creating or sending', async () => {
const createSession = directCreation();
provider.workspaceBinding!.modelPresets = [
{ agentId: 'agent-a', label: 'A' },
{ agentId: 'agent-b', label: 'B' },
];
await render(true, true, undefined, {
handoff: {
sourceSessionId: 'original',
workspaceId: 'ws-a',
cwdRelative: 'project',
agentId: 'agent-b',
text: 'Historical reference',
truncated: true,
},
});
expect(editorMocks.setText).toHaveBeenCalledTimes(1);
expect(editorMocks.setText).toHaveBeenCalledWith('Historical reference');
expect(editorMocks.props?.composerScopeKey).toContain(
':new:handoff:original',
);
expect(provider.workspaceBinding!.get).toHaveBeenCalledWith(
'ws-a',
expect.anything(),
);
expect(container.textContent).toContain('Review and edit this handoff');
expect(container.textContent).toContain('32 KiB');
await expectWorkingDirectory('project');
expect(createSession).not.toHaveBeenCalled();
expect(createEmpty).not.toHaveBeenCalled();
expect(onCreated).not.toHaveBeenCalled();
});
it.each([
{ handoffAgent: 'agent-b', presetAgents: ['agent-a', 'agent-b'] },
{ handoffAgent: 'agent-a', presetAgents: ['agent-b'] },
])(
'prepares an editable handoff with the permitted Workspace, cwd and Agent $handoffAgent without creating or sending',
async ({ handoffAgent, presetAgents }) => {
const createSession = directCreation();
provider.workspaceBinding!.modelPresets = presetAgents.map((agentId) => ({
agentId,
label: agentId,
}));
await render(true, true, undefined, {
handoff: {
sourceSessionId: 'original',
workspaceId: 'ws-a',
cwdRelative: 'project',
agentId: handoffAgent,
text: 'Historical reference',
truncated: true,
},
});
expect(editorMocks.setText).toHaveBeenCalledTimes(1);
expect(editorMocks.setText).toHaveBeenCalledWith('Historical reference');
expect(editorMocks.props?.composerScopeKey).toContain(
':new:handoff:original',
);
expect(editorMocks.props?.currentModel).toBe(handoffAgent);
expect(provider.workspaceBinding!.get).toHaveBeenCalledWith(
'ws-a',
expect.anything(),
);
expect(container.textContent).toContain('Review and edit this handoff');
expect(container.textContent).toContain('32 KiB');
await expectWorkingDirectory('project');
expect(createSession).not.toHaveBeenCalled();
expect(createEmpty).not.toHaveBeenCalled();
expect(onCreated).not.toHaveBeenCalled();
},
);
it('keeps a lost Workspace grant behind the existing admission controls for handoff drafts', async () => {
const createSession = directCreation();
@@ -875,6 +882,105 @@ describe('WorkspaceBindingCreator', () => {
return { createSession, getSessionOptions };
}
it.each(['default', 'auto-edit', 'yolo'] as const)(
'selects a catalog model independently of the %s approval policy',
async (approvalMode) => {
const { createSession, getSessionOptions } = approvalSelection();
provider.workspaceBinding = {
...provider.workspaceBinding!,
agentId: 'qwen-code',
modelPresets: [
{ agentId: 'qwen-code', modelId: 'model-a', label: 'A' },
{ agentId: 'qwen-code', modelId: 'model-b', label: 'B' },
],
};
getSessionOptions.mockResolvedValue({
approvalMode: 'default',
availableApprovalModes: ['default', 'auto-edit', 'yolo'],
modelSelectionSupported: true,
});
getSession.mockResolvedValue({
sessionId: 'session-a',
agentId: 'qwen-code',
approvalMode,
modelSelection: { modelId: 'model-b', revision: 1 },
workspace: { workspaceId: 'ws-a', cwdRelative: '.' },
});
await render();
expect(editorMocks.props?.availableModes).toEqual([
'default',
'auto-edit',
'yolo',
]);
await act(async () => editorMocks.props!.onSelectMode!(approvalMode));
await act(async () => editorMocks.props!.onSelectModel!('model-b'));
expect(editorMocks.props?.currentMode).toBe(approvalMode);
expect(editorMocks.props?.currentModel).toBe('model-b');
const commit = await sendInitial('Use chosen model and policy');
expect(createSession).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'qwen-code',
modelId: 'model-b',
approvalMode,
}),
expect.any(Object),
);
expect(commit).toHaveBeenCalledOnce();
expect(onCreated).toHaveBeenCalledOnce();
},
);
it('freezes the model and permission after an unknown creation across remount', async () => {
const { createSession, getSessionOptions } = approvalSelection();
provider.workspaceBinding = {
...provider.workspaceBinding!,
agentId: 'qwen-code',
modelPresets: [{ agentId: 'qwen-code', modelId: 'model-a', label: 'A' }],
};
getSessionOptions.mockResolvedValue({
approvalMode: 'default',
availableApprovalModes: ['default', 'yolo'],
modelSelectionSupported: true,
});
createSession.mockRejectedValueOnce(new Error('Unknown delivery'));
await render();
await act(async () => editorMocks.props!.onSelectMode!('yolo'));
await sendInitial('Frozen first prompt');
const [request, command] = createSession.mock.calls[0];
await act(async () => root.unmount());
root = createRoot(container);
getSession.mockResolvedValue({
sessionId: 'session-a',
agentId: 'qwen-code',
approvalMode: 'yolo',
modelSelection: { modelId: 'model-a', revision: 1 },
workspace: { workspaceId: 'ws-a', cwdRelative: '.' },
});
await render();
await sendInitial('Changed draft');
expect(createSession).toHaveBeenLastCalledWith(
request,
expect.objectContaining({ idempotencyKey: command.idempotencyKey }),
);
expect(request).toMatchObject({
modelId: 'model-a',
approvalMode: 'yolo',
text: 'Frozen first prompt',
});
expect(onCreated).toHaveBeenCalledWith('session-a', 'ws-a');
});
it('refuses catalog creation when the host lacks the initial-model capability', async () => {
const { createSession } = approvalSelection();
provider.workspaceBinding = {
...provider.workspaceBinding!,
modelPresets: [{ agentId: 'agent-a', modelId: 'model-a', label: 'A' }],
};
await render();
await sendInitial('Do not silently use the default model');
expect(createSession).not.toHaveBeenCalled();
});
it('loads actual policy options and selects a mode without changing model, Workspace or draft', async () => {
const { createSession, getSessionOptions } = approvalSelection();
await render();
@@ -53,6 +53,7 @@ import type {
ManagedAgentWorkspace,
ManagedApprovalMode,
ManagedSessionOptions,
ManagedModelPreset,
ManagedInputBlock,
ManagedSessionSkill,
} from './managed-agent-provider';
@@ -73,6 +74,7 @@ import { RemoteWorkspaceRegistration } from './RemoteWorkspaceRegistration';
interface PendingCreation {
agentId: string;
modelId?: string;
workspaceId: string;
cwdRelative: string;
input: ManagedInputBlock[];
@@ -147,6 +149,8 @@ function readPending(
const input = item['input'];
if (
typeof item['agentId'] !== 'string' ||
(item['modelId'] !== undefined &&
(typeof item['modelId'] !== 'string' || !item['modelId'].trim())) ||
typeof item['workspaceId'] !== 'string' ||
typeof item['cwdRelative'] !== 'string' ||
typeof item['clientId'] !== 'string' ||
@@ -281,17 +285,26 @@ export function WorkspaceBindingCreator({
const [cwdDraft, setCwdDraft] = useState<string>();
const cwdEdited = useRef(Boolean(handoff?.cwdRelative));
const modelPresets = binding.modelPresets ?? [];
const [agentId, setAgentId] = useState(
modelPresets.find((item) => item.agentId === handoff?.agentId)?.agentId ??
(handoff?.agentId === binding.agentId ? binding.agentId : undefined) ??
modelPresets.find((item) => item.agentId === binding.agentId)?.agentId ??
modelPresets[0]?.agentId ??
binding.agentId,
);
const availableModels =
const availableModels: readonly ManagedModelPreset[] =
modelPresets.length > 0
? modelPresets
: [{ agentId: binding.agentId, label: t('managed.modelDefault') }];
const [selectedModel, setSelectedModel] = useState(() => {
const preferred: ManagedModelPreset =
availableModels.find((item) => item.agentId === handoff?.agentId) ??
(handoff?.agentId === binding.agentId
? { agentId: binding.agentId, label: t('managed.modelDefault') }
: undefined) ??
availableModels.find((item) => item.agentId === binding.agentId) ??
availableModels[0]!;
return preferred.modelId ?? preferred.agentId;
});
const selectedPreset = availableModels.find(
(item) => (item.modelId ?? item.agentId) === selectedModel,
);
const agentId = selectedPreset?.agentId ?? binding.agentId;
const modelId = selectedPreset?.modelId;
const [nextCursor, setNextCursor] = useState<string>();
const [supported, setSupported] = useState(false);
const [loading, setLoading] = useState(true);
@@ -552,8 +565,9 @@ export function WorkspaceBindingCreator({
: undefined,
);
const approvalReady =
!supportsApprovalModeSelection ||
(!optionsLoading && Boolean(currentOptions));
(!supportsApprovalModeSelection ||
(!optionsLoading && Boolean(currentOptions))) &&
(modelId === undefined || currentOptions?.modelSelectionSupported === true);
const displayedApprovalMode = pending
? pending.approvalMode
: supportsApprovalModeSelection
@@ -614,6 +628,12 @@ export function WorkspaceBindingCreator({
!supportsApprovalModeSelection
)
return;
if (
attempt?.modelId !== undefined &&
!attempt.sessionId &&
currentOptions?.modelSelectionSupported !== true
)
return;
const firstSubmission = !attempt;
if (!attempt) {
if (
@@ -634,7 +654,7 @@ export function WorkspaceBindingCreator({
if (!approvalReady) return;
if (
!supported ||
!availableModels.some((item) => item.agentId === agentId) ||
!selectedPreset ||
!items.find(
(item) => item.workspaceId === selected && item.canCreateSession,
)
@@ -682,6 +702,7 @@ export function WorkspaceBindingCreator({
}
attempt = {
agentId,
...(modelId === undefined ? {} : { modelId }),
workspaceId: selected,
cwdRelative,
input,
@@ -716,6 +737,9 @@ export function WorkspaceBindingCreator({
if (!attempt.sessionId) {
const request = {
agentId: attempt.agentId,
...(attempt.modelId === undefined
? {}
: { modelId: attempt.modelId }),
workspaceId: attempt.workspaceId,
cwdRelative: attempt.cwdRelative,
...(attempt.skills?.length ? { skills: attempt.skills } : {}),
@@ -766,6 +790,8 @@ export function WorkspaceBindingCreator({
if (abort.signal.aborted) return;
if (
session.sessionId !== createdSessionId ||
(attempt.modelId !== undefined &&
session.modelSelection?.modelId !== attempt.modelId) ||
(attempt.approvalMode !== undefined &&
(session.initialApprovalMode ?? session.approvalMode) !==
attempt.approvalMode) ||
@@ -1307,8 +1333,8 @@ export function WorkspaceBindingCreator({
{t('managed.modelPreset')}
</label>
<Select
value={agentId}
onValueChange={setAgentId}
value={selectedModel}
onValueChange={setSelectedModel}
disabled={busy}
>
<SelectTrigger id="managed-model-select" className="w-full">
@@ -1316,7 +1342,10 @@ export function WorkspaceBindingCreator({
</SelectTrigger>
<SelectContent>
{availableModels.map((item) => (
<SelectItem key={item.agentId} value={item.agentId}>
<SelectItem
key={item.modelId ?? item.agentId}
value={item.modelId ?? item.agentId}
>
{item.label}
</SelectItem>
))}
@@ -1427,7 +1456,7 @@ export function WorkspaceBindingCreator({
Boolean(switchTo) ||
!approvalReady ||
busy ||
!availableModels.some((item) => item.agentId === agentId) ||
!selectedPreset ||
!items.some(
(item) =>
item.workspaceId === selected && item.canCreateSession,
@@ -1500,7 +1529,9 @@ export function WorkspaceBindingCreator({
}
showChatWidthToggle={false}
contextChipPlacement="below"
currentModel={pending?.agentId ?? agentId}
currentModel={
pending?.modelId ?? pending?.agentId ?? selectedModel
}
modelControlsDisabled={
busy || workspaceCreating || Boolean(pending)
}
@@ -1554,12 +1585,12 @@ export function WorkspaceBindingCreator({
}
}}
availableModels={availableModels.map((item) => ({
id: item.agentId,
id: item.modelId ?? item.agentId,
label: item.label,
}))}
onSelectModel={(id) => {
if (!busy && !encoding.current && !pendingRef.current)
setAgentId(id);
setSelectedModel(id);
}}
workspaces={items.map((item) => ({
id: item.workspaceId,
@@ -942,6 +942,8 @@ export interface components {
requestId?: string | null;
idempotencyKey: string;
agentId: string;
/** @description Optional host-catalog model for a bound built-in qwen-code Session. Requires modelSelectionSupported. Persisted atomically before the first Turn and included in creation idempotency; omission preserves the default model and old digest. */
modelId?: string;
/** @description Planned; phase one rejects non-blank values. */
environmentId?: string | null;
/** @description The server enforces the bound in UTF-16 code units (Java String.length()), so an astral character counts as two: a title this schema's code-point maxLength accepts can still exceed the server's 256-unit bound and answer 400 invalid_title. */
@@ -1703,6 +1705,8 @@ export interface components {
planModeSupported?: boolean;
/** @description Optional deployment capability; missing means false. */
inputAttachments?: boolean;
/** @description Optional capability for modelId on Workspace creation. Missing means false. */
modelSelectionSupported?: boolean;
/** @description Optional deployment capability; missing means false. Requires a compatible Harness and durable Session Store. */
skillsSupported?: boolean;
};
@@ -1091,6 +1091,43 @@ describe('createJavaManagedAgentProvider', () => {
).toThrow('productScope');
});
it('sends a catalog model with the built-in Agent on both creation paths', async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockImplementation(async () =>
jsonResponse({ sessionId: 's', turnId: 't' }),
);
const provider = createJavaManagedAgentProvider({
baseUrl: 'https://product.example',
productScope: 'tenant:actor',
enableWorkspaceBinding: true,
fetch: fetchImpl,
});
const request = {
agentId: 'qwen-code',
modelId: 'model-b',
workspaceId: 'ws',
cwdRelative: '.',
approvalMode: 'yolo' as const,
};
const command = { clientId: 'client', idempotencyKey: 'frozen-key' };
await provider.workspaceBinding!.createEmpty(request, command);
await provider.workspaceBinding!.createSession!(
{ ...request, text: 'First' },
command,
);
for (const [, init] of fetchImpl.mock.calls) {
expect(JSON.parse(String(init?.body))).toEqual(
expect.objectContaining({
agentId: 'qwen-code',
modelId: 'model-b',
approvalMode: 'yolo',
idempotencyKey: 'frozen-key',
}),
);
}
});
it('discovers policy selection, reads options and sends explicit policies for both bound creation paths', async () => {
const options = {
approvalMode: 'default',
@@ -27,6 +27,7 @@ import type {
ManagedApprovalMode,
ManagedModelContext,
ManagedModelSelection,
ManagedModelPreset,
} from './managed-agent-provider';
export interface JavaManagedAgentProviderOptions
@@ -36,7 +37,7 @@ export interface JavaManagedAgentProviderOptions
/** Include tenant and actor identity; replace this scope/provider when either changes. Token refresh alone can use getHeaders. */
productScope?: string;
enableWorkspaceBinding?: boolean;
modelPresets?: readonly { agentId: string; label: string }[];
modelPresets?: readonly ManagedModelPreset[];
/** Acquire a streaming save target during the user gesture, then call openStream. */
saveArtifact?: ManagedArtifactSave;
}
@@ -105,6 +106,7 @@ export function createJavaManagedAgentProvider(
async function createWorkspaceSession(
request: {
agentId: string;
modelId?: string;
workspaceId: string;
cwdRelative: string;
text?: string;
@@ -120,6 +122,7 @@ export function createJavaManagedAgentProvider(
requestId: managedRequestId(),
idempotencyKey: command.idempotencyKey,
agentId: request.agentId,
...(request.modelId === undefined ? {} : { modelId: request.modelId }),
...(request.skills?.length ? { skills: request.skills } : {}),
// Without a title the Session list and header fall back to a UUID
// prefix, unlike the standard shell's prompt-derived title.
@@ -706,6 +709,7 @@ export function createJavaManagedAgentProvider(
createEmpty: (
{
agentId,
modelId,
workspaceId,
cwdRelative,
approvalMode,
@@ -717,6 +721,7 @@ export function createJavaManagedAgentProvider(
createWorkspaceSession(
{
agentId,
modelId,
workspaceId,
cwdRelative,
approvalMode,
@@ -46,12 +46,19 @@ export type ManagedSessionRecovery = components['schemas']['SessionRecovery'];
export type ManagedTurnCancelOperation =
components['schemas']['WebShellCommandOperation'];
export interface ManagedModelPreset {
agentId: string;
label: string;
modelId?: string;
}
export interface ManagedSessionOptions {
approvalMode: ManagedApprovalMode;
availableApprovalModes: ManagedApprovalMode[];
planModeSupported?: boolean;
inputAttachments?: boolean;
skillsSupported?: boolean;
modelSelectionSupported?: boolean;
}
export interface ManagedSessionApproval extends ManagedSessionOptions {
@@ -454,8 +461,8 @@ export interface ManagedAgentProvider {
readonly workspaceBinding?: {
readonly agentId: string;
readonly registration?: ManagedWorkspaceRegistration;
/** Host-published model-specific AgentDefinitions available for new Sessions. */
readonly modelPresets?: readonly { agentId: string; label: string }[];
/** Host-published models and legacy AgentDefinition presets for new Sessions. */
readonly modelPresets?: readonly ManagedModelPreset[];
list(
options: ManagedAgentRequestOptions & { cursor?: string; limit?: number },
): Promise<{
@@ -494,6 +501,7 @@ export interface ManagedAgentProvider {
createEmpty(
request: {
agentId: string;
modelId?: string;
workspaceId: string;
cwdRelative: string;
approvalMode?: ManagedApprovalMode;
@@ -505,6 +513,7 @@ export interface ManagedAgentProvider {
createSession?(
request: {
agentId: string;
modelId?: string;
workspaceId: string;
cwdRelative: string;
text: string;
@@ -1,3 +1,4 @@
import type { ManagedModelPreset } from './managed-agent-provider';
import type { JavaManagedAgentProviderOptions } from './java-managed-agent-provider';
/** Local Vite configuration; the production host supplies these provider options directly. */
@@ -25,6 +26,9 @@ export function managedDevelopmentOptions(
return (
typeof preset['agentId'] !== 'string' ||
!preset['agentId'].trim() ||
(preset['modelId'] !== undefined &&
(typeof preset['modelId'] !== 'string' ||
!preset['modelId'].trim())) ||
typeof preset['label'] !== 'string' ||
!preset['label'].trim()
);
@@ -33,9 +37,9 @@ export function managedDevelopmentOptions(
throw new Error(
'VITE_MANAGED_AGENT_MODEL_PRESETS must be an array of {agentId, label}',
);
const modelPresets = raw as { agentId: string; label: string }[];
const modelPresets = raw as ManagedModelPreset[];
if (
new Set(modelPresets.map((item) => item.agentId)).size !==
new Set(modelPresets.map((item) => item.modelId ?? item.agentId)).size !==
modelPresets.length
)
throw new Error('Managed model preset agentId values must be unique');
@@ -11,6 +11,8 @@ import { act } from 'react';
// import is the real createRoot — used to mount the boot gate for real.
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ManagedAgentProvider } from './components/managed/managed-agent-provider';
import { createJavaManagedAgentProvider } from './components/managed/java-managed-agent-provider';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -73,6 +75,7 @@ describe('web shell boot', () => {
document.documentElement.removeAttribute(
'data-web-shell-unsupported-browser',
);
document.querySelector('meta[name="qwen-managed-app"]')?.remove();
window.history.replaceState(null, '', '/');
vi.unstubAllGlobals();
});
@@ -121,6 +124,56 @@ describe('web shell boot', () => {
expect(fetch).not.toHaveBeenCalled();
});
it('fixes Managed boot to the page origin and removes daemon and fanout overrides', async () => {
const marker = document.createElement('meta');
marker.name = 'qwen-managed-app';
marker.content = 'true';
document.head.appendChild(marker);
window.history.replaceState(
null,
'',
'/?daemon=https://attacker.example&fanout=https://other.example#token=browser-token',
);
testState.storedToken = 'browser-token';
document.body.innerHTML = '<div id="root"></div>';
await import('./main');
await vi.waitFor(() => expect(testState.rendered).toHaveLength(1));
const url = new URL(window.location.href);
expect(url.searchParams.has('daemon')).toBe(false);
expect(url.searchParams.has('fanout')).toBe(false);
const strictMode = testState.rendered[0] as React.ReactElement<{
children: React.ReactElement<{
baseUrl: string;
invalidTarget: boolean;
children: (token: string) => React.ReactElement;
}>;
}>;
const gate = strictMode.props.children;
expect(gate.props.baseUrl).toBe(window.location.origin);
expect(gate.props.invalidTarget).toBe(false);
const managedBoot = gate.props.children(
'browser-token',
) as React.ReactElement<{
daemonToken: string;
children: (provider: ManagedAgentProvider) => React.ReactElement<{
daemonToken: string;
managedAgentProvider: ManagedAgentProvider;
}>;
}>;
expect(managedBoot.type).toBe(
(await import('./ManagedStandaloneApp')).ManagedStandaloneApp,
);
expect(managedBoot.props.daemonToken).toBe('browser-token');
const provider = createJavaManagedAgentProvider({
baseUrl: window.location.origin,
productScope: 'local:installation-1',
});
const shell = managedBoot.props.children(provider);
expect(shell.type).toBe((await import('./main')).StandaloneApp);
expect(shell.props.daemonToken).toBe('browser-token');
expect(shell.props.managedAgentProvider).toBe(provider);
});
it('connects with the stored token when the pairing exchange fails', async () => {
const fetch = vi.fn().mockResolvedValue({
status: 401,
+21 -4
View File
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DaemonProductSessionContext } from '@qwen-code/web-shell/daemon-react-sdk';
import type { WebShellProps } from './App';
import { createJavaManagedAgentProvider } from './components/managed/java-managed-agent-provider';
import { useBrowserNotificationSettings } from './browser-turn-notifications';
import type { WebShellResolvedBrand } from './brandContext';
import { extractInlineScript, readIndexHtml } from './test/indexHtmlTestUtils';
@@ -470,10 +471,6 @@ describe('StandaloneApp brand', () => {
expect(stubIcon.href).toBe('data:image/svg+xml,LOGO');
});
// The WorkspaceSessionProvider is mocked out in this file, so an ordinary
// Runtime session id cannot be observed here; the real restore-suppression
// pin belongs with the provider. This check exercises the one behavior in
// scope for this mock set: the managedSession param survives StandaloneApp.
it('keeps the managedSession parameter on a Managed history link', () => {
window.history.replaceState(
null,
@@ -486,6 +483,26 @@ describe('StandaloneApp brand', () => {
).toBe('gateway-session');
});
it('supplies the authenticated Managed provider to the full shell instead of browser development options', () => {
window.history.replaceState(
null,
'',
'/?managedProvider=java&tenant=attacker',
);
const provider = createJavaManagedAgentProvider({
baseUrl: window.location.origin,
productScope: 'local:installation-1',
agentId: 'local-agent',
enableWorkspaceBinding: true,
});
act(() =>
root.render(
<StandaloneApp daemonToken="token" managedAgentProvider={provider} />,
),
);
expect(testState.props?.webShellProps.managedAgentProvider).toBe(provider);
});
it('injects the Java provider into the full shell in development mode', async () => {
window.history.replaceState(
null,
+49 -9
View File
@@ -21,6 +21,10 @@ import { createJavaManagedAgentProvider } from './components/managed/java-manage
import { managedDevelopmentOptions } from './components/managed/managed-development-options';
import type { ManagedAgentProvider } from './components/managed/managed-agent-provider';
import { StandaloneAuth } from './components/StandaloneAuth';
import {
hasManagedAppMarker,
ManagedStandaloneApp,
} from './ManagedStandaloneApp';
import { RootErrorFallback } from './components/RootErrorFallback';
import { WorkspaceSessionProvider } from './components/WorkspaceSessionProvider';
import {
@@ -44,9 +48,11 @@ import {
import 'katex/dist/katex.min.css';
import './styles/standalone.css';
const DAEMON_BASE_URL = getDaemonBaseUrl();
const MANAGED_APP = hasManagedAppMarker();
const DAEMON_BASE_URL = MANAGED_APP ? '' : getDaemonBaseUrl();
const REQUESTED_DAEMON_TARGET =
new URLSearchParams(window.location.search).get('daemon') || '';
(!MANAGED_APP && new URLSearchParams(window.location.search).get('daemon')) ||
'';
const INVALID_DAEMON_TARGET =
Boolean(REQUESTED_DAEMON_TARGET) && !DAEMON_BASE_URL;
// A `?daemon=` link can name any origin; one this browser has never connected
@@ -242,7 +248,13 @@ function FocusedDaemonWorkspaceProvider({
);
}
export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
export function StandaloneApp({
daemonToken,
managedAgentProvider: providedManagedAgentProvider,
}: {
daemonToken?: string;
managedAgentProvider?: ManagedAgentProvider;
}) {
const macosOverlayTitlebar = hasMacOSOverlayTitlebar();
// The entry's own opinion — an explicit URL param or a stored in-app
// choice. Passed down as the `theme`/`language` host props; `undefined`
@@ -267,9 +279,13 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
const [navigationBasePath] = useState(() =>
inferStandaloneBasePath(window.location.pathname),
);
const [managedAgentProvider] = useState(() =>
getDevelopmentManagedAgentProvider(),
const [developmentManagedAgentProvider] = useState(() =>
providedManagedAgentProvider
? undefined
: getDevelopmentManagedAgentProvider(),
);
const managedAgentProvider =
providedManagedAgentProvider ?? developmentManagedAgentProvider;
// One-shot ?theme=/?language=/?lang= params are consumed by the useState
// initializers above; strip them once mounted so a bookmarked URL cannot
// keep overriding stored preferences on later loads. (The reload retry
@@ -461,6 +477,12 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
}
async function main() {
if (MANAGED_APP) {
const url = new URL(window.location.href);
url.searchParams.delete('daemon');
url.searchParams.delete('fanout');
window.history.replaceState(window.history.state, '', url);
}
const baseUrl = DAEMON_BASE_URL || window.location.origin;
const storedToken = INVALID_DAEMON_TARGET
? undefined
@@ -483,9 +505,9 @@ async function main() {
document.documentElement.hasAttribute('data-web-shell-unsupported-browser')
)
return;
const pairing = await exchangePairingCode(
INVALID_DAEMON_TARGET ? '' : baseUrl,
);
const pairing = MANAGED_APP
? undefined
: await exchangePairingCode(INVALID_DAEMON_TARGET ? '' : baseUrl);
const daemonToken =
pairing?.token ??
storedToken ??
@@ -516,7 +538,25 @@ async function main() {
invalidTarget={INVALID_DAEMON_TARGET}
unconfirmedTarget={UNCONFIRMED_DAEMON_TARGET}
>
{(token) => <StandaloneApp daemonToken={token} />}
{(token) =>
MANAGED_APP ? (
<ManagedStandaloneApp
daemonToken={token}
language={
getInitialLanguage() ?? normalizeLanguage(navigator.language)
}
>
{(managedAgentProvider) => (
<StandaloneApp
daemonToken={token}
managedAgentProvider={managedAgentProvider}
/>
)}
</ManagedStandaloneApp>
) : (
<StandaloneApp daemonToken={token} />
)
}
</StandaloneAuth>
</React.StrictMode>,
);
+86
View File
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { copyManagedLocalRuntime } from './managed-local-package.js';
const { values } = parseArgs({
options: {
target: { type: 'string' },
'server-jar': { type: 'string' },
'jre-home': { type: 'string' },
'without-jre': { type: 'boolean' },
'out-dir': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
});
if (values.help) {
console.log(
'Usage: npm run package:managed-runtime -- --target TARGET --server-jar PATH (--jre-home DIR | --without-jre) [--out-dir DIR]\n' +
'Packages the server JAR and manifest, optionally with Java 21. Reuses an installed matching Qwen CLI.\n' +
'--without-jre builds a lite archive requiring local Java 21.\n' +
'Targets: darwin-arm64, darwin-x64, linux-arm64, linux-x64.\n' +
'Default output: packages/managed-agent-server/target/managed-runtime.',
);
} else {
for (const option of ['target', 'server-jar']) {
if (!values[option]) throw new Error(`--${option} is required.`);
}
if (Boolean(values['jre-home']) === Boolean(values['without-jre'])) {
throw new Error('Provide exactly one of --jre-home DIR or --without-jre.');
}
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const { version } = JSON.parse(
fs.readFileSync(path.join(root, 'package.json'), 'utf8'),
);
const target = values.target;
const outDir = path.resolve(
values['out-dir'] ??
path.join(
root,
'packages',
'managed-agent-server',
'target',
'managed-runtime',
),
);
const temporary = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-managed-runtime-'),
);
try {
copyManagedLocalRuntime({
destination: path.join(temporary, 'managed-runtime', version, target),
serverJar: path.resolve(values['server-jar']),
jreHome: values['jre-home']
? path.resolve(values['jre-home'])
: undefined,
version,
target,
runtime: 'node',
});
fs.mkdirSync(outDir, { recursive: true });
const name = `qwen-code-managed-runtime-${values['without-jre'] ? 'lite-' : ''}${target}.tar.gz`;
const archive = path.join(outDir, name);
execFileSync('tar', ['-czf', archive, '-C', temporary, 'managed-runtime'], {
stdio: 'inherit',
env: { ...process.env, COPYFILE_DISABLE: '1' },
});
const digest = createHash('sha256');
for await (const chunk of fs.createReadStream(archive))
digest.update(chunk);
fs.writeFileSync(`${archive}.sha256`, `${digest.digest('hex')} ${name}\n`);
console.log(`Created ${archive}\nCreated ${archive}.sha256`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
}
+52 -3
View File
@@ -14,6 +14,7 @@ import path from 'node:path';
import { createRequire } from 'node:module';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { copyManagedLocalRuntime } from './managed-local-package.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -172,6 +173,10 @@ async function main() {
fail('--runtime must be either "node" or "bun"');
}
if (Boolean(args.managedServerJar) !== Boolean(args.managedJreHome)) {
fail('--managed-server-jar and --managed-jre-home are required together.');
}
if (!args.nodeArchive) {
fail('--node-archive is required');
}
@@ -184,11 +189,27 @@ async function main() {
assertRequiredInputs();
const version = args.version || readPackageVersion();
const outDir = path.resolve(args.outDir || path.join(distDir, 'standalone'));
if (args.managedServerJar && version !== readPackageVersion()) {
fail(
'Managed package --version must match the built CLI version. Build all components from the same versioned checkout.',
);
}
const outDir = path.resolve(
args.outDir ||
path.join(
distDir,
args.managedServerJar ? 'managed-local' : 'standalone',
),
);
fs.mkdirSync(outDir, { recursive: true });
const targetConfig = TARGETS.get(target);
const outputName = standaloneArchiveName(target, args.runtime);
const outputName = args.managedServerJar
? standaloneArchiveName(target, args.runtime).replace(
'qwen-code-',
'qwen-code-managed-local-',
)
: standaloneArchiveName(target, args.runtime);
const outputPath = path.join(outDir, outputName);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-standalone-'));
@@ -219,11 +240,22 @@ async function main() {
}
validateNodeRuntime(target, nodeDir);
writeShims(packageRoot, args.runtime);
if (args.managedServerJar) {
copyManagedLocalRuntime({
destination: path.join(packageRoot, 'lib', 'managed-runtime'),
serverJar: path.resolve(args.managedServerJar),
jreHome: path.resolve(args.managedJreHome),
version,
target,
runtime: args.runtime,
});
}
writeManifest(packageRoot, {
version,
target,
nodeArchive: path.basename(nodeArchive),
runtime: args.runtime,
...(args.managedServerJar ? { managedLocal: true } : {}),
});
if (fs.existsSync(outputPath)) {
@@ -271,6 +303,8 @@ function parseArgs(argv) {
opentuiModulesDir: undefined,
outDir: undefined,
nodeArchive: undefined,
managedServerJar: undefined,
managedJreHome: undefined,
runtime: 'node',
skipChecksums: false,
target: undefined,
@@ -296,6 +330,14 @@ function parseArgs(argv) {
args.nodeArchive = readOptionValue(argv, index, arg);
index += 1;
break;
case '--managed-server-jar':
args.managedServerJar = readOptionValue(argv, index, arg);
index += 1;
break;
case '--managed-jre-home':
args.managedJreHome = readOptionValue(argv, index, arg);
index += 1;
break;
case '--native-modules-dir':
args.nativeModulesDir = readOptionValue(argv, index, arg);
index += 1;
@@ -344,6 +386,11 @@ Options:
binary at the bundled runtime path so the OpenTUI
renderer (needs bun:ffi) works standalone.
--node-archive PATH Downloaded Node.js runtime archive.
--managed-server-jar PATH
Include the matching Managed server Spring Boot JAR.
--managed-jre-home DIR Complete extracted Java 21 runtime for TARGET, including
legal/. Required with --managed-server-jar; creates a
distinct qwen-code-managed-local-TARGET archive.
--native-modules-dir DIR
Staged native node_modules directory holding
@teddyzhu/clipboard* and @lydell/node-pty*
@@ -355,7 +402,8 @@ Options:
Staged node_modules directory holding @opentui
platform packages. Used with --runtime bun; missing
packages are fatal when this is supplied.
--out-dir DIR Output directory. Defaults to dist/standalone.
--out-dir DIR Output directory. Defaults to dist/standalone,
or dist/managed-local for a Managed package.
--version VERSION Qwen Code version. Defaults to package.json version.
--skip-checksums Do not update SHA256SUMS. Used by release packaging.
-h, --help Show this help message.`);
@@ -1064,6 +1112,7 @@ function writeManifest(packageRoot, manifest) {
target: manifest.target,
runtime: manifest.runtime || 'node',
nodeArchive: manifest.nodeArchive,
...(manifest.managedLocal ? { managedLocal: true } : {}),
createdAt: new Date().toISOString(),
},
null,
+86
View File
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const TARGETS = new Map([
['darwin-arm64', ['Darwin', 'aarch64']],
['darwin-x64', ['Darwin', 'x86_64']],
['linux-arm64', ['Linux', 'aarch64']],
['linux-x64', ['Linux', 'x86_64']],
]);
export function copyManagedLocalRuntime({
destination,
serverJar,
jreHome,
version,
target,
runtime,
}) {
const expected = TARGETS.get(target);
if (!expected || runtime !== 'node') {
throw new Error(
'Managed local packages require native Linux/macOS and the Node runtime.',
);
}
let javaVersion;
if (jreHome) {
const release = fs.readFileSync(path.join(jreHome, 'release'), 'utf8');
const field = (name) =>
release.match(new RegExp(`^${name}="([^"]+)"$`, 'm'))?.[1];
javaVersion = field('JAVA_VERSION');
const architecture = field('OS_ARCH');
if (
!/^21(?:[.\-+]|$)/u.test(javaVersion ?? '') ||
field('OS_NAME') !== expected[0] ||
(architecture !== expected[1] &&
!(expected[1] === 'x86_64' && architecture === 'amd64'))
) {
throw new Error(`Managed JRE must be Java 21 for ${target}.`);
}
for (const required of ['bin/java', 'lib/modules', 'legal']) {
if (!fs.existsSync(path.join(jreHome, required))) {
throw new Error(
`Managed JRE is missing ${required}; include the complete runtime and licenses.`,
);
}
}
}
const jarBytes = fs.readFileSync(serverJar);
if (jarBytes.subarray(0, 4).toString('hex') !== '504b0304') {
throw new Error(
'Managed server input must be the packaged Spring Boot JAR.',
);
}
fs.mkdirSync(destination, { recursive: true });
fs.copyFileSync(serverJar, path.join(destination, 'server.jar'));
if (jreHome) {
fs.cpSync(jreHome, path.join(destination, 'jre'), {
recursive: true,
dereference: true,
// Node's native recursive copy can preserve links despite dereference.
// A filter selects the JavaScript traversal that follows them.
filter: () => true,
});
}
const manifest = {
schemaVersion: 1,
cliVersion: version,
target,
javaMajor: 21,
javaSource: jreHome ? 'bundled' : 'system',
javaVersion,
serverSha256: crypto.createHash('sha256').update(jarBytes).digest('hex'),
};
fs.writeFileSync(
path.join(destination, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
);
return manifest;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { valid } from 'semver';
import { isMainModule, parseArgs, sha256File } from './release-script-utils.js';
export async function stageManagedRuntimeRelease({ jar, version, outDir }) {
if (valid(version) !== version)
throw new Error('An exact CLI release version is required.');
const input = fs.openSync(jar, 'r');
try {
const header = Buffer.alloc(4);
fs.readSync(input, header, 0, header.length, 0);
if (header.toString('hex') !== '504b0304')
throw new Error('Expected a packaged server JAR.');
} finally {
fs.closeSync(input);
}
const name = `qwen-managed-agent-server-${version}.jar`;
fs.mkdirSync(outDir, { recursive: true });
const destination = path.join(outDir, name);
fs.copyFileSync(jar, destination);
const sha256 = await sha256File(destination);
fs.writeFileSync(`${destination}.sha256`, `${sha256} ${name}\n`);
return { name, sha256 };
}
export async function checkManagedRuntimeAsset(url, file, fetchImpl = fetch) {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
throw new Error(
'Release artifact verification requires a public HTTPS URL.',
);
}
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(300_000),
});
if (response.status === 404) {
await response.body?.cancel();
return 'missing';
}
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error(
`Cannot verify existing release artifact: HTTP ${response.status}.`,
);
}
const size = fs.statSync(file).size;
const digest = createHash('sha256');
let received = 0;
await pipeline(
Readable.fromWeb(response.body),
new Transform({
transform(chunk, _encoding, callback) {
received += chunk.length;
if (received > size) {
callback(
new Error(
'Existing release artifact has different bytes; refusing overwrite.',
),
);
return;
}
callback(null, chunk);
},
}),
digest,
);
if (received !== size || digest.digest('hex') !== (await sha256File(file))) {
throw new Error(
'Existing release artifact has different bytes; refusing overwrite.',
);
}
return 'present';
}
if (isMainModule(import.meta.url)) {
try {
const args = parseArgs(process.argv.slice(2), {
'--jar': { key: 'jar', type: 'value' },
'--version': { key: 'version', type: 'value' },
'--out-dir': { key: 'outDir', type: 'value' },
'--check-url': { key: 'url', type: 'value' },
'--file': { key: 'file', type: 'value' },
});
if (args.help) {
console.log(
'Stage: --jar PATH --version VERSION --out-dir DIR\nVerify without replacing: --check-url HTTPS_URL --file PATH (prints present or missing).',
);
} else if (
args.url &&
args.file &&
!args.jar &&
!args.version &&
!args.outDir
) {
console.log(await checkManagedRuntimeAsset(args.url, args.file));
} else if (
args.jar &&
args.version &&
args.outDir &&
!args.url &&
!args.file
) {
console.log(JSON.stringify(await stageManagedRuntimeRelease(args)));
} else {
throw new Error(
'Provide one complete stage or verification argument set; see --help.',
);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, test } from 'vitest';
import { copyManagedLocalRuntime } from '../managed-local-package.js';
let temporary;
let options;
beforeEach(() => {
temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'managed-package-'));
const jreHome = path.join(temporary, 'jre');
fs.mkdirSync(path.join(jreHome, 'bin'), { recursive: true });
fs.mkdirSync(path.join(jreHome, 'lib'));
fs.mkdirSync(path.join(jreHome, 'legal'));
fs.writeFileSync(
path.join(jreHome, 'release'),
'JAVA_VERSION="21.0.12"\nOS_NAME="Darwin"\nOS_ARCH="aarch64"\n',
);
fs.writeFileSync(path.join(jreHome, 'bin/java'), 'test runtime', {
mode: 0o755,
});
fs.writeFileSync(path.join(jreHome, 'lib/modules'), 'modules');
fs.writeFileSync(path.join(jreHome, 'legal/LICENSE'), 'license');
if (process.platform === 'win32') {
fs.copyFileSync(
path.join(jreHome, 'legal/LICENSE'),
path.join(jreHome, 'legal/COPYING'),
);
} else {
fs.symlinkSync('LICENSE', path.join(jreHome, 'legal/COPYING'));
}
const serverJar = path.join(temporary, 'server.jar');
fs.writeFileSync(serverJar, Buffer.from('504b03040000', 'hex'));
options = {
destination: path.join(temporary, 'package', 'lib', 'managed-runtime'),
jreHome,
serverJar,
version: '0.25.3',
target: 'darwin-arm64',
runtime: 'node',
};
});
afterEach(() => fs.rmSync(temporary, { recursive: true, force: true }));
test('packages the complete JRE and server with version, target and digest', () => {
const manifest = copyManagedLocalRuntime(options);
const output = options.destination;
assert.equal(manifest.cliVersion, options.version);
assert.equal(manifest.target, options.target);
assert.match(manifest.serverSha256, /^[a-f0-9]{64}$/u);
assert.deepEqual(
JSON.parse(fs.readFileSync(path.join(output, 'manifest.json'))),
manifest,
);
assert.equal(
fs.readFileSync(path.join(output, 'jre/legal/LICENSE'), 'utf8'),
'license',
);
assert.equal(
fs.lstatSync(path.join(output, 'jre/legal/COPYING')).isSymbolicLink(),
false,
);
assert.equal(
fs.readFileSync(path.join(output, 'jre/legal/COPYING'), 'utf8'),
'license',
);
assert.equal(
fs.statSync(path.join(output, 'jre/bin/java')).mode & 0o777,
0o755,
);
});
test('rejects incompatible target, Java, and missing runtime licenses', () => {
assert.throws(
() => copyManagedLocalRuntime({ ...options, target: 'linux-x64' }),
/Java 21 for linux-x64/u,
);
assert.throws(
() => copyManagedLocalRuntime({ ...options, runtime: 'bun' }),
/Node runtime/u,
);
assert.throws(
() => copyManagedLocalRuntime({ ...options, target: 'win-x64' }),
/native Linux/u,
);
fs.rmSync(path.join(options.jreHome, 'legal'), { recursive: true });
assert.throws(
() => copyManagedLocalRuntime(options),
/complete runtime and licenses/u,
);
});
@@ -0,0 +1,251 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { gunzipSync } from 'node:zlib';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe.skipIf(process.platform === 'win32')(
'separate Managed runtime archive',
() => {
let temporary;
let script;
let args;
let outDir;
beforeEach(() => {
temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-package-'));
const checkout = path.join(temporary, 'checkout');
fs.mkdirSync(path.join(checkout, 'scripts'), { recursive: true });
fs.writeFileSync(
path.join(checkout, 'package.json'),
JSON.stringify({ type: 'module', version: '0.25.3' }),
);
const source = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
for (const file of [
'create-managed-runtime-package.js',
'managed-local-package.js',
]) {
fs.copyFileSync(
path.join(source, file),
path.join(checkout, 'scripts', file),
);
}
script = path.join(
checkout,
'scripts',
'create-managed-runtime-package.js',
);
const jre = path.join(temporary, 'jre');
fs.mkdirSync(path.join(jre, 'bin'), { recursive: true });
fs.mkdirSync(path.join(jre, 'lib'));
fs.mkdirSync(path.join(jre, 'legal'));
fs.writeFileSync(
path.join(jre, 'release'),
'JAVA_VERSION="21.0.12"\nOS_NAME="Darwin"\nOS_ARCH="aarch64"\n',
);
fs.writeFileSync(path.join(jre, 'bin', 'java'), 'fixture', {
mode: 0o755,
});
fs.writeFileSync(path.join(jre, 'lib', 'modules'), 'modules');
fs.writeFileSync(path.join(jre, 'legal', 'LICENSE'), 'license');
if (process.platform === 'darwin') {
execFileSync('xattr', [
'-w',
'com.qwen.package-test',
'fixture',
path.join(jre, 'bin', 'java'),
]);
}
const jar = path.join(temporary, 'server.jar');
fs.writeFileSync(jar, Buffer.from('504b03040000', 'hex'));
outDir = path.join(temporary, 'output');
args = [
'--target',
'darwin-arm64',
'--server-jar',
jar,
'--jre-home',
jre,
'--out-dir',
outDir,
];
});
afterEach(() => fs.rmSync(temporary, { recursive: true, force: true }));
it('builds from JAR/JRE inputs alone and installs under the exact CLI version and target', () => {
execFileSync(process.execPath, [script, ...args], { cwd: temporary });
const name = 'qwen-code-managed-runtime-darwin-arm64.tar.gz';
const archive = path.join(outDir, name);
// macOS tar hides AppleDouble entries when listing; inspect raw headers.
const tar = gunzipSync(fs.readFileSync(archive));
const entries = [];
for (let offset = 0; tar[offset]; ) {
const name = tar
.subarray(offset, offset + 100)
.toString()
.split('\0')[0];
const type = String.fromCharCode(tar[offset + 156]);
if (type !== 'x' && type !== 'g') entries.push(name);
const size = Number.parseInt(
tar.subarray(offset + 124, offset + 136).toString(),
8,
);
offset += 512 + Math.ceil(size / 512) * 512;
}
expect(
entries.every((entry) => entry.startsWith('managed-runtime/')),
).toBe(true);
expect(
entries.some((entry) =>
entry.split('/').some((part) => part.startsWith('._')),
),
).toBe(false);
const digest = createHash('sha256')
.update(fs.readFileSync(archive))
.digest('hex');
expect(fs.readFileSync(`${archive}.sha256`, 'utf8')).toBe(
`${digest} ${name}\n`,
);
const extraction = path.join(temporary, 'extracted');
fs.mkdirSync(extraction);
execFileSync('tar', ['-xzf', archive, '-C', extraction]);
const payload = path.join(
extraction,
'managed-runtime',
'0.25.3',
'darwin-arm64',
);
expect(fs.readdirSync(extraction)).toEqual(['managed-runtime']);
expect(fs.readdirSync(payload).sort()).toEqual([
'jre',
'manifest.json',
'server.jar',
]);
expect(
fs.readFileSync(path.join(payload, 'jre', 'legal', 'LICENSE'), 'utf8'),
).toBe('license');
expect(
JSON.parse(
fs.readFileSync(path.join(payload, 'manifest.json'), 'utf8'),
),
).toMatchObject({
schemaVersion: 1,
cliVersion: '0.25.3',
target: 'darwin-arm64',
javaMajor: 21,
javaSource: 'bundled',
serverSha256: createHash('sha256')
.update(fs.readFileSync(args[3]))
.digest('hex'),
});
expect(
fs.existsSync(path.join(path.dirname(path.dirname(script)), 'dist')),
).toBe(false);
});
it('builds a separate lite archive from the server alone, without replacing the bundled archive', () => {
execFileSync(process.execPath, [script, ...args]);
const bundled = path.join(
outDir,
'qwen-code-managed-runtime-darwin-arm64.tar.gz',
);
const bundledBytes = fs.readFileSync(bundled);
const liteArgs = [...args.slice(0, 4), '--without-jre', ...args.slice(6)];
fs.rmSync(args[5], { recursive: true });
execFileSync(process.execPath, [script, ...liteArgs]);
const name = 'qwen-code-managed-runtime-lite-darwin-arm64.tar.gz';
const archive = path.join(outDir, name);
const digest = createHash('sha256')
.update(fs.readFileSync(archive))
.digest('hex');
expect(fs.readFileSync(`${archive}.sha256`, 'utf8')).toBe(
`${digest} ${name}\n`,
);
const extraction = path.join(temporary, 'lite-extracted');
fs.mkdirSync(extraction);
execFileSync('tar', ['-xzf', archive, '-C', extraction]);
const payload = path.join(
extraction,
'managed-runtime',
'0.25.3',
'darwin-arm64',
);
expect(fs.readdirSync(payload).sort()).toEqual([
'manifest.json',
'server.jar',
]);
expect(
JSON.parse(
fs.readFileSync(path.join(payload, 'manifest.json'), 'utf8'),
),
).toMatchObject({ javaSource: 'system', javaMajor: 21 });
expect(fs.readFileSync(bundled)).toEqual(bundledBytes);
});
it('requires exactly one Java delivery mode', () => {
for (const invalid of [
[...args.slice(0, 4), ...args.slice(6)],
[...args, '--without-jre'],
]) {
const result = spawnSync(process.execPath, [script, ...invalid], {
encoding: 'utf8',
});
expect(result.status).toBe(1);
expect(result.stderr).toContain(
'exactly one of --jre-home DIR or --without-jre',
);
}
expect(fs.existsSync(outDir)).toBe(false);
});
it('keeps default archives outside existing CLI assets and runtime discovery', () => {
const checkout = path.dirname(path.dirname(script));
const cliDist = path.join(checkout, 'dist');
fs.mkdirSync(cliDist);
fs.writeFileSync(path.join(cliDist, 'cli.js'), 'existing CLI');
execFileSync(process.execPath, [script, ...args.slice(0, -2)]);
expect(fs.readdirSync(cliDist)).toEqual(['cli.js']);
expect(
fs.existsSync(
path.join(
checkout,
'packages',
'managed-agent-server',
'target',
'managed-runtime',
'qwen-code-managed-runtime-darwin-arm64.tar.gz',
),
),
).toBe(true);
});
it('refuses missing runtime inputs and an unsupported target without emitting an archive', () => {
const missing = spawnSync(
process.execPath,
[script, '--target', 'darwin-arm64'],
{ encoding: 'utf8' },
);
expect(missing.status).toBe(1);
expect(missing.stderr).toContain('--server-jar is required');
const unsupported = spawnSync(
process.execPath,
[
script,
...args.map((arg) => (arg === 'darwin-arm64' ? 'win-x64' : arg)),
],
{ encoding: 'utf8' },
);
expect(unsupported.status).toBe(1);
expect(unsupported.stderr).toContain('native Linux/macOS');
expect(fs.existsSync(outDir)).toBe(false);
});
},
);
@@ -0,0 +1,319 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { parse } from 'yaml';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
checkManagedRuntimeAsset,
stageManagedRuntimeRelease,
} from '../managed-runtime-release.js';
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const workflow = parse(
fs.readFileSync(
path.join(root, '.github/workflows/release-managed-runtime.yml'),
'utf8',
),
);
let temporary;
let jar;
const bytes = Buffer.concat([
Buffer.from('504b0304', 'hex'),
Buffer.from('release JAR fixture'),
]);
beforeEach(() => {
temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'managed-release-'));
jar = path.join(temporary, 'server.jar');
fs.writeFileSync(jar, bytes);
});
afterEach(() => fs.rmSync(temporary, { recursive: true, force: true }));
it('stages the exact server bytes and the checksum consumed by the first-use installer', async () => {
const outDir = path.join(temporary, 'release');
const result = await stageManagedRuntimeRelease({
jar,
version: '0.25.4-preview.1',
outDir,
});
const name = 'qwen-managed-agent-server-0.25.4-preview.1.jar';
expect(result).toEqual({
name,
sha256: createHash('sha256').update(bytes).digest('hex'),
});
expect(fs.readFileSync(path.join(outDir, name))).toEqual(bytes);
expect(fs.readFileSync(path.join(outDir, `${name}.sha256`), 'utf8')).toBe(
`${result.sha256} ${name}\n`,
);
expect(fs.readdirSync(outDir).sort()).toEqual([name, `${name}.sha256`]);
});
it.each(['latest', '../escape', 'v0.25.4'])(
'refuses non-exact version %s before staging',
async (version) => {
const outDir = path.join(temporary, 'release');
await expect(
stageManagedRuntimeRelease({ jar, version, outDir }),
).rejects.toThrow('exact CLI release');
expect(fs.existsSync(outDir)).toBe(false);
},
);
it('refuses non-JAR content rather than producing a downloadable payload', async () => {
fs.writeFileSync(jar, '<html>error</html>');
await expect(
stageManagedRuntimeRelease({
jar,
version: '0.25.4',
outDir: path.join(temporary, 'release'),
}),
).rejects.toThrow('packaged server JAR');
});
it('recognizes an already published identical artifact without replacing it', async () => {
const fetchImpl = vi.fn(async () => new Response(bytes));
await expect(
checkManagedRuntimeAsset('https://example.com/server.jar', jar, fetchImpl),
).resolves.toBe('present');
expect(fetchImpl.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
it('treats only a genuine 404 as an unpublished artifact', async () => {
await expect(
checkManagedRuntimeAsset(
'https://example.com/server.jar',
jar,
async () => new Response('', { status: 404 }),
),
).resolves.toBe('missing');
for (const status of [403, 429, 500]) {
await expect(
checkManagedRuntimeAsset(
'https://example.com/server.jar',
jar,
async () => new Response('', { status }),
),
).rejects.toThrow(`HTTP ${status}`);
}
});
it.each(['different bytes of the same length', 'truncated', 'oversized'])(
'refuses an existing %s artifact',
async (kind) => {
const content =
kind === 'truncated'
? bytes.subarray(0, 4)
: kind === 'oversized'
? Buffer.concat([bytes, Buffer.from('extra')])
: Buffer.alloc(bytes.length, 1);
await expect(
checkManagedRuntimeAsset(
'https://example.com/server.jar',
jar,
async () => new Response(content),
),
).rejects.toThrow('refusing overwrite');
},
);
it('does not disguise network failure as a missing release', async () => {
await expect(
checkManagedRuntimeAsset(
'https://example.com/server.jar',
jar,
async () => {
throw new Error('network unavailable');
},
),
).rejects.toThrow('network unavailable');
});
it.each([
'http://example.com/server.jar',
'https://user:[email protected]/server.jar',
])('requires a public HTTPS source: %s', async (url) => {
const fetchImpl = vi.fn();
await expect(checkManagedRuntimeAsset(url, jar, fetchImpl)).rejects.toThrow(
'public HTTPS',
);
expect(fetchImpl).not.toHaveBeenCalled();
});
describe('Managed runtime publication gates', () => {
const steps = workflow.jobs.release.steps;
const step = (name) => steps.find((item) => item.name === name);
it('defaults manual runs to verification and restricts publication to the protected official repository', () => {
expect(workflow.on.release.types).toEqual(['published']);
expect(workflow.on.workflow_dispatch.inputs.dry_run.default).toBe(true);
expect(workflow.jobs.prepare.if).toContain(
"github.repository == 'QwenLM/qwen-code'",
);
expect(workflow.jobs.release.environment.name).toBe('production-release');
expect(workflow.concurrency['cancel-in-progress']).toBe(false);
expect(workflow.jobs.release.if).toContain(
"needs.prepare.outputs.eligible == 'true'",
);
for (const name of [
'Recheck the immutable tag and existing publication bytes',
'Configure signed Central publication',
'Publish the signed server artifact to Central',
'Attach only missing GitHub release assets',
'Install the verified OSS uploader',
'Upload only missing OSS release assets',
]) {
expect(step(name).if).toContain(
"github.event_name == 'release' || !inputs.dry_run",
);
}
expect(step('Attach only missing GitHub release assets').run).not.toContain(
'--clobber',
);
const build = steps.findIndex(
(item) =>
item.name === 'Verify the executable JAR and publication profile',
);
expect(build).toBeLessThan(
steps.findIndex(
(item) => item.name === 'Publish the signed server artifact to Central',
),
);
});
it('pins checkout and reproducible timestamps, and verifies the SDK before packaging the server', () => {
expect(steps[0].with.ref).toBe('${{ needs.prepare.outputs.sha }}');
expect(steps[0].with['persist-credentials']).toBe(false);
expect(
step('Verify and install the SDK from the same source').run,
).toContain('clean install');
expect(
step('Verify and install the SDK from the same source').run,
).toContain('project.build.outputTimestamp="${BUILD_TIMESTAMP}"');
expect(
step('Verify the executable JAR and publication profile').run,
).toContain('-Pcentral-release');
expect(
step('Verify the executable JAR and publication profile').run,
).toContain('clean verify');
expect(step('Publish the signed server artifact to Central').run).toContain(
'cmp ',
);
});
it('keeps auxiliary executable JARs out of Central without changing their ordinary build attachment', () => {
const pom = fs.readFileSync(
path.join(root, 'packages/managed-agent-server/pom.xml'),
'utf8',
);
expect(pom).toContain(
'<managed.operator-tools.attach>true</managed.operator-tools.attach>',
);
const profile = pom.match(
/<profile>\s*<id>central-release<\/id>([\s\S]*?)<\/profile>/,
)[1];
expect(profile).toContain(
'<managed.operator-tools.attach>false</managed.operator-tools.attach>',
);
for (const plugin of [
'central-publishing-maven-plugin',
'maven-source-plugin',
'maven-javadoc-plugin',
'maven-gpg-plugin',
])
expect(profile).toContain(plugin);
expect(
pom.match(/<attach>\$\{managed.operator-tools.attach\}<\/attach>/g),
).toHaveLength(6);
});
});
describe.skipIf(process.platform === 'win32')('release gate execution', () => {
function executable(name, body) {
const bin = path.join(temporary, 'bin');
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`, {
mode: 0o755,
});
return bin;
}
function run(script, env = {}) {
return spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', script], {
cwd: temporary,
env: {
...process.env,
HOME: temporary,
BASH_ENV: '/dev/null',
PATH: `${path.join(temporary, 'bin')}${path.delimiter}${process.env.PATH}`,
RUNNER_TEMP: temporary,
GITHUB_OUTPUT: path.join(temporary, 'output'),
RELEASE_TAG: 'v0.25.4',
EVENT_NAME: 'workflow_dispatch',
WORKFLOW_REF: 'refs/heads/main',
...env,
},
encoding: 'utf8',
});
}
const gate = workflow.jobs.prepare.steps.find(
(item) => item.id === 'gate',
).run;
const preflight = workflow.jobs.release.steps.find(
(item) => item.id === 'publication',
).run;
it('rejects a non-main manual run before consulting the release', () => {
executable('gh', 'touch gh-called; exit 1');
expect(run(gate, { WORKFLOW_REF: 'refs/heads/untrusted' }).status).not.toBe(
0,
);
expect(fs.existsSync(path.join(temporary, 'gh-called'))).toBe(false);
});
it('ignores an unrelated published release without attempting publication', () => {
executable('gh', 'touch gh-called; exit 1');
expect(
run(gate, { EVENT_NAME: 'release', RELEASE_TAG: 'vscode-v0.25.4' })
.status,
).toBe(0);
expect(fs.readFileSync(path.join(temporary, 'output'), 'utf8')).toBe(
'eligible=false\n',
);
expect(fs.existsSync(path.join(temporary, 'gh-called'))).toBe(false);
});
it('rejects a changed release tag before checking or uploading artifacts', () => {
executable('git', "printf '%s\\tref\\n' different-sha");
executable('gh', 'touch gh-called; exit 1');
expect(
run(preflight, { SOURCE_SHA: 'original-sha', CLI_VERSION: '0.25.4' })
.status,
).not.toBe(0);
expect(fs.existsSync(path.join(temporary, 'gh-called'))).toBe(false);
});
it('aborts preflight on an artifact-verification error instead of treating it as present', () => {
executable('git', "printf '%s\\tref\\n' original-sha");
executable('gh', "printf '%s\\n' '{\"draft\":false}'");
executable(
'node',
'case "$*" in *github.com*".jar --file "*) echo mismatch >&2; exit 7 ;; *repo.maven.apache.org*) echo present ;; *) echo missing ;; esac',
);
const result = run(preflight, {
SOURCE_SHA: 'original-sha',
CLI_VERSION: '0.25.4',
OSS_PUBLIC_BASE_URL: 'https://example.com',
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('mismatch');
});
});