qwen-code/scripts/package-managed-agent-reference.mjs
Shaojin Wen 40871f7d27
feat(managed-agent): bootstrap the shared deployment credentials (#14634)
* feat(managed-agent): ship a configuration template in the reference distribution

The reference archive carried no application.yml, so the Runtime credential
key ring — which cannot be expressed through QWEN_MANAGED_AGENT_* variables
because relaxed binding normalises map keys — forced every rotating deployment
to create a file and pass --spring.config.additional-location by hand.

Ship config/application.example.yml and have both launchers load
config/application.yml from the bundle root when it exists, leaving an
operator-supplied SPRING_CONFIG_ADDITIONAL_LOCATION in effect.

* feat(managed-agent): add a read-only --check preflight

The reference distribution listed its prerequisites in prose, but the server
evaluated them only at startup or on the first Turn: MySQL grants, the
credential key ring, auth/bind combinations, Harness reachability, broker
credentials, directories and ports.

Intercept --check before Spring starts and report each input as
PASS/WARN/FAIL/SKIP, exiting 1 on any failure. The command binds the same
configuration as the server, probes /capabilities, and compares the Flyway
version with the release manifest when one is present.

* feat(managed-agent): ship systemd units for the single-host topology

The server reference required a supervisor that leaves Runtime workers running
across a Broker restart, but the reference distribution shipped no unit that
did so.

Add control-plane and Hosted Harness units, environment templates, and an
install guide. The control-plane unit uses KillMode=process and StateDirectory;
the Harness uses KillMode=mixed. Include the templates in the archive and
guard their required directives with a structural test.

* feat(managed-agent): bootstrap the shared deployment credentials

Every deployment had to mint a Harness token, a capability digest, a broker
token and a 32-byte credential key, then keep the token and digest identical
across two files and the key ring in a third.

Add bin/qwen-managed-agent-bootstrap, which generates all four values and
writes harness.env, server.env and application.yml with mode 0600, refuses to
overwrite without --force, and prints paths only. Ship it in the archive.

* fix(managed-agent): pass the distribution config through the environment

The shared launcher injected --spring.config.additional-location before the
operator's arguments, so the operator-recovery entry, which dispatches on
args[0] and the exact argument count, could no longer run inspect, prepare or
complete.

Export SPRING_CONFIG_ADDITIONAL_LOCATION instead and leave the argument vector
untouched for both launchers. Also make the template's active key pair use the
mapped placeholders, so a copied example still honours the documented
environment override.

* docs(managed-agent): match the preflight design to the launcher fix

The preflight design still said the launcher adds a command-line config option.
The launcher now exports SPRING_CONFIG_ADDITIONAL_LOCATION, so update both
language versions.

* test(managed-agent): isolate the launcher test from an ambient config variable

The test spread process.env, so a machine that already exported
SPRING_CONFIG_ADDITIONAL_LOCATION changed the launcher's behaviour and failed
the new assertions. Remove the variable from the base environment; the
explicit operator-override case still sets it.

* fix(managed-agent): align the preflight with startup validation

The preflight could report PASS for a database without the performance_schema
grant, ignored command-line overrides, dropped earlier external YAML files
when several were listed, and could not find the release manifest in the
repackaged executable JAR. It also hand-copied auth and Workspace tool-path
rules that disagreed with the startup validators.

Delegate auth to BrokerSecurity, mirror the Workspace tool-path admission,
fail on a missing grant, load command-line arguments and every external YAML
with unique property sources, and resolve the manifest from the outer JAR.

* fix(managed-agent): reject the static provisioner for Workspace files

validateWorkspaceFiles refuses Workspace files on the static provisioner, but tool-path reported only WARN. Make it FAIL and keep the not-evaluated warning for other provisioners.

* fix(managed-agent): close the remaining preflight review gaps

Resolve relative class-path entries before locating the manifest, bind the full
server and spring.mvc properties before the auth guard, and require durable
local-process execution plus verified recovery for a Workspace creation root.

* fix(managed-agent): wire the Harness Broker flags and keep EnvironmentFile semantics

Expose QWEN_HOSTED_HARNESS_BROKER_ARGS in the Harness unit so the tool path can
pass the Broker URL and token, and run the preflight through systemd-run so it
reads the same EnvironmentFile as the service.

* fix(managed-agent): harden the bootstrap entry point and writes

Resolve symlinked entry points before running main, quote the configuration
path for shell sourcing and reject paths that cannot be quoted, and hold an
exclusive lock across the existence check and the three writes.

* fix(managed-agent): run the startup validation in the preflight

The preflight bound ManagedAgentProperties with Binder, which never runs
the @PostConstruct startup validation. Configurations that the server
rejects at startup -- an approval timeout below the ten-second floor, an
unknown bound-session isolation class, SSH enabled without Workspace
files -- printed PASS and exited 0.

ManagedAgentProperties now exposes the validation as a callable method
and the preflight reports its verdict as a `validation` row.

* fix(ci): keep the systemd READMEs on the full profile

The packaging test names distribution/systemd/README.md and its Chinese
counterpart, so classify-profile.test.mjs requires them in
TEST_READ_MARKDOWN. Without the entry the helper battery failed with both
files listed as unregistered lightweight Markdown, reddening Lint &
Static for the systemd stack.

* fix(managed-agent): boot the systemd template and authenticate the preflight

The shipped server.env.example enabled the Hosted Harness without the
Managed Session Store, which startup refuses, so following the README
restart-looped. The template now enables the store and sets its base URL
and the Runtime workspace ID.

The preflight probed /capabilities without the Harness token, so --check
printed FAIL 401 against a healthy authenticated Harness. It now sends the
configured bearer token and reports a rejected token separately from an
unreachable Harness.

The README installs application.yml as 0600 owned by the service account
instead of 0640 root:group: the control plane reads it as that account and
the preflight rejects group-readable secret files. It also documents the
ProtectHome constraint on the CLI location and the port-advance case.

* fix(managed-agent): close the systemd review gaps

The Harness unit expands QWEN_HOSTED_HARNESS_BROKER_ARGS into argv, so the
broker token is readable through /proc/<pid>/cmdline. Say so in the env
template and the unit comment, and keep that path to single-tenant hosts.

KillMode=process means `systemctl stop` signals only the main process: the
unit reports inactive while Runtime workers keep the state directory and
Workspace. Document the symptom and the reap command in both READMEs and
the deployment guide.

Derive the archive's required systemd entries from systemdFiles, so a
dropped env template or Chinese README fails packaging instead of only
breaking install step 3.

* fix(managed-agent): point the generated store at the server and reject commas

The bootstrap generator's server.env still omitted the Managed Session
Store, so the generated deployment failed the preflight's validation row.
It now emits the store enablement, its base URL and the Runtime workspace
ID.

The store URL is this Java control plane's listener, not the Harness: the
template and the generator now use http://127.0.0.1:8080, matching the
server reference. The systemd READMEs name both variables so a Harness
port change does not leave the store behind.

A comma in --directory splits SPRING_CONFIG_ADDITIONAL_LOCATION into two
locations; the guard now rejects it with the quote and newline cases, and
tests pin the generator's store lines and the 8080 value.
2026-10-19 12:11:21 +00:00

525 lines
16 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import {
chmod,
copyFile,
mkdir,
readFile,
readdir,
rm,
stat,
utimes,
writeFile,
} from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
import { createGzip } from 'node:zlib';
import {
CONTRACT_DOCUMENT,
documentVersion,
} from './contract-version-utils.js';
import {
LOCATIONS,
migrationFiles,
migrationVersion,
} from './flyway-migration-utils.js';
const DISTRIBUTION_NAME = 'qwen-managed-agent-reference';
const PRERELEASE_IDENTIFIER =
'(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)';
const VERSION_PATTERN = new RegExp(
`^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)-${PRERELEASE_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*$`,
);
const REVISION_PATTERN = /^[0-9a-f]{40}$/;
async function sha256(file) {
return createHash('sha256')
.update(await readFile(file))
.digest('hex');
}
function match(text, pattern, label) {
const result = text.match(pattern);
if (!result) throw new Error(`Cannot read ${label}.`);
return result[1];
}
function uniqueMatch(text, pattern, label) {
const results = [...text.matchAll(pattern)];
if (results.length !== 1) {
throw new Error(`Cannot uniquely read ${label}.`);
}
return results[0][1];
}
function withoutComments(text) {
let output = '';
let block = false;
for (let index = 0; index < text.length; index += 1) {
if (block) {
if (text.startsWith('*/', index)) {
block = false;
index += 1;
} else if (text[index] === '\n') {
output += '\n';
}
} else if (text.startsWith('/*', index)) {
block = true;
index += 1;
} else if (text.startsWith('//', index)) {
index = text.indexOf('\n', index) - 1;
if (index < -1) break;
} else {
output += text[index];
}
}
return output;
}
async function pomVersion(file, artifactId) {
const text = await readFile(file, 'utf8');
return match(
text,
new RegExp(
`<artifactId>${artifactId}</artifactId>\\s*<version>([^<]+)</version>`,
),
`${artifactId} version`,
);
}
function compareMigrationVersions(left, right) {
const a = left.split('.').map(Number);
const b = right.split('.').map(Number);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
const difference = (a[index] ?? 0) - (b[index] ?? 0);
if (difference !== 0) return difference;
}
return 0;
}
function migrationRange(root) {
const versions = LOCATIONS.flatMap(({ dir, suffix }) =>
migrationFiles(
path.join(root, 'packages/managed-agent-server', ...dir),
suffix,
).map((file) => migrationVersion(path.basename(file))),
);
if (versions.length === 0) throw new Error('No Flyway migrations found.');
versions.sort(compareMigrationVersions);
return { minimum: versions[0], latest: versions.at(-1) };
}
async function listFiles(directory, prefix = '') {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const relative = path.posix.join(prefix, entry.name);
if (entry.isDirectory()) {
files.push(
...(await listFiles(path.join(directory, entry.name), relative)),
);
} else if (entry.isFile()) {
files.push(relative);
}
}
return files.sort();
}
async function writeChecksums(directory, files, destination) {
const lines = [];
for (const relative of files) {
lines.push(`${await sha256(path.join(directory, relative))} ${relative}`);
}
await writeFile(destination, `${lines.join('\n')}\n`);
}
async function verifyChecksums(directory, checksumFile) {
const lines = (await readFile(checksumFile, 'utf8')).trim().split('\n');
for (const line of lines) {
const found = line.match(/^([0-9a-f]{64}) {2}(.+)$/);
if (!found) throw new Error(`Invalid checksum line: ${line}`);
const actual = await sha256(path.join(directory, found[2]));
if (actual !== found[1]) {
throw new Error(`Checksum mismatch for ${found[2]}.`);
}
}
}
async function copy(root, stage, source, destination) {
const target = path.join(stage, destination);
await mkdir(path.dirname(target), { recursive: true });
await copyFile(path.join(root, source), target);
return target;
}
function launcher(jar) {
return `#!/usr/bin/env sh
set -eu
base="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
config="$base/config/application.yml"
if [ -f "$config" ] && [ -z "\${SPRING_CONFIG_ADDITIONAL_LOCATION:-}" ]; then
SPRING_CONFIG_ADDITIONAL_LOCATION="file:\${config}"
export SPRING_CONFIG_ADDITIONAL_LOCATION
fi
exec java -jar "$base/lib/${jar}" "$@"
`;
}
async function createDeterministicArchive(output, rootName, stage, archive) {
const files = await listFiles(stage);
const timestamp = new Date(0);
for (const relative of files) {
const file = path.join(stage, relative);
await chmod(file, relative.startsWith('bin/') ? 0o755 : 0o644);
await utimes(file, timestamp, timestamp);
}
const tarVersion = execFileSync('tar', ['--version'], { encoding: 'utf8' });
const ownershipArguments = tarVersion.toLowerCase().includes('bsdtar')
? ['--uid', '0', '--gid', '0', '--uname', 'root', '--gname', 'root']
: ['--owner=0', '--group=0'];
const tarFile = `${archive}.tmp`;
try {
execFileSync(
'tar',
[
'-c',
'--format=ustar',
...ownershipArguments,
'-f',
tarFile,
'-C',
output,
...files.map((file) => path.posix.join(rootName, file)),
],
{ stdio: 'inherit' },
);
await pipeline(
createReadStream(tarFile),
createGzip({ level: 9, mtime: 0 }),
createWriteStream(archive),
);
} finally {
await rm(tarFile, { force: true });
}
}
export async function createReferenceDistribution({
repoRoot,
outputDirectory,
version,
sourceRevision,
}) {
if (!VERSION_PATTERN.test(version)) {
throw new Error(
'Version must be an experimental SemVer with a prerelease.',
);
}
if (!REVISION_PATTERN.test(sourceRevision)) {
throw new Error(
'Source revision must be a lowercase 40-character Git SHA.',
);
}
const root = path.resolve(repoRoot);
const output = path.resolve(outputDirectory);
const rootName = `${DISTRIBUTION_NAME}-${version}`;
const stage = path.join(output, rootName);
const archive = path.join(output, `${rootName}.tar.gz`);
const detachedManifest = path.join(output, `${rootName}.manifest.json`);
const detachedChecksums = path.join(output, 'SHA256SUMS.txt');
await mkdir(output, { recursive: true });
await Promise.all([
rm(stage, { recursive: true, force: true }),
rm(archive, { force: true }),
rm(detachedManifest, { force: true }),
rm(detachedChecksums, { force: true }),
]);
await mkdir(stage, { recursive: true });
const serverPom = path.join(root, 'packages/managed-agent-server/pom.xml');
const serverVersion = await pomVersion(
serverPom,
'qwen-managed-agent-server',
);
const packageJson = JSON.parse(
await readFile(path.join(root, 'package.json')),
);
const publicApi = documentVersion(
await readFile(path.join(root, CONTRACT_DOCUMENT), 'utf8'),
);
if (publicApi.error) {
throw new Error(`${CONTRACT_DOCUMENT} ${publicApi.error}.`);
}
const capabilities = await readFile(
path.join(root, 'packages/cli/src/serve/capabilities.ts'),
'utf8',
);
const brokerProvider = await readFile(
path.join(
root,
'packages/cli/src/serve/broker-managed-runtime-provider.ts',
),
'utf8',
);
const applicationConfiguration = await readFile(
path.join(
root,
'packages/managed-agent-server/src/main/resources/application.yml',
),
'utf8',
);
const hostedHarnessContract = await readFile(
path.join(root, 'packages/cli/src/serve/hosted-harness-contract.ts'),
'utf8',
);
const serverPomText = await readFile(serverPom, 'utf8');
const schema = migrationRange(root);
const publicShellDefault = uniqueMatch(
applicationConfiguration,
/^[ \t]*workspace-shell-enabled:[ \t]*'\$\{QWEN_MANAGED_AGENT_WORKSPACE_SHELL_ENABLED:([^}\r\n]+)\}'[ \t]*$/gm,
'public Shell default',
);
if (publicShellDefault !== 'false') {
throw new Error('Public Shell must default to false.');
}
const lifecycleV2ProfilesDefault = uniqueMatch(
withoutComments(hostedHarnessContract),
/^[ \t]*process\.env\[HOSTED_LIFECYCLE_V2_PROFILES_ENV\][ \t]*\?\?[ \t]*'([^'\r\n]*)'[ \t]*$/gm,
'Hosted lifecycle v2 profile default',
);
if (lifecycleV2ProfilesDefault !== '') {
throw new Error('Hosted lifecycle v2 profiles must default to empty.');
}
const serverJar = `qwen-managed-agent-server-${serverVersion}.jar`;
const recoveryJar = `qwen-managed-agent-server-${serverVersion}-operator-recovery.jar`;
const componentSources = [
{
name: 'qwen-managed-agent-server',
version: serverVersion,
source: `packages/managed-agent-server/target/${serverJar}`,
destination: `lib/${serverJar}`,
role: 'control-plane',
},
{
name: 'qwen-managed-agent-operator-recovery',
version: serverVersion,
source: `packages/managed-agent-server/target/${recoveryJar}`,
destination: `lib/${recoveryJar}`,
role: 'operator-recovery',
},
];
for (const component of componentSources) {
await copy(root, stage, component.source, component.destination);
}
await copy(
root,
stage,
'packages/managed-agent-server/distribution/config/application.example.yml',
'config/application.example.yml',
);
const systemdFiles = [
'qwen-hosted-harness.service',
'qwen-managed-agent-server.service',
'harness.env.example',
'server.env.example',
'README.md',
'README.zh-CN.md',
];
for (const file of systemdFiles) {
await copy(
root,
stage,
`packages/managed-agent-server/distribution/systemd/${file}`,
`systemd/${file}`,
);
}
for (const file of [
'qwen-managed-agent-bootstrap',
'qwen-managed-agent-bootstrap.mjs',
]) {
await copy(
root,
stage,
`packages/managed-agent-server/distribution/bin/${file}`,
`bin/${file}`,
);
}
const documentation = [
['docs/users/managed-agent-reference-deployment.md', 'docs/deployment.md'],
[
'docs/users/managed-agent-reference-deployment.zh-CN.md',
'docs/deployment.zh-CN.md',
],
['packages/managed-agent-server/README.md', 'docs/server-reference.md'],
[
'docs/users/hosted-workspace-recovery.md',
'docs/hosted-workspace-recovery.md',
],
[
'docs/users/hosted-workspace-recovery.zh-CN.md',
'docs/hosted-workspace-recovery.zh-CN.md',
],
['LICENSE', 'LICENSE'],
];
for (const [source, destination] of documentation) {
await copy(root, stage, source, destination);
}
const serverLauncher = path.join(stage, 'bin/qwen-managed-agent-server');
const recoveryLauncher = path.join(
stage,
'bin/qwen-managed-agent-operator-recovery',
);
await mkdir(path.dirname(serverLauncher), { recursive: true });
await Promise.all([
writeFile(serverLauncher, launcher(serverJar)),
writeFile(recoveryLauncher, launcher(recoveryJar)),
]);
await Promise.all([
chmod(serverLauncher, 0o755),
chmod(recoveryLauncher, 0o755),
]);
const components = [];
for (const component of componentSources) {
const file = path.join(stage, component.destination);
components.push({
name: component.name,
version: component.version,
role: component.role,
path: component.destination,
size: (await stat(file)).size,
sha256: await sha256(file),
});
}
const manifest = {
schemaVersion: 1,
name: DISTRIBUTION_NAME,
version,
status: 'experimental',
source: {
repository: 'https://github.com/QwenLM/qwen-code',
revision: sourceRevision,
},
components,
compatibility: {
qwenCodeVersion: packageJson.version,
hostedHarnessProfile: 'hosted-harness',
serveProtocolVersion: match(
capabilities,
/SERVE_PROTOCOL_VERSION = '([^']+)'/,
'Serve protocol version',
),
managedRuntimeBrokerProtocolVersion: Number(
match(
brokerProvider,
/MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION = (\d+)/,
'Runtime Broker protocol version',
),
),
managedAgentPublicApiVersion: publicApi.version,
java: `>=${match(
serverPomText,
/<java\.version>([^<]+)<\/java\.version>/,
'Java version',
)}`,
node: packageJson.engines.node,
databaseSchema: {
migrationTool: 'Flyway',
minimumMigrationVersion: schema.minimum,
latestMigrationVersion: schema.latest,
},
},
referenceTopology: {
operatingSystem: 'Linux',
javaControlPlanes: 1,
hostTrust: 'trusted-single-host',
runtimeBroker: 'embedded',
database: 'MySQL 8',
},
defaults: {
publicShellEnabled: publicShellDefault === 'true',
v2CapabilityAdvertisementEnabled: lifecycleV2ProfilesDefault !== '',
},
documentation: {
deployment: 'docs/deployment.md',
deploymentChinese: 'docs/deployment.zh-CN.md',
serverReference: 'docs/server-reference.md',
operatorRecovery: 'docs/hosted-workspace-recovery.md',
},
};
const manifestFile = path.join(stage, 'manifest.json');
await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
const payload = await listFiles(stage);
await writeChecksums(stage, payload, path.join(stage, 'SHA256SUMS'));
await verifyChecksums(stage, path.join(stage, 'SHA256SUMS'));
await copyFile(manifestFile, detachedManifest);
await createDeterministicArchive(output, rootName, stage, archive);
const entries = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' });
for (const required of [
`${rootName}/manifest.json`,
`${rootName}/SHA256SUMS`,
`${rootName}/bin/qwen-managed-agent-server`,
`${rootName}/bin/qwen-managed-agent-operator-recovery`,
`${rootName}/bin/qwen-managed-agent-bootstrap`,
`${rootName}/config/application.example.yml`,
...systemdFiles.map((file) => `${rootName}/systemd/${file}`),
]) {
if (!entries.split('\n').includes(required)) {
throw new Error(`Archive is missing ${required}.`);
}
}
await writeChecksums(
output,
[path.basename(archive), path.basename(detachedManifest)],
detachedChecksums,
);
await verifyChecksums(output, detachedChecksums);
await rm(stage, { recursive: true, force: true });
return { archive, detachedManifest, detachedChecksums, manifest };
}
async function main() {
const { values } = parseArgs({
options: {
version: { type: 'string' },
'source-revision': { type: 'string' },
output: { type: 'string' },
},
});
if (!values.version || !values['source-revision'] || !values.output) {
throw new Error(
'Usage: package-managed-agent-reference.mjs --version <experimental-semver> --source-revision <sha> --output <directory>',
);
}
const result = await createReferenceDistribution({
repoRoot: process.cwd(),
outputDirectory: values.output,
version: values.version,
sourceRevision: values['source-revision'],
});
console.log(result.archive);
console.log(result.detachedManifest);
console.log(result.detachedChecksums);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}