diff --git a/.dockerignore b/.dockerignore index 83154dfdb17..8af4f91511a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,8 @@ node_modules dist **/dist **/tsconfig.tsbuildinfo +target +**/target # Version control # Both forms: the release workflow checks its trusted scripts out into diff --git a/packages/sdk-java/managed-agent-server/Dockerfile b/packages/sdk-java/managed-agent-server/Dockerfile index 04323e027f5..815eee64fab 100644 --- a/packages/sdk-java/managed-agent-server/Dockerfile +++ b/packages/sdk-java/managed-agent-server/Dockerfile @@ -14,15 +14,52 @@ RUN mvn --batch-mode --no-transfer-progress \ -DskipTests install \ && mvn --batch-mode --no-transfer-progress \ -f packages/sdk-java/managed-agent-server/pom.xml \ - -DskipTests package + -DskipTests package \ + # The pom attaches classified repackage executions alongside the + # unclassified server jar, so a wildcard matches several artifacts and + # a pinned version drifts. Select + # the unclassified jar by exclusion and publish it under a fixed name; + # fail the build (rather than ship a bad image) unless exactly one + # exists. sh has no nullglob, so a zero-match glob yields the pattern + # itself — break on it so the count check below still fails, and name + # the expectation loudly instead of dying at cp. + && cd packages/sdk-java/managed-agent-server/target \ + && main="" && count=0 \ + && for f in qwen-managed-agent-server-*.jar; do \ + [ -e "$f" ] || break; \ + case "$f" in \ + *-workspace-bundle.jar|*-operator-recovery.jar|*-workspace-migration.jar) ;; \ + *) main="$f"; count=$((count+1)) ;; \ + esac; \ + done \ + && { [ "$count" -eq 1 ] || { \ + echo >&2 "expected exactly one unclassified server jar," \ + "found $count"; exit 1; \ + }; } \ + && cp "$main" /tmp/qwen-managed-agent-server.jar FROM eclipse-temurin:21-jre WORKDIR /app -COPY --from=build \ - /workspace/packages/sdk-java/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \ +COPY --from=build /tmp/qwen-managed-agent-server.jar \ /app/qwen-managed-agent-server.jar +# The image keeps the application.yml loopback defaults: under the shipped +# `auto` auth mode the server refuses a non-loopback bind, so a plain +# `docker run -p 8080:8080` must not — and cannot — publish it. Publishing +# is an explicit operator opt-in that names an authentication posture (see +# the README's container section): +# docker run -p 8080:8080 \ +# -e QWEN_MANAGED_AGENT_SERVER_ADDRESS=0.0.0.0 \ +# -e QWEN_MANAGED_AGENT_AUTH_MODE=signed \ +# -e QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY \ +# -e SPRING_DATASOURCE_URL='jdbc:mysql://:3306/qwen_managed_agent' \ +# -e SPRING_DATASOURCE_USERNAME='qwen' \ +# -e SPRING_DATASOURCE_PASSWORD \ +# +# or the deliberately unauthenticated +# QWEN_MANAGED_AGENT_AUTH_ALLOW_INSECURE_BIND=true override. + EXPOSE 8080 4182 USER 10001 ENTRYPOINT ["java", "-jar", "/app/qwen-managed-agent-server.jar"] diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md index 3b9de1d79d9..bf6c137fffe 100644 --- a/packages/sdk-java/managed-agent-server/README.md +++ b/packages/sdk-java/managed-agent-server/README.md @@ -416,8 +416,10 @@ npm run dev:managed-agent # Once per clone, and re-run after pulling changes to qwencode/runtime-broker (~12 s): mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install mvn -f packages/sdk-java/runtime-broker/pom.xml -DskipTests install -# One-time, on a fresh MySQL 8 (creates the database and user the URL names): -mysql -u root -e "CREATE DATABASE qwen_managed_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'qwen'@'localhost' IDENTIFIED BY 'replace-me'; CREATE USER 'qwen'@'127.0.0.1' IDENTIFIED BY 'replace-me'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'localhost'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'127.0.0.1';" +# One-time, on a fresh MySQL 8 (creates the database and user the URL names; +# the performance_schema SELECT covers Flyway's boot probe — without it the +# probe is denied (ERROR 1142) and startup stops at 'foreign_key_checks'): +mysql -u root -e "CREATE DATABASE qwen_managed_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'qwen'@'localhost' IDENTIFIED BY 'replace-me'; CREATE USER 'qwen'@'127.0.0.1' IDENTIFIED BY 'replace-me'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'localhost'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'127.0.0.1'; GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'localhost'; GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'127.0.0.1';" # (official MySQL images enable skip-name-resolve, so 'qwen'@'localhost' alone never # matches TCP clients; a containerized MySQL sees the gateway address — grant at # 'qwen'@'%' or the container-visible host instead) @@ -975,6 +977,61 @@ The stock image contains the Java control plane only. Use the static Runtime provisioner, or provide a derived image/mount with Node.js and the Qwen worker artifacts, before enabling the local-process provisioner in a container. +The image keeps the application's `127.0.0.1` defaults, so a plain +`docker run -p 8080:8080` publishes nothing: `-p` DNATs to the container's +bridge IP, and a loopback listener is unreachable through it. Publishing is +an explicit opt-in, and the server refuses a non-loopback bind under the +shipped `auto` auth mode, so the command must name an authentication +posture — signed mode, as the introduction recommends (see "Broker +authentication and writer credentials" above). The signing key and the +datasource password are passed valueless so Docker forwards them from the +shell environment — the `=` spelling would leave either secret in the +command's argv for the container's whole lifetime. A valueless `-e` +forwards the variable of the same name, so export +`QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY` and `SPRING_DATASOURCE_PASSWORD` before +the run. The datasource must also be +named: the Prerequisites' `127.0.0.1` default is the container itself from +inside, so the image cannot boot against it: + +```bash +docker run -p 8080:8080 \ + -e QWEN_MANAGED_AGENT_SERVER_ADDRESS=0.0.0.0 \ + -e QWEN_MANAGED_AGENT_AUTH_MODE=signed \ + -e QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY \ + -e SPRING_DATASOURCE_URL='jdbc:mysql://:3306/qwen_managed_agent' \ + -e SPRING_DATASOURCE_USERNAME='qwen' \ + -e SPRING_DATASOURCE_PASSWORD \ + +``` + +or the deliberately unauthenticated +`-e QWEN_MANAGED_AGENT_AUTH_ALLOW_INSECURE_BIND=true` override. + +The `qwen` user the recipe names needs the grants from the Full WebShell +entry's MySQL one-liner — including `SELECT` on +`performance_schema.user_variables_by_thread`, granted at the +container-visible host — or Flyway's boot probe is denied (`ERROR 1142`) +and startup stops. + +Publishing the API needs no Runtime Broker face: the broker ships disabled +by default, and a non-loopback broker bind is a separate opt-in — the broker +refuses one unless `QWEN_MANAGED_AGENT_RUNTIME_BROKER_ALLOW_NON_LOOPBACK=true` +is also set (see Broker deployment above). + +The opt-in makes the authentication consequence load-bearing. Under the +shipped default — `auto` resolving to `open` — there is no HTTP +authentication: tenancy is whatever `X-Qwen-Tenant-Id` says. Signed mode +HMAC-authenticates every request's tenant and actor headers; the +insecure-bind override publishes with request authentication off. Either way the tenant-scoped API is +exposed to anything that can route to the listener. That exposure does +not depend on publishing a port: on the default bridge network the API +also answers on the container's own bridge address, so every process on +the Docker host and every peer container on that bridge can reach it +with no `-p` at all. Run a published deployment behind an ingress that +authenticates the tenant before setting `X-Qwen-Tenant-Id`, map ports +only inside your own network policy, and attach the container only to +networks whose peers you trust. + ## Managed Session Store verification Unit and H2 contract tests run with the normal Maven test phase. `mvn verify` diff --git a/scripts/managed-agent-dev.js b/scripts/managed-agent-dev.js index 7fea8ebe046..8ee34b2e420 100644 --- a/scripts/managed-agent-dev.js +++ b/scripts/managed-agent-dev.js @@ -352,7 +352,7 @@ export function buildSpringRecipeLines({ springPs1Path, }) { const lines = [ - ` # one-time DB/user: mysql -u root -e "CREATE DATABASE qwen_managed_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'qwen'@'localhost' IDENTIFIED BY 'replace-me'; CREATE USER 'qwen'@'127.0.0.1' IDENTIFIED BY 'replace-me'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'localhost'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'127.0.0.1';"`, + ` # one-time DB/user: mysql -u root -e "CREATE DATABASE qwen_managed_agent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'qwen'@'localhost' IDENTIFIED BY 'replace-me'; CREATE USER 'qwen'@'127.0.0.1' IDENTIFIED BY 'replace-me'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'localhost'; GRANT ALL ON qwen_managed_agent.* TO 'qwen'@'127.0.0.1'; GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'localhost'; GRANT SELECT ON performance_schema.user_variables_by_thread TO 'qwen'@'127.0.0.1';"`, ` # (official MySQL images enable skip-name-resolve, so 'qwen'@'localhost' alone never matches TCP clients; a containerized MySQL sees the gateway address — grant at 'qwen'@'%' or the container-visible host instead)`, ' # once per clone, and re-run after pulling changes to qwencode/runtime-broker (~12 s):', ' # mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install', diff --git a/scripts/run-managed-agent-server-e2e.ts b/scripts/run-managed-agent-server-e2e.ts index d0d8f2f0aa3..7ab0d903e8e 100644 --- a/scripts/run-managed-agent-server-e2e.ts +++ b/scripts/run-managed-agent-server-e2e.ts @@ -12,6 +12,7 @@ import { import { createServer, type Server } from 'node:http'; import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; +import { inspect } from 'node:util'; import { fakeToolCall, startFakeOpenAIServer, @@ -105,13 +106,33 @@ if (!Number.isSafeInteger(runtimeDelayMs) || runtimeDelayMs < 0) { } const cliBundle = path.join(root, 'dist', 'cli.js'); -const springJar = path.join( +const springModule = path.join( root, 'packages', 'sdk-java', 'managed-agent-server', - 'target', - 'qwen-managed-agent-server-0.1.0-alpha.jar', +); +const springTarget = path.join(springModule, 'target'); +// The pom attaches classified repackage executions alongside the +// unclassified server jar, so a wildcard over target/ matches several +// artifacts. Read the project version from the +// pom and name the unclassified jar exactly — version-independent without +// assuming it is the only packaged artifact. The immediately +// following the module's own (not the block's). The +// capture itself is whitespace-tolerant, so a formatter wrapping or +// re-indenting the element cannot flow newlines into the jar path. +const pomXml = readFileSync(path.join(springModule, 'pom.xml'), 'utf8'); +const pomVersion = pomXml.match( + /qwen-managed-agent-server<\/artifactId>\s*\s*([^<]*?\S)\s*<\/version>/, +)?.[1]; +if (!pomVersion) { + throw new Error( + 'Could not read the project from managed-agent-server/pom.xml', + ); +} +const springJar = path.join( + springTarget, + `qwen-managed-agent-server-${pomVersion}.jar`, ); for (const required of [ cliBundle, @@ -336,10 +357,16 @@ function start( return registered; } +// One spelling for "has this child exited": five independent phrasings +// drifted into the pre-fix ESRCH that process.kill raised on a reaped pid. +function childExited(child: ChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + function processTreeExists(child: ChildProcess): boolean { if (child.pid === undefined) return false; if (process.platform === 'win32') { - return child.exitCode === null && child.signalCode === null; + return !childExited(child); } try { process.kill(-child.pid, 0); @@ -392,19 +419,17 @@ async function crashChild(child: ChildProcess, name: string): Promise { } async function crashProcess(child: ChildProcess, name: string): Promise { - if (child.pid === undefined || child.exitCode !== null) { + // A child killed BY signal has exitCode null but signalCode set; missing + // that branch kills a pid that no longer exists and throws raw ESRCH. + if (child.pid === undefined || childExited(child)) { throw new Error(`${name} exited before the crash was injected`); } process.kill(child.pid, 'SIGKILL'); const deadline = Date.now() + 5_000; - while ( - child.exitCode === null && - child.signalCode === null && - Date.now() < deadline - ) { + while (!childExited(child) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 50)); } - if (child.exitCode === null && child.signalCode === null) { + if (!childExited(child)) { throw new Error(`${name} survived SIGKILL`); } } @@ -542,33 +567,115 @@ async function startHeldExecutionStartProxy( async function waitUntil( name: string, - predicate: () => Promise | boolean, + predicate: (remainingMs: number) => Promise | boolean, timeoutMs: number, child?: Child, ): Promise { const deadline = Date.now() + timeoutMs; + let lastError: unknown; + let lastErrorPredatesStall = false; + // Identity, not text: matching the rendered message would let a reworded + // sentinel silently invert the error precedence in the catch below. + const stall = new Error(`${name} predicate stalled`); while (Date.now() < deadline) { if (receivedSignal) throw new Error(`Interrupted by ${receivedSignal}`); - if ( - child && - (child.child.exitCode !== null || child.child.signalCode !== null) - ) { + if (child && childExited(child.child)) { throw new Error(`${name} exited early\n${child.log()}`); } try { - if (await predicate()) return; - } catch { - // The dependency is still starting. + // Bound one iteration against the remaining deadline: a hung + // predicate must not outlive timeoutMs, and its last error must + // surface instead of vanishing into "did not become ready". unref the + // stall timer: a quick success must not keep the event loop (and this + // runner) alive until the discarded timeout fires. The race bounds + // only an asynchronous predicate — a synchronous one blocks the event + // loop, so the remaining budget is handed to the predicate for its + // own probe timeout. + const ready = await Promise.race([ + Promise.resolve().then(() => + predicate(Math.max(1, deadline - Date.now())), + ), + new Promise((_, reject) => { + // Floor the bound: the final iteration can start with a sliver of + // budget left, and a poll that would answer just past the deadline + // must resolve the wait, not be discarded and misreported as a + // wedged predicate. The clamp re-admits up to ~500ms of overshoot + // on a genuinely wedged poll — the same trade runMysql's probe + // timeout floor makes for the synchronous path. + const stallTimer = setTimeout( + () => reject(stall), + Math.max(500, deadline - Date.now()), + ); + stallTimer.unref(); + }), + ]); + if (ready) return; + // The predicate answered; an error from an earlier phase no longer + // describes the state the deadline found. + lastError = undefined; + } catch (error) { + // Prefer the cause an undici fetch rejection carries (its own message + // is "fetch failed"). A real predicate error wins over the synthetic + // stall; when the stall is what the deadline found, the older error + // is reported as predating it instead of posing as the final state. + if (error instanceof Error && error.cause instanceof Error) { + lastError = error.cause; + } else if (error !== stall) { + lastError = error; + } else if (lastError === undefined) { + lastError = error; + } else { + lastErrorPredatesStall = true; + } } await new Promise((resolve) => setTimeout(resolve, 100)); } - throw new Error(`${name} did not become ready\n${child?.log() ?? ''}`); + // A non-Error rejection (a status string, a library object) is still + // the last thing the predicate said: inspect it so the content survives — + // String() renders a plain object as "[object Object]". + let cause = ''; + if (lastError instanceof Error) { + cause = `: ${lastError.message}`; + } else if (lastError !== undefined) { + try { + cause = `: ${inspect(lastError)}`; + } catch { + cause = `: ${String(lastError)}`; + } + } + if (lastErrorPredatesStall) { + cause = `: predicate stalled; last error before the stall${cause}`; + } + throw new Error( + `${name} did not become ready${cause}\n${child?.log() ?? ''}`, + ); } -function runMysql(port: number, sql: string): string { +// MySQL reads $HOME/.mylogin.cnf even when option files are disabled, +// so a mysql_config_editor credential on a developer's machine would +// silently auth-connect to the scratch empty-password server — and an +// exported MYSQL_PWD (read as the password) or MYSQL_TEST_LOGIN_FILE +// (relocates .mylogin.cnf ahead of $HOME, past --no-defaults) does the +// same. Client invocations run against an isolated environment instead of +// inheriting the real one: an empty HOME under the runner's one scratch +// root, so the finally reclaims it and QWEN_MANAGED_E2E_KEEP_TMP=1 keeps +// it for inspection, with both credential variables stripped. +const mysqlClientHome = path.join(temporary, 'mysql-client-home'); +mkdirSync(mysqlClientHome, { recursive: true }); +const mysqlClientEnv: NodeJS.ProcessEnv = { + ...process.env, + HOME: mysqlClientHome, +}; +delete mysqlClientEnv['MYSQL_PWD']; +delete mysqlClientEnv['MYSQL_TEST_LOGIN_FILE']; + +function runMysql(port: number, sql: string, timeoutMs = 10_000): string { const result = spawnSync( mysql, [ + // First argument, honored only there: a developer's ~/.my.cnf client + // options must not auth-fail a scratch empty-password server. + '--no-defaults', '--protocol=tcp', '--host=127.0.0.1', `--port=${port}`, @@ -578,8 +685,24 @@ function runMysql(port: number, sql: string): string { '--execute', sql, ], - { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + { + encoding: 'utf8', + env: mysqlClientEnv, + // waitUntil hands the lease polls its remaining budget, and the final + // iteration can shrink it below one client round trip; floor the probe + // timeout so the last poll is never handed less than it can cost. The + // clamp re-admits up to ~2s of overshoot per wedged poll, against the + // ~10s the budget threading set out to remove. + timeout: Math.max(2_000, timeoutMs), + maxBuffer: 16 * 1024 * 1024, + }, ); + // A timed-out spawn sets error and leaves status null with empty stderr: + // surface the spawn-level reason, or a wedge reads as a bare + // "MySQL command failed: ". + if (result.error) { + throw new Error(`MySQL command failed: ${result.error.message}`); + } if (result.status !== 0) { throw new Error(`MySQL command failed: ${result.stderr}`); } @@ -801,6 +924,13 @@ let heldStartProxy: HeldExecutionStartProxy | undefined; let replacementBrokerProxy: HeldExecutionStartProxy | undefined; let failure: unknown; let dumpPort: number | undefined; +// The success JSON, assembled inside the try and printed only after +// the post-finally throws, so an interrupt can never put a clean pass +// on stdout for a run the operator explicitly stopped. +let resultJson: string | undefined; +// The freeze arm's wake record reads as a pass line too, so it defers the +// same way and prints after the main result, as in the base's ordering. +let fencedWriterJson: string | undefined; try { const harnessToken = randomBytes(24).toString('base64url'); const brokerToken = randomBytes(24).toString('base64url'); @@ -929,17 +1059,24 @@ try { ); await waitUntil( 'MySQL', + // spawnSync blocks the event loop, so the race deadline cannot fire + // inside one iteration: the probe carries its own timeout. () => spawnSync( mysqladmin, [ + '--no-defaults', '--protocol=tcp', '--host=127.0.0.1', `--port=${mysqlPort}`, '--user=root', 'ping', ], - { stdio: 'ignore' }, + { + stdio: 'ignore', + env: mysqlClientEnv, + timeout: 10_000, + }, ).status === 0, 60_000, mysqlServer, @@ -1379,20 +1516,22 @@ try { } await waitUntil( 'Managed Session writer lease expiry', - () => + (remainingMs) => runMysql( mysqlPort, `SELECT IF(writer_lease_until IS NULL OR writer_lease_until < CURRENT_TIMESTAMP(6), 1, 0) FROM qwen_managed_agent.qwen_managed_session_journal_head WHERE ${sessionFilter}`, + remainingMs, ) === '1', 10_000, ); if (inflightFailover || continuationFailover) { await waitUntil( 'Managed Turn dispatch lease expiry', - () => + (remainingMs) => runMysql( mysqlPort, `SELECT IF(dispatch_lease_until IS NULL OR dispatch_lease_until < UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000, 1, 0) FROM qwen_managed_agent.managed_agent_turn WHERE ${sessionFilter}`, + remainingMs, ) === '1', 10_000, ); @@ -1649,26 +1788,24 @@ try { ); } - console.log( - JSON.stringify( - { - sessionId: session.id, - executionCallId: originalExecutionCallId, - executionState: recoveredExecution[1], - dispatchGeneration: Number(recoveredExecution[2]), - firstHarnessBootId: firstBootId, - replacementHarnessBootId: replacementBootId, - writerGeneration: `${firstHead[0]} -> ${replacementHead[0]}`, - journalRevision: `${firstHead[1]} -> ${replacementHead[1]}`, - committedSequence: `${firstHead[2]} -> ${replacementHead[2]}`, - promptReplayed: false, - physicalToolExecutions: 1, - terminalTurns: terminalCount, - oldHarnessDiskDeleted: !existsSync(harnessHome), - }, - null, - 2, - ), + resultJson = JSON.stringify( + { + sessionId: session.id, + executionCallId: originalExecutionCallId, + executionState: recoveredExecution[1], + dispatchGeneration: Number(recoveredExecution[2]), + firstHarnessBootId: firstBootId, + replacementHarnessBootId: replacementBootId, + writerGeneration: `${firstHead[0]} -> ${replacementHead[0]}`, + journalRevision: `${firstHead[1]} -> ${replacementHead[1]}`, + committedSequence: `${firstHead[2]} -> ${replacementHead[2]}`, + promptReplayed: false, + physicalToolExecutions: 1, + terminalTurns: terminalCount, + oldHarnessDiskDeleted: !existsSync(harnessHome), + }, + null, + 2, ); } else if (continuationFailover) { await waitForTerminal( @@ -1758,26 +1895,24 @@ try { `Continuation failover audit failed: terminal=${recoveredTerminal?.type ?? 'missing'} text=${JSON.stringify(visibleText)} deltas=${JSON.stringify(textDeltas)} execution=${recoveredExecution.join(',')} rows=${executionCount} boot=${firstBootId}->${replacementBootId} terminals=${terminalCount} model=${initialModelRequests.length}+${continuationRequests.length} sideEffect=${JSON.stringify(sideEffectBytes)}`, ); } - console.log( - JSON.stringify( - { - sessionId: session.id, - executionCallId: originalExecutionCallId, - firstHarnessBootId: firstBootId, - replacementHarnessBootId: replacementBootId, - promptReplayed: initialModelRequests.length !== 1, - physicalToolExecutions: executionCount, - continuationModelRequests: continuationRequests.length, - visibleText, - terminalTurns: terminalCount, - // The frozen-owner arm keeps the home on purpose (the wake - // needs it), so one key cannot mean both arms' intent. - oldHarnessDiskDeleted: !freeze && !existsSync(harnessHome), - harnessHomeRetainedForWake: freeze && existsSync(harnessHome), - }, - null, - 2, - ), + resultJson = JSON.stringify( + { + sessionId: session.id, + executionCallId: originalExecutionCallId, + firstHarnessBootId: firstBootId, + replacementHarnessBootId: replacementBootId, + promptReplayed: initialModelRequests.length !== 1, + physicalToolExecutions: executionCount, + continuationModelRequests: continuationRequests.length, + visibleText, + terminalTurns: terminalCount, + // The frozen-owner arm keeps the home on purpose (the wake + // needs it), so one key cannot mean both arms' intent. + oldHarnessDiskDeleted: !freeze && !existsSync(harnessHome), + harnessHomeRetainedForWake: freeze && existsSync(harnessHome), + }, + null, + 2, ); if (freeze) { @@ -1892,18 +2027,16 @@ try { `Frozen former Harness mutated the takeover after waking: head=${headBeforeWake}->${headAfterWake} oldWriterTx=${oldGenerationTxBeforeWake}->${oldGenerationTxAfterWake} boot=${replacementBootId}->${bootAfterWake} text=${JSON.stringify(visibleText)}->${JSON.stringify(awakeText)} terminals=${terminalCount}->${awakeTerminalCount}`, ); } - console.log( - JSON.stringify( - { - fencedFormerWriter: true, - journalHead: headAfterWake, - oldGenerationTx: oldGenerationTxAfterWake, - harnessBootId: bootAfterWake, - visibleText: awakeText, - }, - null, - 2, - ), + fencedWriterJson = JSON.stringify( + { + fencedFormerWriter: true, + journalHead: headAfterWake, + oldGenerationTx: oldGenerationTxAfterWake, + harnessBootId: bootAfterWake, + visibleText: awakeText, + }, + null, + 2, ); } } else { @@ -2010,30 +2143,28 @@ try { ); } - console.log( - JSON.stringify( - { - sessionId: session.id, - firstHarnessBootId: firstBootId, - replacementHarnessBootId: secondBootId, - writerGeneration: `${firstHead[0]} -> ${secondHead[0]}`, - journalRevision: `${firstHead[1]} -> ${secondHead[1]}`, - committedSequence: `${firstHead[2]} -> ${secondHead[2]}`, - terminalTurns: terminalCount, - ...(bigOutput - ? { - expectedCharacters: failoverFirstResponse.length, - expectedUtf8Bytes: Buffer.byteLength(failoverFirstResponse), - shortControlCharacters: failoverSecondResponse.length, - fullTextPreserved: true, - } - : {}), - restoredFirstTurnContext: true, - oldHarnessDiskDeleted: !existsSync(harnessHome), - }, - null, - 2, - ), + resultJson = JSON.stringify( + { + sessionId: session.id, + firstHarnessBootId: firstBootId, + replacementHarnessBootId: secondBootId, + writerGeneration: `${firstHead[0]} -> ${secondHead[0]}`, + journalRevision: `${firstHead[1]} -> ${secondHead[1]}`, + committedSequence: `${firstHead[2]} -> ${secondHead[2]}`, + terminalTurns: terminalCount, + ...(bigOutput + ? { + expectedCharacters: failoverFirstResponse.length, + expectedUtf8Bytes: Buffer.byteLength(failoverFirstResponse), + shortControlCharacters: failoverSecondResponse.length, + fullTextPreserved: true, + } + : {}), + restoredFirstTurnContext: true, + oldHarnessDiskDeleted: !existsSync(harnessHome), + }, + null, + 2, ); } } else { @@ -2192,25 +2323,23 @@ try { throw new Error(`Durable event audit failed: ${durable.join(',')}`); } - console.log( - JSON.stringify( - { - model, - sessionId: session.id, - createAdmissionMs: createdAt - requestStartedAt, - firstModelEventMs: firstModel.observedAt - requestStartedAt, - runtimeReadyMs: runtimeReady.observedAt - requestStartedAt, - terminalMs: terminal.observedAt - requestStartedAt, - modelBeforeRuntimeReady: - firstModel.event.sequence < runtimeReady.event.sequence, - toolSideEffect: true, - idempotentReplay: true, - crossTenantStatus: crossTenant.status, - durableEventCount: Number(durable[0]), - }, - null, - 2, - ), + resultJson = JSON.stringify( + { + model, + sessionId: session.id, + createAdmissionMs: createdAt - requestStartedAt, + firstModelEventMs: firstModel.observedAt - requestStartedAt, + runtimeReadyMs: runtimeReady.observedAt - requestStartedAt, + terminalMs: terminal.observedAt - requestStartedAt, + modelBeforeRuntimeReady: + firstModel.event.sequence < runtimeReady.event.sequence, + toolSideEffect: true, + idempotentReplay: true, + crossTenantStatus: crossTenant.status, + durableEventCount: Number(durable[0]), + }, + null, + 2, ); } } catch (error) { @@ -2265,7 +2394,13 @@ try { await replacementBrokerProxy?.close(); releaseContinuationHold(); await fake?.close(); - if (failure && process.env['QWEN_MANAGED_E2E_KEEP_TMP'] === '1') { + // An interrupt during teardown is a non-pass: the keep switch must still + // hold, or a stopped run deletes its own evidence before the post-finally + // throw reports it. + if ( + (failure || receivedSignal) && + process.env['QWEN_MANAGED_E2E_KEEP_TMP'] === '1' + ) { console.error(`Keeping temporary directory: ${temporary}`); } else { rmSync(temporary, { recursive: true, force: true }); @@ -2275,3 +2410,13 @@ try { } if (failure) throw failure; +// An interrupt after the last poll — or during the finally's teardown — +// must not print the success JSON and exit 0: a supervising script would +// record a clean pass for a run the operator explicitly stopped. The +// payload stays a variable until both guards have run, so the decision +// precedes the record. +if (receivedSignal) throw new Error(`Interrupted by ${receivedSignal}`); +if (resultJson !== undefined) { + console.log(resultJson); + if (fencedWriterJson !== undefined) console.log(fencedWriterJson); +} diff --git a/scripts/tests/managed-agent-dev.test.js b/scripts/tests/managed-agent-dev.test.js index 7d3ad1c059a..a6e052586e9 100644 --- a/scripts/tests/managed-agent-dev.test.js +++ b/scripts/tests/managed-agent-dev.test.js @@ -1577,6 +1577,27 @@ describe('buildSpringRecipeLines', () => { ); } }); + + it('prints the README MySQL one-liner verbatim, grants included', () => { + const readme = readFileSync( + path.join( + root, + 'packages', + 'sdk-java', + 'managed-agent-server', + 'README.md', + ), + 'utf8', + ); + const oneLiner = readme.match(/mysql -u root -e "[^"]+"/)?.[0]; + expect(oneLiner).toBeTruthy(); + const joined = buildSpringRecipeLines({ + isWinPlatform: false, + springEnvPath: envPath, + springPs1Path: ps1Path, + }).join('\n'); + expect(joined).toContain(oneLiner); + }); }); describe('script entry', () => { diff --git a/scripts/tests/managed-agent-server-e2e.test.js b/scripts/tests/managed-agent-server-e2e.test.js index 9bd8d0157a1..26f3ce8386b 100644 --- a/scripts/tests/managed-agent-server-e2e.test.js +++ b/scripts/tests/managed-agent-server-e2e.test.js @@ -4,9 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { existsSync, readFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inspect } from 'node:util'; import { createSourceFile, + isArrayLiteralExpression, + isCallExpression, isFunctionDeclaration, isVariableStatement, ScriptTarget, @@ -39,31 +51,101 @@ const fencedShellBlocks = (text) => ].map((match) => match[1]); describe('managed-agent-server e2e runner', () => { - it('keeps service and proxy ports distinct when an ephemeral port repeats', async () => { + const extracted = (names) => { const source = createSourceFile( 'runner.ts', read('scripts/run-managed-agent-server-e2e.ts'), ScriptTarget.Latest, true, ); - const allocation = source.statements + const text = source.statements .filter( (node) => - (isFunctionDeclaration(node) && - ['freePort', 'startHeldExecutionStartProxy'].includes( - node.name?.text, - )) || + (isFunctionDeclaration(node) && names.includes(node.name?.text)) || (isVariableStatement(node) && - node.declarationList.declarations.some( - (declaration) => - declaration.name.getText(source) === 'allocatedPorts', + node.declarationList.declarations.some((declaration) => + names.includes(declaration.name.getText(source)), )), ) .map((node) => node.getText(source)) .join('\n'); - const { outputText } = transpileModule(allocation, { + return transpileModule(text, { compilerOptions: { target: ScriptTarget.ES2022 }, - }); + }).outputText; + }; + + // waitUntil calls childExited, so its extraction list must name the + // predicate: a free identifier in generated code resolves against the + // global scope, and the early-exit branch would die with a ReferenceError + // that points at the runner instead of at this harness's extraction list. + // One inventory with one loader — pasted copies drift, and the drift reds + // a test whose author did not touch it while the other copies stay + // silently stale. + const WAIT_UNTIL_DEPS = ['waitUntil', 'receivedSignal', 'childExited']; + const load = (names, returns, ...params) => + new Function(...params, `${extracted(names)}\nreturn { ${returns} };`); + // The interrupt setter closes over the extracted body's own + // receivedSignal: without it the binding is permanently undefined and no + // test can reach the poll-head interrupt early-exit. + const loadWaitUntil = () => + load( + WAIT_UNTIL_DEPS, + 'waitUntil, interrupt: (signal) => { receivedSignal = signal }', + 'inspect', + )(inspect); + + // Source-level MySQL launch sites, selected on the launched binary + // rather than the callee or a hand-listed identifier: a launch through + // any spawner, with the binary spelled as a bare identifier or any quoted + // literal, is inventoried the same way. The binary names come from the + // runner's own command() resolutions, so a future mysql* binary joins the + // inventory with its declaration. + const mysqlCalls = (sourceText) => { + const binaries = new Set(); + for (const match of sourceText.matchAll( + /const (\w+) = command\('([^']+)'\)/g, + )) { + if (match[2].startsWith('mysql')) { + binaries.add(match[1]).add(match[2]); + } + } + const ast = createSourceFile( + 'runner.ts', + sourceText, + ScriptTarget.Latest, + true, + ); + const calls = []; + const visit = (node) => { + if (isCallExpression(node) && node.arguments.length > 0) { + // command('mysql…') resolves a binary path; it is not a launch. + if (node.expression.getText(ast) === 'command') { + node.forEachChild(visit); + return; + } + // getText returns a string literal with its quotes, so compare the + // unquoted spelling: the quote style must not hide a launch. + const binary = node.arguments[0] + .getText(ast) + .replace(/^['"`]|['"`]$/g, ''); + if (binaries.has(binary)) { + const args = node.arguments[1]; + const firstArg = + args !== undefined && + isArrayLiteralExpression(args) && + args.elements.length > 0 + ? args.elements[0].getText(ast) + : undefined; + calls.push({ binary, text: node.getText(ast), firstArg }); + } + } + node.forEachChild(visit); + }; + visit(ast); + return { calls, binaries }; + }; + + it('keeps service and proxy ports distinct when an ephemeral port repeats', async () => { const sequence = [ 33061, 33231, 36301, 36302, 36301, 36303, 38943, 36417, 36417, 36418, 36417, 36418, 36419, @@ -84,9 +166,10 @@ describe('managed-agent-server e2e runner', () => { done?.(); }, }); - const { freePort, startHeldExecutionStartProxy } = new Function( + const { freePort, startHeldExecutionStartProxy } = load( + ['freePort', 'startHeldExecutionStartProxy', 'allocatedPorts'], + 'freePort, startHeldExecutionStartProxy', 'createServer', - `${outputText}\nreturn { freePort, startHeldExecutionStartProxy };`, )(createServer); const ports = []; for (const count of [4, 3]) { @@ -496,4 +579,693 @@ describe('managed-agent-server e2e runner', () => { '`managed_session_store_conflict` fence is target design', ); }); + + it('waitUntil surfaces the last predicate error', async () => { + const { waitUntil } = loadWaitUntil(); + await expect( + waitUntil( + 'probe', + () => Promise.reject(new Error('HTTP 503 wedged')), + 300, + ), + ).rejects.toThrow(/wedged/); + }); + + // A non-Error rejection is recorded on every poll; rendering only Error + // instances would drop the one diagnostic the budget produced. + it('waitUntil surfaces a non-Error predicate rejection', async () => { + const { waitUntil } = loadWaitUntil(); + // An object rejection discriminates inspect from String: the latter + // renders "[object Object]" and drops the one diagnostic the poll + // produced. + await expect( + waitUntil('probe', () => Promise.reject({ status: 503 }), 300), + ).rejects.toThrow(/503/); + // The harness injects its own inspect as a parameter, so the runner's + // binding is pinned by text: without the import, the free identifier + // resolves to Node's deprecated global inspect in production. + expect(read('scripts/run-managed-agent-server-e2e.ts')).toMatch( + /import \{ inspect \} from 'node:util'/, + ); + }); + + it('exercises the poll-head interrupt early-exit through the setter', async () => { + const { waitUntil, interrupt } = loadWaitUntil(); + interrupt('SIGINT'); + await expect(waitUntil('probe', () => false, 300)).rejects.toThrow( + /Interrupted by SIGINT/, + ); + }); + + // Positive control: with no signal set the same poll fails by deadline, + // so the setter — not a spurious initial value — drives the exit above. + it('does not throw before the signal is set (positive control)', async () => { + const { waitUntil } = loadWaitUntil(); + await expect(waitUntil('probe', () => false, 300)).rejects.toThrow( + /did not become ready/, + ); + }); + + it('waitUntil bounds a stalled iteration by the deadline', async () => { + const { waitUntil } = loadWaitUntil(); + const started = Date.now(); + await expect( + waitUntil('probe', () => new Promise(() => {}), 300), + ).rejects.toThrow(/predicate stalled/); + // A hung predicate used to outlive timeoutMs several-fold. + expect(Date.now() - started).toBeLessThan(2_000); + }); + + // The mirror of the stall bound: a poll already in flight at the deadline + // must be allowed to answer. The final iteration can start with a sliver + // of budget left, and a dependency that becomes ready inside that sliver + // is ready — discarding its answer misreports it as a wedged predicate. + it('waitUntil lets the final in-flight poll answer past the deadline', async () => { + const { waitUntil } = loadWaitUntil(); + let polls = 0; + await expect( + waitUntil( + 'probe', + async () => { + // Key the transition on the poll count, not the clock: a + // clock-keyed predicate must outwait one interval before it can + // answer true, leaving the loop's re-entry a ~30ms sliver that + // ordinary CI timer lateness eats. The instant first decline + // re-enters with ~150ms of budget, and this poll's fixed sleep + // still answers past the 250ms deadline. + if (polls++ === 0) return false; + await new Promise((resolve) => setTimeout(resolve, 300)); + return true; + }, + 250, + ), + ).resolves.toBeUndefined(); + }); + + it('waitUntil reports a child that exited early', async () => { + const { waitUntil } = loadWaitUntil(); + await expect( + waitUntil('probe', () => new Promise(() => {}), 300, { + child: { exitCode: 1, signalCode: null }, + log: () => '', + }), + ).rejects.toThrow(/exited early/); + }); + + // A child killed BY signal (OOM-kill, segfault) keeps exitCode null and + // sets signalCode: an exitCode-only early-exit guard never trips, the + // poll burns its whole site budget, and the failure reads "did not + // become ready" — pointing at the dependency instead of the dead child. + it('waitUntil reports a child that died by signal as exited early', async () => { + const { waitUntil } = loadWaitUntil(); + await expect( + waitUntil('probe', () => false, 300, { + child: { exitCode: null, signalCode: 'SIGKILL' }, + log: () => '', + }), + ).rejects.toThrow(/exited early/); + }); + + // A rejecting-then-hanging predicate must keep both facts: the deadline + // found the hang, so the older real error is named as predating the stall + // rather than posing as the state the deadline saw. + it('waitUntil keeps a real predicate error when a later iteration stalls', async () => { + const { waitUntil } = loadWaitUntil(); + let calls = 0; + const failure = await waitUntil( + 'probe', + () => + calls++ === 0 + ? Promise.reject( + new Error('fetch failed', { cause: new Error('ECONNREFUSED') }), + ) + : new Promise(() => {}), + 300, + ).catch((error) => error); + expect(failure.message).toContain( + 'predicate stalled; last error before the stall: ECONNREFUSED', + ); + }); + + // The stall wording is earned by a stalled final poll: a predicate that + // answers every poll with the same real error must not acquire it. + it('waitUntil names no stall when the error is the deadline state', async () => { + const { waitUntil } = loadWaitUntil(); + const failure = await waitUntil( + 'probe', + () => + Promise.reject( + new Error('fetch failed', { cause: new Error('ECONNREFUSED') }), + ), + 300, + ).catch((error) => error); + expect(failure.message).toContain('ECONNREFUSED'); + expect(failure.message).not.toContain('predicate stalled'); + }); + + // The mirror: once the predicate answers falsy, a connectivity error from + // an earlier phase no longer describes the state the deadline found. + it('waitUntil drops an error that later answered iterations supersede', async () => { + const { waitUntil } = loadWaitUntil(); + let calls = 0; + const failure = await waitUntil( + 'probe', + () => + calls++ === 0 + ? Promise.reject( + new Error('fetch failed', { cause: new Error('ECONNREFUSED') }), + ) + : false, + 300, + ).catch((error) => error); + expect(failure.message).toContain('did not become ready'); + expect(failure.message).not.toContain('ECONNREFUSED'); + }); + + // spawnSync blocks the event loop, so the stall race cannot bound a + // synchronous predicate: the remaining budget is handed to the predicate + // for its own probe timeout, and wall time must stay near the budget + // rather than near budget + a site-local probe timeout. + it('waitUntil threads the remaining budget into a synchronous predicate', async () => { + const { waitUntil } = loadWaitUntil(); + const started = Date.now(); + await expect( + waitUntil( + 'probe', + (remainingMs) => + spawnSync(process.execPath, ['-e', 'setTimeout(() => {}, 5_000)'], { + timeout: remainingMs, + }).status === 0, + 300, + ), + ).rejects.toThrow(/did not become ready/); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it('crashProcess reports a by-signal exit instead of throwing ESRCH', async () => { + const { crashProcess } = load( + ['crashProcess', 'childExited'], + 'crashProcess', + 'process', + )(process); + const child = spawn('node', [ + '-e', + 'setTimeout(() => process.kill(process.pid, "SIGKILL"), 20)', + ]); + // Let the by-signal death land first: guard reads signalCode, not + // exitCode, and must refuse with the descriptive pre-exit diagnostic. + await new Promise((resolve) => child.once('exit', resolve)); + await expect(crashProcess(child, 'probe')).rejects.toThrow( + /exited before the crash was injected/, + ); + }); + + // The pre-crash guard is pinned above; the poll loop and the + // survived-SIGKILL throw must read a by-signal death too: an exitCode-only + // poll burns the 5 s deadline on a child SIGKILL already reaped, and an + // exitCode-only throw reports that dead child as having survived. + it('crashProcess returns promptly once its SIGKILL lands', async () => { + const { crashProcess } = load( + ['crashProcess', 'childExited'], + 'crashProcess', + 'process', + )(process); + const child = spawn('node', ['-e', 'setTimeout(() => {}, 30_000)']); + const started = Date.now(); + await expect(crashProcess(child, 'probe')).resolves.toBeUndefined(); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + // The success document must not reach stdout for a run the operator + // stopped: every payload is assembled inside the try and printed only + // after both post-finally throws, so the guard decides before the record + // exists. A print inside the try would leave the finally's teardown — + // seconds of stopChild awaits with the handlers still attached — + // unguarded. + it('checks the received signal at the success exit too', () => { + const source = read('scripts/run-managed-agent-server-e2e.ts'); + // The base's eager prints were multi-line calls, so a single-line + // negative pin is true of base and head alike and can never fire: pin + // the deferred assignment on every mode branch, and that no success + // payload is printed eagerly. The freeze arm's wake record reads as a + // pass line on stdout too, so it defers the same way — no eager + // console.log of a JSON payload remains inside the try at all. + expect(source.match(/resultJson = JSON\.stringify\(/g)).toHaveLength(4); + expect(source.match(/fencedWriterJson = JSON\.stringify\(/g)).toHaveLength( + 1, + ); + expect(source).not.toMatch(/console\.log\(\s*JSON\.stringify\(/); + // No gate typechecks scripts/ (tsx strips types unchecked), so the + // module-scope binding the deferred print reads is pinned by text: + // dropped, the print below throws ReferenceError on the success path. + expect(source).toMatch(/^let resultJson: string \| undefined;$/m); + expect(source).toMatch(/^let fencedWriterJson: string \| undefined;$/m); + // The behavioural tests inject the signal through this harness's + // synthesized setter, so the production link — the runner's own + // handleSignal assigning the module-scope receivedSignal the guards + // below read — is pinned by text: registered with an empty body, the + // handlers swallow Ctrl-C and a CI cancellation, and the run prints + // the success payload and exits 0. Pin the process.on pair only; the + // finally removes both listeners during teardown. + expect(source).toMatch( + /const handleSignal = \(signal: NodeJS\.Signals\) => \{\s*receivedSignal = signal;\s*\};/, + ); + expect(source).toMatch(/process\.on\('SIGINT', handleSignal\);/); + expect(source).toMatch(/process\.on\('SIGTERM', handleSignal\);/); + // An interrupted run is a non-pass: the finally's keep decision must + // still honor the keep switch, or a stopped run deletes its own + // evidence before the signal throw below reports it. + expect(source).toMatch( + /\(failure \|\| receivedSignal\) &&\s*process\.env\['QWEN_MANAGED_E2E_KEEP_TMP'\]/, + ); + const failureThrow = source.indexOf('if (failure) throw failure;'); + const signalThrow = source.indexOf( + 'if (receivedSignal) throw new Error', + failureThrow, + ); + const print = source.indexOf('console.log(resultJson)'); + expect(failureThrow).toBeGreaterThan(-1); + expect(signalThrow).toBeGreaterThan(failureThrow); + expect( + print, + 'the success payload must print only after both exit guards', + ).toBeGreaterThan(signalThrow); + // The freeze arm's wake record follows the main result, restoring the + // base's ordering, and sits behind the same two guards. + const wakePrint = source.indexOf('console.log(fencedWriterJson)'); + expect( + wakePrint, + 'the freeze wake record must print only after the main result', + ).toBeGreaterThan(print); + }); + + // A timed-out spawn sets error and leaves status null with empty stderr, + // so a status-only check throws "MySQL command failed: " and the one + // diagnostic a wedged durable-state dump exists to produce is lost. + it('runMysql surfaces the spawn-level reason on a timeout', () => { + const spawns = []; + const { runMysql } = load( + ['runMysql'], + 'runMysql', + 'spawnSync', + 'mysql', + 'mysqlClientEnv', + )( + (...args) => { + spawns.push(args); + return { + status: null, + signal: 'SIGTERM', + stderr: '', + error: new Error('spawnSync mysql ETIMEDOUT'), + }; + }, + 'mysql', + { HOME: '/tmp/mysql-client-home' }, + ); + expect(() => runMysql(3306, 'SELECT 1')).toThrow(/ETIMEDOUT/); + // A final waitUntil poll can hand runMysql a remaining budget below one + // client round trip; the floor keeps the last probe possible. + expect(() => runMysql(3306, 'SELECT 1', 3)).toThrow(/ETIMEDOUT/); + expect(spawns[1][2].timeout).toBeGreaterThanOrEqual(2_000); + // The default-timeout call pins the opposite direction: a budget above + // the floor reaches spawnSync unchanged. + expect(spawns[0][2].timeout).toBe(10_000); + }); + + // --no-defaults does not disable $HOME/.mylogin.cnf, so a developer's + // mysql_config_editor credential would still auth-connect to the scratch + // empty-password server. Both client invocations — the mysql client in + // runMysql and the mysqladmin readiness probe — run against an isolated + // environment: an empty HOME under the runner's one scratch root (so the + // finally reclaims it) with MYSQL_PWD and MYSQL_TEST_LOGIN_FILE stripped; + // the two mysqld server launches deliberately keep the real HOME. + it('isolates the MySQL client HOME under the runner scratch root', () => { + const source = read('scripts/run-managed-agent-server-e2e.ts'); + expect(source).toContain("path.join(temporary, 'mysql-client-home')"); + // --no-defaults and the isolated HOME leave two credential sources + // open: MYSQL_PWD is read as the password, and MYSQL_TEST_LOGIN_FILE + // relocates .mylogin.cnf ahead of $HOME and past --no-defaults. The + // shared client environment must strip both, or an exported developer + // credential reaches the scratch empty-password server. + expect(source).toContain("delete mysqlClientEnv['MYSQL_PWD'];"); + expect(source).toContain("delete mysqlClientEnv['MYSQL_TEST_LOGIN_FILE'];"); + // Per call site, not a whole-file count: a count detects a removed + // override but stays green when a fifth client invocation without one is + // added. The mysqld server launches deliberately keep the real HOME, so + // the isolated override is required on the mysql/mysqladmin clients. + const { calls, binaries } = mysqlCalls(source); + // The exact inventory, not a lower bound — and the scan's binary set is + // the runner's own command() resolutions, pinned exactly — so a scan + // that silently stops seeing a call site or a declaration fails here + // instead of passing against a collapsed population. + expect([...binaries].sort()).toEqual(['mysql', 'mysqladmin', 'mysqld']); + expect(calls).toHaveLength(4); + for (const call of calls) { + // MySQL honors --no-defaults only as the first option, so pin the + // position: containment would survive the flag moving off index 0. + expect( + call.firstArg, + `${call.binary} must pass --no-defaults first`, + ).toBe("'--no-defaults'"); + if (call.binary === 'mysqld') continue; + expect( + call.text, + `${call.binary} must run with the isolated mysqlClientEnv`, + ).toContain('env: mysqlClientEnv'); + } + }); + + // The inventory promises quote-style blindness: TypeScript reports a + // no-substitution template literal with its backticks on, so the strip + // class must cover the third JS string spelling or a backticked launch + // is never inventoried. + it('inventories a MySQL launch spelled with any quote style', () => { + const { calls } = mysqlCalls(` + const mysql = command('mysql'); + spawnSync('mysql', ['--no-defaults']); + spawnSync("mysql", ['--no-defaults']); + spawnSync(\`mysql\`, ['--no-defaults']); + `); + expect(calls).toHaveLength(3); + }); + + // spawnSync blocks the event loop, so waitUntil's race cannot bound a + // synchronous probe: the mysqladmin readiness probe carries its own + // timeout, and the two lease polls derive each call's timeout from the + // remaining waitUntil budget rather than a site-local constant that can + // overshoot the declared budget several-fold. + it('bounds every synchronous MySQL probe by the poll budget', () => { + const source = read('scripts/run-managed-agent-server-e2e.ts'); + expect(source).toMatch(/spawnSync\(\s*mysqladmin,[\s\S]*?timeout: 10_000/); + // Pin the forwarded argument, not the parameter list: an arrow that + // takes remainingMs but never passes it on falls back to the 10 s + // default inside a budget that may be smaller. + expect( + source.match(/\(remainingMs\) =>\s*runMysql\([\s\S]*?remainingMs,/g), + 'both lease polls must derive the probe timeout from the waitUntil budget', + ).toHaveLength(2); + }); + + it('names the jar explicitly on both surfaces — pom-derived name in the runner, classifier exclusion at image build', () => { + const script = read('scripts/run-managed-agent-server-e2e.ts'); + expect(script).not.toContain('qwen-managed-agent-server-0.1.0-alpha'); + // The pom's repackage executions leave the unclassified jar plus one + // artifact per classifier in target/. The runner derives the + // unclassified name from the pom (one source of truth); the Dockerfile + // selects by classifier exclusion with a loud cardinality guard. + expect(script).toContain('qwen-managed-agent-server-${pomVersion}.jar'); + expect(script).not.toContain('packagedJars'); + const dockerfile = read( + 'packages/sdk-java/managed-agent-server/Dockerfile', + ); + expect(dockerfile).not.toContain('qwen-managed-agent-server-0.1.0-alpha'); + expect(dockerfile).toContain('/tmp/qwen-managed-agent-server.jar'); + // The exclusion set must cover every classifier the pom declares, or a + // pom edit surfaces as an operator's failed docker build ("found 2") + // instead of a red here: derive the set from the pom, the one source + // of truth, rather than pinning a literal copy of it. + const classifiers = [ + ...read('packages/sdk-java/managed-agent-server/pom.xml').matchAll( + /([^<]+)<\/classifier>/g, + ), + ].map((match) => match[1]); + expect(classifiers.length).toBeGreaterThan(0); + for (const classifier of classifiers) { + expect(dockerfile).toContain(`*-${classifier}.jar`); + } + }); + + it('unrefs the waitUntil stall timer so a fast success does not idle the runner', () => { + const source = read('scripts/run-managed-agent-server-e2e.ts'); + expect(source).toMatch(/stallTimer\.unref\(\)/); + }); + + it('resolves the project version from the real pom', () => { + // The runner's own regex executes against the real pom — lifted from + // the runner source, not copied — so a whitespace change between + // and or a ${revision} indirection surfaces + // here, not at the next local run of the script. A routine version + // bump must stay green: pin the shape the runner depends on (the match + // anchored at the module's own artifactId and a concrete, non-property + // version), never today's value. + const script = read('scripts/run-managed-agent-server-e2e.ts'); + const patternSource = script.match( + /pomXml\.match\(\s*(\/(?:\\.|[^\\/])*\/)/, + )?.[1]; + expect(patternSource).toBeTruthy(); + const pattern = new Function(`return ${patternSource};`)(); + const match = read('packages/sdk-java/managed-agent-server/pom.xml').match( + pattern, + ); + expect(match?.[0]).toContain( + 'qwen-managed-agent-server', + ); + expect(match?.[1]).toBeTruthy(); + expect(match?.[1]).not.toContain('${'); + // The descriptive throw stays part of the lookup: a pom whose version + // the regex cannot read must fail there, not at the jar existsSync. + expect(script).toContain('Could not read the project '); + // Whitespace inside the element (a formatter wrapping the value) must + // not flow into the jar path: the lifted pattern's capture is + // whitespace-tolerant, and an empty element still matches nothing, so + // the runner's descriptive guard fires. + const wrapped = + 'qwen-managed-agent-server\n \n 9.9.9\n '; + expect(wrapped.match(pattern)?.[1]).toBe('9.9.9'); + expect( + 'qwen-managed-agent-server'.match( + pattern, + ), + ).toBeNull(); + }); + + it('keeps the image on the loopback default and the jar guard loud', () => { + const dockerfile = read( + 'packages/sdk-java/managed-agent-server/Dockerfile', + ); + // The module has no HTTP authentication, so the published image must + // not bind beyond loopback by default: publishing is the operator's + // explicit -e opt-in at docker run, documented in the README's + // container section. The cardinality guard stays loud, so the build + // fails rather than shipping a wrong or glob-stat jar. + // Dockerfile instructions are case-insensitive and tolerate leading + // whitespace, so anchor on the instruction rather than its + // conventional spelling: a lowercase or indented ENV would + // reintroduce the published wide bind with an uppercase-only pin + // green. + expect(dockerfile).not.toMatch( + /^[ \t]*ENV[ \t]+QWEN_MANAGED_AGENT_SERVER_ADDRESS/im, + ); + expect(dockerfile).not.toMatch( + /^[ \t]*ENV[ \t]+QWEN_MANAGED_AGENT_RUNTIME_BROKER_HOST/im, + ); + // Pin the bind default at the file that owns it, not only at one + // downstream file declining to override it: every Java test passes + // --server.address on the command line and the e2e runner passes none, + // so a flipped yml default would otherwise ship silently. + const applicationYml = read( + 'packages/sdk-java/managed-agent-server/src/main/resources/application.yml', + ); + expect(applicationYml).toMatch( + /address: '\$\{QWEN_MANAGED_AGENT_SERVER_ADDRESS:127\.0\.0\.1\}'/, + ); + expect(dockerfile).toMatch(/\{ \[ "\$count" -eq 1 \] \|\|/); + expect(dockerfile).toContain( + 'expected exactly one unclassified server jar', + ); + // Host Maven output must not ride the build context into the stage the + // guard inspects: a stale jar from a developer's target/ would trip the + // cardinality check with an artifact this build did not produce. Both + // spellings — a bare `target` is root-anchored under Docker's pattern + // syntax. + const dockerignore = read('.dockerignore'); + expect(dockerignore).toMatch(/^target$/m); + expect(dockerignore).toMatch(/^\*\*\/target$/m); + // The container section must document the opt-in and name the + // default-bridge exposure, or an operator who skips -p concludes the + // surface is closed. + const readme = read('packages/sdk-java/managed-agent-server/README.md'); + expect(readme).toContain('-e QWEN_MANAGED_AGENT_SERVER_ADDRESS=0.0.0.0'); + expect(readme).toMatch(/with no `-p` at all/); + // RuntimeBrokerHttpServer refuses a non-loopback broker bind unless the + // allow-non-loopback flag is set, so a documented broker wildcard opt-in + // must name the flag in the same block: the unguarded pair crash-loops + // the container the moment the broker is enabled. + for (const document of [dockerfile, readme]) { + const wildcard = document.indexOf( + 'QWEN_MANAGED_AGENT_RUNTIME_BROKER_HOST=0.0.0.0', + ); + if (wildcard === -1) continue; + const before = document.lastIndexOf('\n\n', wildcard); + const after = document.indexOf('\n\n', wildcard); + expect( + document.slice( + before === -1 ? 0 : before, + after === -1 ? undefined : after, + ), + 'a documented broker wildcard bind must name ALLOW_NON_LOOPBACK=true', + ).toMatch( + /QWEN_MANAGED_AGENT_RUNTIME_BROKER_ALLOW_NON_LOOPBACK='?true'?/, + ); + } + // BrokerSecurity refuses a non-loopback server.address under the + // shipped auto / allow-insecure-bind=false defaults, so a documented + // wildcard opt-in whose command names neither override crash-loops the + // container: every block setting SERVER_ADDRESS=0.0.0.0 must pass + // signed mode with its signing key or the explicit insecure override as + // -e flags — an override mentioned only in prose is not in the command + // an operator copies. + for (const document of [dockerfile, readme]) { + for (const occurrence of document.matchAll( + /QWEN_MANAGED_AGENT_SERVER_ADDRESS=0\.0\.0\.0/g, + )) { + const before = document.lastIndexOf('\n\n', occurrence.index); + const after = document.indexOf('\n\n', occurrence.index); + const block = document.slice( + before === -1 ? 0 : before, + after === -1 ? undefined : after, + ); + // The signing key must be forwarded valueless: the = spelling + // leaves the HMAC secret in the docker run argv, readable by any + // local user from /proc//cmdline for the container's + // lifetime. + const signed = + /-e QWEN_MANAGED_AGENT_AUTH_MODE='?signed'?/.test(block) && + /-e QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY(?!=)/.test(block); + const insecure = + /-e QWEN_MANAGED_AGENT_AUTH_ALLOW_INSECURE_BIND='?true'?/.test(block); + expect( + signed || insecure, + 'a documented server wildcard bind must pass -e AUTH_MODE=signed with a valueless signing key or -e AUTH_ALLOW_INSECURE_BIND=true', + ).toBe(true); + // The datasource password is a credential of the same class as + // the signing key above: spelled with = it sits in the docker run + // argv, readable from /proc//cmdline for the container's + // lifetime, so the recipe must forward it valueless. + expect( + block, + 'a documented server wildcard bind must pass -e SPRING_DATASOURCE_PASSWORD valueless', + ).toMatch(/-e SPRING_DATASOURCE_PASSWORD(?!=)/); + // Flyway runs against SPRING_DATASOURCE_URL at container start and + // the yml default's 127.0.0.1 is the container itself: a publish + // recipe that omits the datasource — or points it at loopback — + // passes the auth guard and then dies at the datasource with the + // published port refused. toContain stops at the flag name, so pin + // the loopback spellings away from the host; the + // placeholder the recipes document stays valid. + expect( + block, + 'a documented server wildcard bind must pass -e SPRING_DATASOURCE_URL= with a container-reachable host', + ).toMatch( + /-e SPRING_DATASOURCE_URL='?jdbc:mysql:\/\/(?!127\.0\.0\.1|localhost)/, + ); + // Docker drops a valueless -e whose variable is unset without a + // warning, so the recipe prose must have the operator export BOTH + // valueless variables: an instruction naming only the password + // boots the container with an empty signing key, and the JVM dies + // at BrokerSecurity's minimum-key guard with the published port + // refused. The Dockerfile's comment defers to the README's + // container section rather than repeating the precondition, so + // the pin is README-only. + if (document === readme) { + const preceding = document.slice(0, before === -1 ? 0 : before); + expect( + preceding, + 'the prose ahead of a documented server wildcard bind must have the operator export both valueless variables', + ).toMatch( + /export[\s\S]{0,160}?QWEN_MANAGED_AGENT_AUTH_SIGNING_KEY[\s\S]{0,160}?SPRING_DATASOURCE_PASSWORD/, + ); + } + } + } + }); + + // The jar-selection guard is executable shell, and the text pins above + // never look inside the loop body: deleting the glob break or forcing the + // count both left them green. Run the stage's own shell text against + // fixture target/ populations so the guard's behaviour is pinned, not its + // spelling. + // The fixture ends in `cp`, and a Git-Bash-only Windows PATH resolves + // sh.exe without the coreutils: probe the capability the fixture consumes + // and skip visibly — an in-body return would record the case as passed for + // a guard that never ran. + const hasShAndCp = + spawnSync('sh', ['-c', 'command -v cp >/dev/null 2>&1'], { + stdio: 'ignore', + }).status === 0; + + it.skipIf(!hasShAndCp)( + 'fails the image build loudly when the jar selection is ambiguous', + () => { + const dockerfile = read( + 'packages/sdk-java/managed-agent-server/Dockerfile', + ); + const stage = dockerfile.match( + /cd packages\/sdk-java\/managed-agent-server\/target \\[\s\S]*?&& cp "\$main" \/tmp\/qwen-managed-agent-server\.jar/, + ); + expect( + stage, + 'the jar-selection RUN stage must be extractable from the Dockerfile', + ).not.toBeNull(); + const script = stage[0] + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')) + .map((line) => line.trimEnd().replace(/\\$/, '')) + .join(' '); + const populations = [ + { jars: [], status: 1, found: 0 }, + { jars: ['qwen-managed-agent-server-1.0.jar'], status: 0 }, + { + jars: [ + 'qwen-managed-agent-server-1.0.jar', + 'qwen-managed-agent-server-1.0-workspace-bundle.jar', + 'qwen-managed-agent-server-1.0-operator-recovery.jar', + ], + status: 0, + }, + { + jars: [ + 'qwen-managed-agent-server-1.0.jar', + 'qwen-managed-agent-server-1.0-workspace-bundle.jar', + 'qwen-managed-agent-server-1.0-workspace-migration.jar', + 'qwen-managed-agent-server-1.0-operator-recovery.jar', + ], + status: 0, + }, + { + jars: [ + 'qwen-managed-agent-server-1.0.jar', + 'qwen-managed-agent-server-2.0.jar', + ], + status: 1, + found: 2, + }, + ]; + for (const { jars, status, found } of populations) { + const dir = mkdtempSync(join(tmpdir(), 'jar-guard-')); + try { + for (const jar of jars) writeFileSync(join(dir, jar), ''); + const fixture = script + .replace( + 'cd packages/sdk-java/managed-agent-server/target', + `cd '${dir.replaceAll('\\', '/')}'`, + ) + .replace( + '/tmp/qwen-managed-agent-server.jar', + `'${join(dir, 'published.jar').replaceAll('\\', '/')}'`, + ); + const run = spawnSync('sh', ['-c', fixture], { encoding: 'utf8' }); + expect(run.status, `${jars.length} jars: ${run.stderr}`).toBe(status); + if (found !== undefined) { + expect(run.stderr).toContain( + `expected exactly one unclassified server jar, found ${found}`, + ); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }, + ); });