mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-26 00:39:51 +00:00
* feat(managed-agent): derive Session Store wiring on a single host An enabled Hosted Harness requires the Managed Session Store, yet the store's switch, base URL and workspace ID all had to be configured by hand, and the shipped templates set a workspace ID that the documented local-process tool path then rejects. The store now follows the Harness switch, takes its base URL from this server's loopback or wildcard listener, and shares the Runtime Broker's workspace identity, derived from the Workspace directory when no ID is set. Explicit values keep precedence. Preflight reports the effective store wiring, and the templates, bootstrap and deployment guide name the Workspace directory instead of a conflicting ID. * fix(managed-agent): derive no plaintext Store URL for a TLS listener When the public listener serves TLS through server.ssl and no dedicated internal listener exists, the derived http:// loopback URL reached the TLS connector, so every Store call failed while preflight reported PASS. Such a listener now derives nothing and the existing refusal asks for an explicit URL. The internal listener is always plaintext, so it still derives behind a TLS public listener. * test(managed-agent): pin the dual-path entry to the derived Store wiring The dual-path README contract still required the explicit store switch that the entry no longer exports, and its ordering check would pass vacuously once the name was gone. It now checks that the store follows the Harness by default, that Prerequisites exports the workspace ID, and that the Harness base-url export precedes the restart cue, with both positions required.
1666 lines
73 KiB
JavaScript
1666 lines
73 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { createServer, request as httpRequest } from 'node:http';
|
|
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,
|
|
transpileModule,
|
|
} from 'typescript';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { QWEN_SERVER_TOKEN_ENV } from '../../packages/cli/src/serve/channel-worker-env.js';
|
|
import { HOSTED_HARNESS_CAPABILITY_DIGEST_ENV } from '../../packages/cli/src/serve/hosted-harness-contract.js';
|
|
import { validateHostedHarnessProfile } from '../../packages/cli/src/serve/hosted-harness-profile.js';
|
|
|
|
const read = (file) =>
|
|
readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
|
|
|
|
// Repo-root script mentions, bare or ./-prefixed. A package-relative tail such
|
|
// as packages/foo/scripts/bar.js is not a root mention, so the lookbehind
|
|
// still rejects a `scripts/` preceded by a path character.
|
|
const namedScripts = (text) => [
|
|
...new Set(
|
|
[...text.matchAll(/(?<![\w./-])(?:\.\/)?scripts\/[\w./-]*[\w-]/g)].map(
|
|
(match) => match[0].replace(/^\.\//, ''),
|
|
),
|
|
),
|
|
];
|
|
|
|
// Fenced shell blocks of a markdown text, bodies only; language-less fences
|
|
// are invisible to this scan.
|
|
const fencedShellBlocks = (text) =>
|
|
[
|
|
...text.matchAll(/```[ \t]*(?:bash|sh|shell|console|zsh)\n([\s\S]*?)```/g),
|
|
].map((match) => match[1]);
|
|
|
|
describe('managed-agent-server e2e runner', () => {
|
|
it('queries tables from the shared fresh-database baseline', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
const baseline = read(
|
|
'packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql',
|
|
);
|
|
const tables = new Set(
|
|
[...baseline.matchAll(/CREATE TABLE (\w+)/g)].map((match) => match[1]),
|
|
);
|
|
const queried = new Set(
|
|
[...source.matchAll(/qwen_managed_agent\.(\w+)/g)].map(
|
|
(match) => match[1],
|
|
),
|
|
);
|
|
expect(queried.size).toBeGreaterThan(0);
|
|
for (const table of queried) {
|
|
expect(
|
|
tables.has(table),
|
|
`E2E table ${table} is absent from canonical V1`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
const extracted = (names) => {
|
|
const source = createSourceFile(
|
|
'runner.ts',
|
|
read('scripts/run-managed-agent-server-e2e.ts'),
|
|
ScriptTarget.Latest,
|
|
true,
|
|
);
|
|
const text = source.statements
|
|
.filter(
|
|
(node) =>
|
|
(isFunctionDeclaration(node) && names.includes(node.name?.text)) ||
|
|
(isVariableStatement(node) &&
|
|
node.declarationList.declarations.some((declaration) =>
|
|
names.includes(declaration.name.getText(source)),
|
|
)),
|
|
)
|
|
.map((node) => node.getText(source))
|
|
.join('\n');
|
|
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,
|
|
];
|
|
const createServer = () => ({
|
|
once() {},
|
|
off() {},
|
|
closeAllConnections() {},
|
|
listen(port, _host, ready) {
|
|
this.port = port || sequence.shift();
|
|
expect(this.port).toBeDefined();
|
|
ready();
|
|
},
|
|
address() {
|
|
return { port: this.port };
|
|
},
|
|
close(done) {
|
|
done?.();
|
|
},
|
|
});
|
|
const { freePort, startHeldExecutionStartProxy } = load(
|
|
['freePort', 'startHeldExecutionStartProxy', 'allocatedPorts'],
|
|
'freePort, startHeldExecutionStartProxy',
|
|
'createServer',
|
|
)(createServer);
|
|
const ports = [];
|
|
for (const count of [4, 3]) {
|
|
for (let index = 0; index < count; index++) ports.push(await freePort());
|
|
const proxy = await startHeldExecutionStartProxy('http://127.0.0.1:1');
|
|
ports.push(Number(new URL(proxy.baseUrl).port));
|
|
await proxy.close();
|
|
}
|
|
expect(new Set(ports).size).toBe(9);
|
|
});
|
|
|
|
// #14481 step 2: a lost reply is a reply the server already committed and
|
|
// answered, so the helper must wait for `response` before destroying it.
|
|
// Destroying on `request` would send nothing and let every retry assertion
|
|
// pass on a request the control plane never saw.
|
|
it('drops a committed reply without skipping the request', async () => {
|
|
const { postAndDropReply } = load(
|
|
['postAndDropReply'],
|
|
'postAndDropReply',
|
|
'httpRequest',
|
|
)(httpRequest);
|
|
let received = '';
|
|
const server = createServer((request, response) => {
|
|
request.setEncoding('utf8');
|
|
request.on('data', (chunk) => {
|
|
received += chunk;
|
|
});
|
|
request.on('end', () => {
|
|
response.writeHead(202, { 'content-type': 'application/json' });
|
|
response.end('{"id":"operation-1"}');
|
|
});
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
try {
|
|
await postAndDropReply(
|
|
`http://127.0.0.1:${port}/v1/agents/sessions/s/events`,
|
|
'{"type":"x"}',
|
|
{
|
|
'content-type': 'application/json',
|
|
'idempotency-key': 'retry-key',
|
|
},
|
|
);
|
|
expect(received).toBe('{"type":"x"}');
|
|
} finally {
|
|
server.closeAllConnections();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
});
|
|
|
|
// A 4xx means the control plane committed nothing, so dropping that reply
|
|
// would let the retry assertions pass over a request the server rejected.
|
|
it('refuses to drop a reply the control plane did not accept', async () => {
|
|
const { postAndDropReply } = load(
|
|
['postAndDropReply'],
|
|
'postAndDropReply',
|
|
'httpRequest',
|
|
)(httpRequest);
|
|
const server = createServer((request, response) => {
|
|
request.resume();
|
|
request.on('end', () => {
|
|
response.writeHead(409, { 'content-type': 'application/json' });
|
|
response.end('{"error":"turn_active"}');
|
|
});
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
try {
|
|
await expect(
|
|
postAndDropReply(
|
|
`http://127.0.0.1:${port}/v1/agents/sessions/s/events`,
|
|
'{"type":"x"}',
|
|
{ 'content-type': 'application/json' },
|
|
),
|
|
).rejects.toThrow(/expected 202/);
|
|
} finally {
|
|
server.closeAllConnections();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
});
|
|
|
|
// The reviewer's probe: a 202 only admits the response operation, so the
|
|
// Action row is still `requested` with a null receipt. The after-decision
|
|
// barrier must reject that answer, or a crash proves delivery, not commit.
|
|
it('refuses an admitted but undelivered decision as the after-decision barrier', () => {
|
|
const { approvalDecisionCommitted } = load(
|
|
['approvalDecisionCommitted'],
|
|
'approvalDecisionCommitted',
|
|
)();
|
|
expect(approvalDecisionCommitted('requested\t0\t0')).toBe(false);
|
|
expect(approvalDecisionCommitted('decided\t0\t0')).toBe(false);
|
|
expect(approvalDecisionCommitted('decided\t1\t0')).toBe(false);
|
|
expect(approvalDecisionCommitted('decided\t1\t1')).toBe(true);
|
|
});
|
|
|
|
it('pins the #14481 approval fault arms to durable barriers', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
const workflow = read('.github/workflows/sdk-java.yml');
|
|
const manifest = read('package.json');
|
|
const scripts = [
|
|
'test:e2e:managed-harness-restart-approval-after-decision',
|
|
'test:e2e:managed-harness-restart-approval-after-prepare',
|
|
'test:e2e:managed-harness-restart-approval-after-result',
|
|
'test:e2e:managed-harness-restart-approval-decision-reply-lost',
|
|
'test:e2e:managed-harness-restart-approval-admission-reply-lost',
|
|
];
|
|
for (const script of scripts) {
|
|
expect(manifest, `${script} must be an npm script`).toContain(
|
|
`"${script}"`,
|
|
);
|
|
expect(workflow, `${script} must be a CI step`).toContain(script);
|
|
}
|
|
// Deny, Cancel and expiry have no approved execution, so layering a
|
|
// crash there would silently assert a weaker path.
|
|
expect(source).toMatch(
|
|
/if \(approvalFault && verificationMode !== 'allow'\)/,
|
|
);
|
|
// The lost decision must retry the same key and land on one operation.
|
|
expect(source).toContain("operation_kind='ACTION_RESPONSE'");
|
|
// The crash arms scrap the crashed home and require a third generation,
|
|
// rather than reusing the killed owner's disk.
|
|
const recovery = source.indexOf("'Post-fault recovery Harness'");
|
|
const scrap = source.indexOf(
|
|
'rmSync(replacementHarnessHome, { recursive: true, force: true })',
|
|
);
|
|
expect(recovery).toBeGreaterThan(-1);
|
|
expect(scrap).toBeGreaterThan(-1);
|
|
expect(
|
|
scrap,
|
|
'the crashed home must be scrapped before recovery',
|
|
).toBeLessThan(recovery);
|
|
expect(source).toContain(
|
|
"await waitUntil(\n 'Post-fault recovery Harness adopted the Session'",
|
|
);
|
|
// The partial-prepare crash needs the held :start and a PREPARED row
|
|
// with no dispatch, or it degrades into the after-decision barrier.
|
|
expect(source).toContain("execution === 'PREPARED\\t0'");
|
|
expect(source).toContain(
|
|
'replacementBrokerProxy?.heldPath() !== undefined',
|
|
);
|
|
// The after-decision crash must read the durable decision row, not the
|
|
// 202: hold :start so it cannot reach dispatch, and require the state,
|
|
// receipt and digest together.
|
|
expect(source).toContain('Approval decision durably committed');
|
|
expect(source).toMatch(/approvalDecisionCommitted\(\s*runMysql\(/);
|
|
expect(source).toContain(
|
|
'IF(decision_receipt_id IS NULL, 0, 1), IF(decision_digest IS NULL, 0, 1)',
|
|
);
|
|
const proxy = source.indexOf(
|
|
'replacementBrokerProxy = await startHeldExecutionStartProxy',
|
|
);
|
|
expect(proxy).toBeGreaterThan(-1);
|
|
expect(source.slice(proxy - 500, proxy)).toContain(
|
|
"approvalFault === 'after-decision'",
|
|
);
|
|
// The result-commit crash waits for the settled result and the held
|
|
// model round, then releases the hold; its reissue must reuse the same
|
|
// saved request or the proof is about a rewritten one.
|
|
expect(source).toContain(
|
|
'Approved result committed and its prefix published',
|
|
);
|
|
expect(source).toContain('SETTLED\\t1');
|
|
expect(source).toContain('releaseApprovalResultHold()');
|
|
expect(source).toContain(
|
|
'Result-commit recovery changed the saved model request',
|
|
);
|
|
expect(source).toContain('publishedPrefixSequences');
|
|
expect(source).toContain("approvalFault === 'after-result'");
|
|
expect(source).toContain('expectedModelsAfterResults');
|
|
expect(source).toContain('? 2');
|
|
// R1-3: the retraction assertion must be pinned, or deleting it keeps
|
|
// every script suite green while the arm stops proving the prefix left.
|
|
expect(source).toContain('Result-commit recovery left');
|
|
// R1-2: the structured half (stream.reconciled ordering) must be
|
|
// asserted, not only written to the evidence file.
|
|
expect(source).toContain(
|
|
'Result-commit recovery did not reconcile the published prefix',
|
|
);
|
|
// R1-6: a parity failure must retain the two request bodies for diffing.
|
|
expect(source).toContain('model-request-parity.json');
|
|
// R1-5: the pre-dispatch proxy must never hold after-result's :start.
|
|
expect(source.slice(proxy - 500, proxy)).not.toContain(
|
|
"approvalFault === 'after-result'",
|
|
);
|
|
});
|
|
|
|
// #12941: the Stage A acceptance criterion names a 15-second Runtime delay,
|
|
// but the ordering assertion was gated at 20 s, so a --runtime-delay-ms 15000
|
|
// run silently skipped it. Pin the threshold to the criterion's delay and
|
|
// the README's statement of the arming delay to the threshold.
|
|
it('arms the model-before-Runtime assertion at the criterion delay', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain('modelBeforeRuntimeAssertionDelayMs = 15_000');
|
|
expect(source).toContain(
|
|
'runtimeDelayMs >= modelBeforeRuntimeAssertionDelayMs',
|
|
);
|
|
const delaySeconds =
|
|
Number(
|
|
source
|
|
.match(/modelBeforeRuntimeAssertionDelayMs = (\d[\d_]*)/)[1]
|
|
.replace(/_/g, ''),
|
|
) / 1000;
|
|
expect(read('packages/managed-agent-server/README.md')).toContain(
|
|
`the ${delaySeconds} seconds the acceptance criterion`,
|
|
);
|
|
});
|
|
|
|
// When the assertion fires the operator must tell an ordering defect from
|
|
// provider latency, so the thrown message must carry the deciding sequence
|
|
// operands alongside the in-scope timings (observedAt is a poll-batch stamp,
|
|
// so the timings alone can be identical or argue against the verdict).
|
|
it('reports the ordering timings when the assertion fires', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain('firstModelSequence=${firstModel.event.sequence}');
|
|
expect(source).toContain(
|
|
'runtimeReadySequence=${runtimeReady.event.sequence}',
|
|
);
|
|
expect(source).toContain(
|
|
'firstModelEventMs=${firstModel.observedAt - requestStartedAt}',
|
|
);
|
|
expect(source).toContain(
|
|
'runtimeReadyMs=${runtimeReady.observedAt - requestStartedAt}',
|
|
);
|
|
expect(source).toContain('runtimeDelayMs=${runtimeDelayMs}');
|
|
});
|
|
|
|
// #12941: the README named scripts/run-managed-hosted-runtime-e2e.ts as the
|
|
// deterministic CI proof, a file that has never existed. Any script the
|
|
// README names must be real.
|
|
it('names only scripts that exist', () => {
|
|
const readme = read('packages/managed-agent-server/README.md');
|
|
expect(readme).not.toContain('run-managed-hosted-runtime-e2e');
|
|
for (const script of namedScripts(readme)) {
|
|
expect(
|
|
existsSync(new URL(`../../${script}`, import.meta.url)),
|
|
`${script} named in the managed-agent README does not exist`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
// With the server defaults flipped on, dropping these pins would make the
|
|
// runner start the durable path off Linux and fail at server startup while
|
|
// `npm run test:scripts` stayed green: trusted recovery is pinned off at
|
|
// both Spring launch sites, and durable local process only ever follows the
|
|
// runtimeTakeover modes.
|
|
it('pins the runtime recovery flags for every runner mode', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain(
|
|
'const runtimeTakeover = inflightFailover || continuationFailover;',
|
|
);
|
|
expect(
|
|
source.match(
|
|
/if \(runtimeTakeover && !harnessOnly && process\.platform !== 'linux'\)/,
|
|
),
|
|
'only orphaned Runtime takeover modes must require Linux',
|
|
).not.toBeNull();
|
|
expect(
|
|
source.match(
|
|
/QWEN_MANAGED_AGENT_RUNTIME_TRUSTED_LOCAL_REBOOT_RECOVERY:\s*'false'/g,
|
|
),
|
|
'both Spring launch sites must pin trusted reboot recovery off',
|
|
).toHaveLength(2);
|
|
expect(
|
|
source.match(
|
|
/QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS:\s*'false'/g,
|
|
),
|
|
'both non-runtimeTakeover branches must pin durable local process off',
|
|
).toHaveLength(2);
|
|
expect(
|
|
source.match(
|
|
/\.\.\.\(runtimeTakeover\s*\?\s*\{\s*QWEN_MANAGED_AGENT_RUNTIME_DURABLE_LOCAL_PROCESS:\s*process\.platform === 'linux' \? 'true' : 'false'/g,
|
|
),
|
|
'both durable local process branches must follow runtimeTakeover, Linux-gated',
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
// The frozen control-plane arm proves #12952 Q2's binding half only if the
|
|
// woken former Spring really tried its stale claim: without the general-log
|
|
// witness every unchanged row would hold just as well after a wake that
|
|
// never reached a store call. A failed run must also wake the stopped JVM
|
|
// before teardown, or stopChild's SIGTERM stays pending on it.
|
|
it('keeps the frozen control-plane wake non-vacuous and woken at teardown', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain("SET GLOBAL general_log = 'ON'");
|
|
expect(source).toMatch(
|
|
/LOCATE\(\$\{sqlString\(formerDispatchOwner\)\}, CONVERT\(argument USING utf8mb4\)\)/,
|
|
);
|
|
// Only statements sent after the wake may count, or the frozen owner's
|
|
// pre-freeze renewals would satisfy the witness on their own.
|
|
expect(source).toMatch(
|
|
/FROM mysql\.general_log WHERE event_time >= \$\{sqlString\(wokeAt\)\}/,
|
|
);
|
|
const quiet = source.indexOf("'Replacement quiet before the wake'");
|
|
const wake = source.indexOf(
|
|
"signalProcessTree(child.child, 'SIGCONT');",
|
|
quiet,
|
|
);
|
|
const witness = source.indexOf(
|
|
'(remainingMs) => staleClaimUpdates(remainingMs) > 0',
|
|
);
|
|
const compare = source.indexOf('const afterWake = fencedState();');
|
|
expect(quiet).toBeGreaterThan(-1);
|
|
expect(
|
|
wake,
|
|
'the before-wake snapshot must settle before the former pair resumes',
|
|
).toBeGreaterThan(quiet);
|
|
expect(witness).toBeGreaterThan(wake);
|
|
expect(
|
|
compare,
|
|
'the fenced-state comparison must wait for the stale-claim witness',
|
|
).toBeGreaterThan(witness);
|
|
// The waits are awaited: a floating one would let the comparison run
|
|
// before anything it waits for was observed.
|
|
for (const label of [
|
|
'Replacement quiet before the wake',
|
|
'Woken control plane exercises its stale claim',
|
|
'Woken Harness meets the writer fence',
|
|
]) {
|
|
expect(source).toMatch(new RegExp(`await waitUntil\\(\\s*'${label}'`));
|
|
}
|
|
// A dead replacement would hold every snapshot still: each wait in the
|
|
// window watches the pair, and the verdict checks it once more.
|
|
expect(source).toContain(
|
|
'const owners = [replacementSpring, finishingHarness];',
|
|
);
|
|
for (const label of [
|
|
'Replacement quiet before the wake',
|
|
'Former control plane wake',
|
|
'Woken control plane exercises its stale claim',
|
|
'Woken Harness meets the writer fence',
|
|
]) {
|
|
const at = source.indexOf(`'${label}'`);
|
|
const end = source.indexOf('\n );', at);
|
|
expect(at, label).toBeGreaterThan(-1);
|
|
expect(source.slice(at, end), label).toMatch(/\n {10}owners,$/);
|
|
}
|
|
const ownersAlive = source.indexOf(
|
|
'for (const owner of owners) {\n if (childExited(owner.child)) {\n throw new Error(',
|
|
compare,
|
|
);
|
|
expect(ownersAlive).toBeGreaterThan(compare);
|
|
expect(ownersAlive).toBeLessThan(
|
|
source.indexOf('const moved = (', compare),
|
|
);
|
|
// The baseline the comparison uses is the snapshot the quiet poll
|
|
// settled on, taken before the wake.
|
|
const settle = source.indexOf('beforeWake = settled;', quiet);
|
|
expect(
|
|
settle,
|
|
'the snapshot the equality gate compares must be taken before the wake',
|
|
).toBeGreaterThan(quiet);
|
|
expect(settle).toBeLessThan(wake);
|
|
expect(
|
|
source.slice(wake, compare),
|
|
'the baseline must not be re-taken after the wake',
|
|
).not.toMatch(/\bbeforeWake\s*=/);
|
|
// The woken Harness needs its home, or the checks hold by absence.
|
|
const home = source.indexOf(
|
|
"'Frozen control-plane arm lost the Harness home the wake depends on'",
|
|
);
|
|
expect(home).toBeGreaterThan(quiet);
|
|
expect(home).toBeLessThan(wake);
|
|
// The writer axis: no refusal before the wake, one after it, and the
|
|
// comparison waits for it. The refusal text is the store's own.
|
|
const refusal = 'The Managed Session writer grant is stale or unavailable';
|
|
expect(source).toContain(`'${refusal}'`);
|
|
expect(
|
|
read(
|
|
'packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java',
|
|
),
|
|
).toContain(`"${refusal}."`);
|
|
const writerBefore = source.indexOf(
|
|
'if (harness.log().includes(staleWriterRefusal))',
|
|
);
|
|
// Counted on the output kept since the wake, not the rolling log tail.
|
|
expect(source).toContain(
|
|
'harnessOutputSinceWake.split(staleWriterRefusal).length - 1',
|
|
);
|
|
const writerWitness = source.indexOf('() => staleWriterRefusals() > 0');
|
|
expect(writerBefore).toBeGreaterThan(quiet);
|
|
expect(writerBefore).toBeLessThan(wake);
|
|
expect(writerWitness).toBeGreaterThan(wake);
|
|
expect(compare).toBeGreaterThan(writerWitness);
|
|
const teardown = source.lastIndexOf('} finally {');
|
|
const teardownWake = source.indexOf(
|
|
'for (const child of frozenChildren.splice(0))',
|
|
teardown,
|
|
);
|
|
const stop = source.indexOf('await stopChild(child.child);', teardown);
|
|
expect(teardownWake).toBeGreaterThan(teardown);
|
|
expect(stop).toBeGreaterThan(teardownWake);
|
|
});
|
|
|
|
// Every mode now runs through the G0 public Workspace admission, and the
|
|
// failover modes hand the same Session to a replacement owner: both Spring
|
|
// launch sites and both Harness launch sites must carry the admission
|
|
// wiring, or a mode turns red only after the failover kill with an error
|
|
// that reads like a takeover defect instead of a config asymmetry.
|
|
it('pins the G0 workspace admission at both launch sites', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toContain(
|
|
'const workspaceTurns = runtimeTakeover || bigOutput;',
|
|
);
|
|
expect(
|
|
source.match(
|
|
/\.\.\.\(workspaceTurns\s*\?\s*\{\s*workspace:\s*\{\s*workspace_id:\s*boundWorkspaceId\s*\}\s*\}/,
|
|
),
|
|
'every workspaceTurns mode must create a Workspace-bound Session',
|
|
).not.toBeNull();
|
|
expect(
|
|
source.match(
|
|
/directory === runtimeState && workspaceTurns\s*\?\s*\{\s*mode:\s*0o700\s*\}/,
|
|
),
|
|
'every workspaceTurns mode must keep Runtime state owner-only',
|
|
).not.toBeNull();
|
|
expect(
|
|
source.match(
|
|
/QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER: trustedActorHeader/g,
|
|
),
|
|
'both Spring launch sites must configure the trusted actor header',
|
|
).toHaveLength(2);
|
|
expect(
|
|
source.match(/QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED: 'true'/g),
|
|
'both Spring launch sites must enable Hosted Workspace files',
|
|
).toHaveLength(2);
|
|
expect(
|
|
source.match(/'--managed-runtime-broker-url'/g),
|
|
'both Harness launch sites must pass the Runtime Broker flags',
|
|
).toHaveLength(2);
|
|
// The mount argument is pushed once into the springArguments both Spring
|
|
// launch sites share; re-gating it would fail validateWorkspaceFiles at
|
|
// startup in every non-workspaceTurns mode while CI stayed green.
|
|
expect(
|
|
source.match(/workspace-mounts\[0\]\.root=/g),
|
|
'the shared Spring arguments must configure the Workspace mount',
|
|
).toHaveLength(1);
|
|
// The mount root itself must sit in the unconditional mkdir list: a
|
|
// re-gated entry still starts Spring (nothing checks the root exists)
|
|
// and only breaks the real-model side-effect assertion, which no lane
|
|
// runs.
|
|
expect(
|
|
source.match(/^\s+workspaceMount,$/m),
|
|
'the Workspace mount root must be created for every mode',
|
|
).not.toBeNull();
|
|
expect(
|
|
source.match(/INSERT INTO qwen_managed_agent\.ma_workspace\b/g),
|
|
'the Workspace registry row must be seeded for every mode',
|
|
).toHaveLength(1);
|
|
expect(
|
|
source.match(/INSERT INTO qwen_managed_agent\.ma_workspace_access/g),
|
|
'the Workspace access grant must be seeded for every mode',
|
|
).toHaveLength(1);
|
|
// The counts above cannot see WHERE an item sits: the runner before the
|
|
// admission alignment gated these same items inside workspaceTurns
|
|
// conditionals and satisfied every count. These negative pins are the
|
|
// symmetry witness. The windows stay short so the gates that must stay
|
|
// (durable local process, the Linux check, the 0700 state dir, the
|
|
// unbound create body) and comments mentioning either predicate do not
|
|
// trip them.
|
|
for (const predicate of ['workspaceTurns', 'runtimeTakeover']) {
|
|
for (const reGated of [
|
|
new RegExp(
|
|
`${predicate}[\\s\\S]{0,120}?QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER`,
|
|
),
|
|
new RegExp(
|
|
`${predicate}[\\s\\S]{0,120}?QWEN_MANAGED_AGENT_WORKSPACE_FILES_ENABLED`,
|
|
),
|
|
new RegExp(`${predicate}[\\s\\S]{0,120}?--managed-runtime-broker-url`),
|
|
new RegExp(`${predicate}[\\s\\S]{0,120}?workspaceMount`),
|
|
new RegExp(`if \\(${predicate}\\) \\{\\s*runMysql\\(`),
|
|
]) {
|
|
expect(
|
|
source.match(reGated),
|
|
`G0 admission re-gated behind ${predicate}: ${reGated}`,
|
|
).toBeNull();
|
|
}
|
|
}
|
|
});
|
|
|
|
// The README currently names no script, so only a fixture can pin the
|
|
// extractor itself: an extractor that stops matching must fail, not pass.
|
|
it('extracts the script spellings the README could use', () => {
|
|
expect(namedScripts('npx tsx ./scripts/nope.ts')).toEqual([
|
|
'scripts/nope.ts',
|
|
]);
|
|
expect(namedScripts('see `scripts/nope.ts`')).toEqual(['scripts/nope.ts']);
|
|
expect(namedScripts('packages/foo/scripts/nope.ts')).toEqual([]);
|
|
});
|
|
|
|
it('pins the fenced hosted-harness launch block in the server README to a startable form', () => {
|
|
// The oracle is the profile validator itself, not a copy of its rules:
|
|
// the documented launch must supply everything
|
|
// validateHostedHarnessProfile rejects for missing, or the launch fails
|
|
// at startup while this pin stays green. The block is matched exactly —
|
|
// a grammar that parses the fence into argv/env fails open on every
|
|
// shell spelling it does not model. The CLI-side credential names come
|
|
// from the production constants so renaming one reddens this pin instead
|
|
// of stranding the README's spelling.
|
|
const readme = read('packages/managed-agent-server/README.md');
|
|
const fencedBlocks = fencedShellBlocks(readme);
|
|
const command =
|
|
'qwen serve --profile hosted-harness --port 4171 --hostname 127.0.0.1 --no-web';
|
|
const launchBlocks = fencedBlocks.filter((block) =>
|
|
block.includes(command),
|
|
);
|
|
expect(
|
|
launchBlocks,
|
|
'the README must fence exactly one hosted-harness launch block',
|
|
).toHaveLength(1);
|
|
expect(launchBlocks[0]).toBe(
|
|
[
|
|
`${QWEN_SERVER_TOKEN_ENV}="$QWEN_MANAGED_AGENT_HARNESS_TOKEN" \\`,
|
|
`${HOSTED_HARNESS_CAPABILITY_DIGEST_ENV}="$QWEN_MANAGED_AGENT_CAPABILITY_DIGEST" \\`,
|
|
command,
|
|
].join('\n') + '\n',
|
|
);
|
|
// A `$VAR` reference defers to a name the reader was told to export in
|
|
// the Prerequisites section; a renamed or dropped export there expands
|
|
// to empty in the reader's shell and the launch dies at startup, so
|
|
// every name the launch block references must be assigned inside that
|
|
// section — an assignment in a fence anywhere else in the README never
|
|
// reaches the reader's shell.
|
|
const prereqStart = readme.indexOf('## Prerequisites');
|
|
const prereqEnd = readme.indexOf('## Public Session lifecycle');
|
|
expect(prereqStart).toBeGreaterThan(-1);
|
|
expect(prereqEnd).toBeGreaterThan(prereqStart);
|
|
const assigned = new Set();
|
|
for (const fenced of fencedShellBlocks(
|
|
readme.slice(prereqStart, prereqEnd),
|
|
)) {
|
|
for (const match of fenced.matchAll(
|
|
/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/gm,
|
|
)) {
|
|
assigned.add(match[1]);
|
|
}
|
|
}
|
|
for (const match of launchBlocks[0].matchAll(
|
|
/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g,
|
|
)) {
|
|
expect(
|
|
assigned.has(match[1]),
|
|
`the launch block references $${match[1]}, which the Prerequisites section does not assign`,
|
|
).toBe(true);
|
|
}
|
|
// The validator's input is parsed from the pinned block so the fixture
|
|
// cannot drift from the documented launch: an edit that drops --no-web
|
|
// or widens --hostname must redden this oracle, not only the exact-text
|
|
// pin above. mode stays the constant serve.ts hardcodes — the launch
|
|
// carries no flag for it — and the deferred `$VAR` credentials stand in
|
|
// as conforming values so the validator judges the launch's shape rather
|
|
// than the placeholder spelling.
|
|
expect(() =>
|
|
validateHostedHarnessProfile({
|
|
profile: 'hosted-harness',
|
|
hostname: /--hostname\s+(\S+)/.exec(launchBlocks[0])[1],
|
|
port: Number(/--port\s+(\d+)/.exec(launchBlocks[0])[1]),
|
|
mode: 'http-bridge',
|
|
token: 'documented-value',
|
|
serveWebShell: !launchBlocks[0].includes('--no-web'),
|
|
hostedHarnessCapabilityDigest: `sha256:${'a'.repeat(64)}`,
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('pairs the 4171 base-url export with a startup-order note in the dual-path entry', () => {
|
|
// The base URL is read once at JVM startup, so the section that moves
|
|
// Spring to 4171 must say the value applies before (or via a restart
|
|
// of) `mvn spring-boot:run`, or the reader's running server stays on
|
|
// the 4170 value exported in Prerequisites.
|
|
const readme = read('packages/managed-agent-server/README.md');
|
|
const start = readme.indexOf(
|
|
'## Full WebShell dual-path development entry',
|
|
);
|
|
const end = readme.indexOf('## Embedded Runtime Broker');
|
|
expect(start).toBeGreaterThan(-1);
|
|
expect(end).toBeGreaterThan(start);
|
|
const slice = readme.slice(start, end);
|
|
// The export must point at the port the documented launch binds, not at
|
|
// a second copy of the number: a launch-block port bump mirrored into
|
|
// the launch pin above would otherwise leave this assertion green while
|
|
// Spring keeps calling the old port and every Managed Turn fails.
|
|
const launchBlocks = fencedShellBlocks(readme).filter((fenced) =>
|
|
fenced.includes('qwen serve --profile hosted-harness'),
|
|
);
|
|
expect(
|
|
launchBlocks,
|
|
'the README must fence exactly one hosted-harness launch block',
|
|
).toHaveLength(1);
|
|
const port = /--port\s+(\d+)/.exec(launchBlocks[0])[1];
|
|
expect(
|
|
slice,
|
|
'the Spring base URL must point at the port the launch block binds',
|
|
).toContain(
|
|
`QWEN_MANAGED_AGENT_HARNESS_BASE_URL='http://127.0.0.1:${port}'`,
|
|
);
|
|
// The caveat sentence is hard-wrapped in the README, so match across the
|
|
// line break; the fallback is the restart clause, not the bare word
|
|
// `restart` that any unrelated sentence in the slice could supply.
|
|
expect(slice.replace(/\s+/g, ' ')).toMatch(
|
|
/read once at JVM startup|restart it with the override/i,
|
|
);
|
|
// The by-hand recipe re-anchors Spring to the Prerequisites environment.
|
|
// Without a store descriptor the Harness answers every attach with
|
|
// 400 invalid_managed_session_store, so the store must come on with the
|
|
// Harness and find its workspace ID there: application.yml derives the
|
|
// switch from the Harness, and Prerequisites exports the ID.
|
|
expect(
|
|
read('packages/managed-agent-server/src/main/resources/application.yml'),
|
|
'the Session Store switch must follow the Harness by default',
|
|
).toContain(
|
|
"enabled: '${QWEN_MANAGED_AGENT_SESSION_STORE_ENABLED:${qwen.managed-agent.harness.enabled:false}}'",
|
|
);
|
|
const prerequisites = readme.slice(
|
|
readme.indexOf('## Prerequisites'),
|
|
readme.indexOf('## Public Session lifecycle'),
|
|
);
|
|
expect(
|
|
prerequisites,
|
|
'Prerequisites must export the workspace ID that scopes the store',
|
|
).toMatch(/^export QWEN_MANAGED_AGENT_WORKSPACE_ID='[^']+'$/m);
|
|
// The base URL is read once at JVM startup: exported below the restart
|
|
// cue it never reaches the reader's JVM, and every Turn fails while the
|
|
// recipe reads complete. The cue is hard-wrapped, so compare on the
|
|
// collapsed slice, and require both positions so neither can pass as -1.
|
|
const flat = slice.replace(/\s+/g, ' ');
|
|
const cue = flat.indexOf('restart it with the override');
|
|
const baseUrlExport = flat.indexOf(
|
|
`QWEN_MANAGED_AGENT_HARNESS_BASE_URL='http://127.0.0.1:${port}'`,
|
|
);
|
|
expect(cue, 'the slice must keep the Spring restart cue').toBeGreaterThan(
|
|
-1,
|
|
);
|
|
expect(baseUrlExport).toBeGreaterThan(-1);
|
|
expect(
|
|
baseUrlExport,
|
|
'the Harness base-url export must precede the Spring restart cue',
|
|
).toBeLessThan(cue);
|
|
});
|
|
|
|
it('keeps the review-corrections merge gates behind the CI-gated Hosted proofs', () => {
|
|
// The hosted-harness-mysql CI job runs HostedWorkspaceToolTurnIT (a real
|
|
// file-tool Turn through the packaged worker), fail-closed via the
|
|
// failsafe includes and check-failsafe-reports.js, and the
|
|
// hosted-failover-e2e job runs the three owner-failover E2E modes
|
|
// against the production HTTP durable-store adapter. While both
|
|
// oracles stand, the review-corrections gates must not re-assert those
|
|
// capabilities as unproven — the foundation-boundary banner names that
|
|
// document the current authority, so the false claim reaches
|
|
// integrators in either language.
|
|
const toolTurnIt =
|
|
'packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedWorkspaceToolTurnIT.java';
|
|
expect(
|
|
existsSync(new URL(`../../${toolTurnIt}`, import.meta.url)) &&
|
|
read('.github/workflows/sdk-java.yml').includes(
|
|
'test:e2e:managed-session-failover',
|
|
),
|
|
'the Hosted tool-turn IT and the failover E2E lane must both exist for this oracle to mean anything',
|
|
).toBe(true);
|
|
for (const [file, heading, claim] of [
|
|
[
|
|
'docs/design/2026-09-25-managed-agent-review-corrections.md',
|
|
'## Remaining integration gates',
|
|
/what remains unproven is[^.]*\./gi,
|
|
],
|
|
[
|
|
'docs/design/2026-09-25-managed-agent-review-corrections.zh-CN.md',
|
|
'## 剩余集成门禁',
|
|
/仍未证明[^。]*。/g,
|
|
],
|
|
]) {
|
|
const doc = read(file);
|
|
expect(doc, `${file} must keep its merge-gates section`).toContain(
|
|
heading,
|
|
);
|
|
const gates = doc.slice(doc.indexOf(heading));
|
|
for (const [sentence] of gates.matchAll(claim)) {
|
|
expect(
|
|
sentence,
|
|
`${file} lists CI-gated Hosted capabilities as unproven`,
|
|
).not.toMatch(
|
|
/tool turns|durable-store adapter|worker bundle|工具 Turn/i,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('pins the attach-time generation fence in the Harness attachment contract', () => {
|
|
// The attachment paragraph publishes which identities an attach does NOT
|
|
// compare. The Harness keys its in-memory Session by sessionId alone and
|
|
// compares no tenant on attach, so the paragraph must say exactly that —
|
|
// an integrator who reads a tenant-keyed coalescing claim leaves tenant
|
|
// scoping out of their own gateway on the recovery redrive, which is not
|
|
// fenced. The Harness writer generation is the one identity that IS
|
|
// enforced — the contract middleware answers a stale boot id with 409
|
|
// hosted_harness_generation_mismatch and the attach handler rejects a
|
|
// store descriptor whose writerId differs before any coalescing — so the
|
|
// paragraph must name that rejection: an integrator reading "not
|
|
// rejected" for the generation omits the 409 path and every attach fails
|
|
// after a Harness restart with no documented way out. Each fence pin
|
|
// below matches a polarity phrase, not the bare name: the name alone
|
|
// stays green when the paragraph says the generation is not rejected or
|
|
// the store fence is shipped behavior.
|
|
const readme = read('packages/managed-agent-server/README.md');
|
|
const anchor = readme.indexOf('The Java connector caches an attachment');
|
|
expect(anchor).toBeGreaterThan(-1);
|
|
const end = readme.indexOf('\n\n', anchor);
|
|
expect(end).toBeGreaterThan(anchor);
|
|
const paragraph = readme.slice(anchor, end).replace(/\s+/g, ' ');
|
|
expect(paragraph).toContain(
|
|
'keys its in-memory Session by `sessionId` alone',
|
|
);
|
|
expect(paragraph).toContain(
|
|
'fails closed with `409 hosted_harness_generation_mismatch`',
|
|
);
|
|
expect(paragraph).toContain(
|
|
'`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: () => '',
|
|
name: 'Only',
|
|
}),
|
|
).rejects.toThrow(/exited early: Only\n/);
|
|
});
|
|
|
|
// 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: () => '',
|
|
name: 'Only',
|
|
}),
|
|
).rejects.toThrow(/exited early: Only\n/);
|
|
});
|
|
|
|
// The model-round terminal wait watches the control plane and the Harness
|
|
// generation finishing the round; either one dying must end the wait and
|
|
// say which one died, and a deadline must still report both tails.
|
|
it('waitUntil watches every child it is given', async () => {
|
|
const { waitUntil } = loadWaitUntil();
|
|
const live = (name) => ({
|
|
child: { exitCode: null, signalCode: null },
|
|
log: () => `${name} log`,
|
|
name,
|
|
});
|
|
const dead = (name) => ({
|
|
child: { exitCode: 1, signalCode: null },
|
|
log: () => `${name} log`,
|
|
name,
|
|
});
|
|
await expect(
|
|
waitUntil('probe', () => false, 300, [dead('First'), live('Second')]),
|
|
).rejects.toThrow(/exited early: First\nFirst log/);
|
|
await expect(
|
|
waitUntil('probe', () => false, 300, [live('First'), dead('Second')]),
|
|
).rejects.toThrow(/exited early: Second\nSecond log/);
|
|
await expect(
|
|
waitUntil('probe', () => false, 300, [live('First'), live('Second')]),
|
|
).rejects.toThrow(/did not become ready\nFirst log\nSecond log$/);
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toMatch(
|
|
/waitForTerminal\(\s*replacementSpringUrl,\s*tenant,\s*session\.id,\s*0,\s*\[replacementSpring, finishingHarness\],/,
|
|
);
|
|
});
|
|
|
|
// 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(5);
|
|
expect(
|
|
source.match(/approvalResultJson = JSON\.stringify\(/g),
|
|
).toHaveLength(1);
|
|
expect(source.match(/g3SummaryJson = JSON\.stringify\(/g)).toHaveLength(1);
|
|
expect(source.match(/fencedWriterJson = JSON\.stringify\(/g)).toHaveLength(
|
|
2,
|
|
);
|
|
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 approvalResultJson: string \| undefined;$/m);
|
|
expect(source).toMatch(/^let g3SummaryJson: 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);
|
|
const approvalPrint = source.indexOf('console.log(approvalResultJson)');
|
|
const summaryPrint = source.indexOf('console.log(g3SummaryJson)');
|
|
expect(approvalPrint).toBeGreaterThan(signalThrow);
|
|
expect(summaryPrint).toBeGreaterThan(approvalPrint);
|
|
expect(summaryPrint).toBeLessThan(print);
|
|
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 lease polls (writer and dispatch lease expiry, the
|
|
// model-round arm's second writer lease, and the approval fault arms'
|
|
// post-crash writer lease) 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),
|
|
'every lease poll must derive the probe timeout from the waitUntil budget',
|
|
).toHaveLength(4);
|
|
// The frozen arm's snapshot fans one budget out over several
|
|
// synchronous probes; each takes its share, not the whole budget.
|
|
const fenced = source.slice(
|
|
source.indexOf('const fencedState = ('),
|
|
source.indexOf('// The replacement must go quiet first'),
|
|
);
|
|
const probes = Number(source.match(/const fencedProbes = (\d+);/)?.[1]);
|
|
expect(fenced).toContain(
|
|
'const probeMs = Math.max(1, Math.floor(timeoutMs / fencedProbes));',
|
|
);
|
|
expect(fenced.match(/runMysql\(/g)).toHaveLength(probes);
|
|
expect(fenced.match(/^\s*probeMs,$/gm)).toHaveLength(probes);
|
|
expect(fenced).not.toMatch(/^\s*timeoutMs,$/m);
|
|
});
|
|
|
|
// The model-round arm samples the head once per generation: each
|
|
// takeover must advance the writer, the journal and the committed
|
|
// sequence, not the writer generation alone.
|
|
it('requires every head column to advance across the model-round takeovers', () => {
|
|
const source = read('scripts/run-managed-agent-server-e2e.ts');
|
|
expect(source).toMatch(
|
|
/!\[0, 1, 2\]\.every\(\s*\(column\) => head\[column\] > heads\[index - 1\]\[column\],?\s*\)/,
|
|
);
|
|
expect(source).toMatch(
|
|
/const currentHead = \(\) =>\s*runMysql\(\s*mysqlPort,\s*`SELECT writer_generation, journal_revision, committed_sequence FROM/,
|
|
);
|
|
});
|
|
|
|
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/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/managed-agent-server/pom.xml').matchAll(
|
|
/<classifier>([^<]+)<\/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
|
|
// <artifactId> and <version> 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/managed-agent-server/pom.xml').match(pattern);
|
|
expect(match?.[0]).toContain(
|
|
'<artifactId>qwen-managed-agent-server</artifactId>',
|
|
);
|
|
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 <version>');
|
|
// 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 =
|
|
'<artifactId>qwen-managed-agent-server</artifactId>\n <version>\n 9.9.9\n </version>';
|
|
expect(wrapped.match(pattern)?.[1]).toBe('9.9.9');
|
|
expect(
|
|
'<artifactId>qwen-managed-agent-server</artifactId><version></version>'.match(
|
|
pattern,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('keeps the image on the loopback default and the jar guard loud', () => {
|
|
const dockerfile = read('packages/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/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/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/<pid>/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/<pid>/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 <db-host>
|
|
// 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/managed-agent-server/Dockerfile');
|
|
const stage = dockerfile.match(
|
|
/cd packages\/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/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 });
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|