Files
qwen-code/integration-tests/helpers/hosted-relay-headers.test.ts
T
Heyang Wangandwangheyang 8666ab091f fix(cli): reject malformed hosted shell Unicode before admission (#13678)
* fix(cli): reject malformed hosted shell Unicode before admission

Prevent lone UTF-16 surrogates from reaching Workspace acquisition
or execution while preserving valid Unicode and durable history.

- Refuse whole batches before Hooks and native tool admission
- Stop malformed sequential Hook rewrites and restore safe refusals
- Suppress post-tool callbacks for live and replayed Unicode refusals
- Cover local/O2 persistence, same-turn correction, and Session reuse
- Document the admission design and local acceptance evidence

* fix(cli): preserve durable hosted shell Unicode refusals

Keep malformed Shell input refusals bounded and recoverable after
Hook rewrites without losing policy denials or the original store error.

- Normalize optional arguments and validate reduced refusal evidence
- Fold nested permission rewrites and bound assembled Hook receipts
- Read recovery resources concurrently while preserving event order
- Cover MCP and background guards, sibling denials and failed writes
- Stabilize Hook admission waits and verify the second Session alone

* fix(cli): keep hosted Unicode refusals replayable and batch-wide

Keep durable Unicode refusals correctable across terminal Hook
recovery and bounded receipts, without admitting sibling tools.

- Accept proven-unstarted Hooks while rejecting blocked evidence
- Preserve a fixed Unicode reason in byte-bounded Hook refusals
- Retain each sibling's own argument validation error
- Pin MCP admission, Hook handoff and continuation regression cases

* fix(cli): recover cancelled size-refused hook plans

Treat cancelled Hook plans as terminal when their durable evidence
proves a result or no dispatch, so Unicode batch refusals can replay.

- Preserve failure-closed checks for unresolved or contradictory evidence
- Cover bounded permission refusals and independent recovery guard failures
- Assert Java capture lanes and single successful dispatch in both sessions

* test(sdk-java): scope Unicode publication checks per session

Shared publication rows made the local gate depend on invocation order.
A global positive count also hid a missing secondary-session capture.

- Filter publication counts by the current tenant and each session
- Preserve local zero and o2 positive checks for both sessions

* test(sdk-java): verify hosted Unicode capture completion

Make local publication checks run against an available service,
and distinguish completed o2 captures from reservation rows.

- Register publication fixtures for both capture modes
- Require referenced and complete results in each o2 session

* test(sdk-java): align Unicode gate with managed schema

Keep the hosted Unicode oracle runnable after schema consolidation.
Use canonical tables without changing the per-session completion
and single-dispatch assertions.

- Query captures from ma_output_capture
- Query dispatch evidence from ma_tool_execution

* test(managed-agent): align Unicode gate with main contracts

Keep the hosted Unicode gate compatible with the server's updated
Checkstyle rules and object-store contract after the module move.

- Add braces around the missing-object fixture refusal
- Add braces around the local-mode empty-capture assertion
- Align the fixture with the current object-store validation method

* test(managed-agent): register native macOS workspace fixtures

The Hosted tool-turn fixture registered guarded workspaces only on
Linux, leaving corrected Shell calls unavailable on supported macOS
hosts after malformed input had been refused.

- Match fixture registration to the native storage guard platforms
- Exercise the existing physical checks for both supported host systems

Native macOS local/O2 tests and the non-Unicode workspace control pass
with the original assertions, physical guards and execution deadlines.

---------

Co-authored-by: wangheyang <[email protected]>
2026-10-23 02:16:46 +00:00

171 lines
6.4 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { once } from 'node:events';
import { readdirSync, readFileSync } from 'node:fs';
import { createServer, type Server } from 'node:http';
import net from 'node:net';
import type { AddressInfo, Socket } from 'node:net';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { relayedHeaders, relayUpstream } from './hosted-relay-headers.js';
const servers: Server[] = [];
async function listen(server: Server) {
servers.push(server);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
server.close();
await once(server, 'close');
}
});
describe('Hosted proxy header relay', () => {
it('keeps only the end-to-end headers that describe the buffered body', () => {
expect(
relayedHeaders(
new Headers({
'cache-control': 'no-store',
// Two mixed-case tokens after ", " pin the dynamic branch's
// split/trim/lowercase; keep them out of the expectation, and keep
// 'keep-alive' out of this list — the dynamic branch would mask
// the static NOT_RELAYED entry this case witnesses.
connection: 'X-Upstream-Hop, X-Second-Hop',
'content-encoding': 'identity',
'content-length': '11',
'content-type': 'application/json',
'keep-alive': 'timeout=60',
'proxy-authenticate': 'Basic',
'proxy-authorization': 'Basic upstream-proxy-credential',
'proxy-connection': 'keep-alive',
te: 'trailers',
trailer: 'x-qwen-trailer',
'transfer-encoding': 'chunked',
upgrade: 'h2c',
'x-qwen-resource-digest': 'sha256',
'x-qwen-resource-kind': 'workspace',
'x-qwen-resource-schema-version': '1',
'x-second-hop': '2',
'x-upstream-hop': '1',
}),
),
).toEqual({
'cache-control': 'no-store',
'content-type': 'application/json',
'x-qwen-resource-digest': 'sha256',
'x-qwen-resource-kind': 'workspace',
'x-qwen-resource-schema-version': '1',
});
});
it("advertises the proxy's own keep-alive window, not the upstream's", async () => {
// A raw TCP origin: Node's own server would append
// `Connection: keep-alive` to the reply, and the filter's dynamic branch
// would then drop the advertised Keep-Alive even with the static
// 'keep-alive' entry deleted, leaving this case green on that mutant.
const sockets = new Set<Socket>();
const raw = net.createServer((socket) => {
sockets.add(socket);
socket.once('data', () => {
// Hold the socket open: with a short-lived origin the mutant dies on
// a socket-reuse race instead of on the assertion. The 503 pins
// relayUpstream's status pass-through.
socket.write(
'HTTP/1.1 503 Service Unavailable\r\n' +
'Cache-Control: no-store\r\n' +
'Keep-Alive: timeout=60\r\n' +
'Content-Length: 2\r\n' +
'\r\n' +
'{}',
);
});
});
raw.listen(0, '127.0.0.1');
await once(raw, 'listening');
try {
const upstream = `http://127.0.0.1:${
(raw.address() as AddressInfo).port
}`;
const proxy = createServer(async (_req, res) => {
const response = await fetch(upstream);
relayUpstream(res, response, Buffer.from(await response.arrayBuffer()));
});
const response = await fetch(await listen(proxy));
expect(response.status).toBe(503);
expect(await response.text()).toBe('{}');
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('keep-alive')).toBe(
`timeout=${proxy.keepAliveTimeout / 1000}`,
);
} finally {
for (const socket of sockets) {
socket.destroy();
}
const closed = once(raw, 'close');
raw.close();
await closed;
}
});
it('keeps the Hosted relays calling the shared helper', () => {
// The sdk-java workflow step runs this file after `cd integration-tests`
// while the repo-root lanes use `--root ./integration-tests`, so resolve
// the drivers relative to this file, never process.cwd(). Read source
// text because importing a driver throws — each parses process.argv[2]
// at module load. Positive pin only: a new driver hand-rolling its relay
// is absent from the enumerated list and still passes. A new legitimate
// caller of the shared helper joins this list deliberately — the list
// itself is what this pin keeps countable.
const dir = dirname(fileURLToPath(import.meta.url));
const callers = readdirSync(dir)
.filter((name) => /^hosted-.*-driver\.ts$/.test(name))
.filter((name) =>
/\brelayUpstream\s*\(/.test(readFileSync(join(dir, name), 'utf8')),
)
.sort();
expect(callers).toEqual([
'hosted-channel-fault-driver.ts',
'hosted-latency-driver.ts',
'hosted-monitor-run-fault-driver.ts',
'hosted-process-crash-driver.ts',
'hosted-recovery-blocked-wedge-driver.ts',
'hosted-shell-output-driver.ts',
'hosted-shell-unicode-driver.ts',
'hosted-store-failure-driver.ts',
'hosted-workflow-fault-driver.ts',
'hosted-workspace-tool-turn-driver.ts',
]);
});
it('pins the probe driver to certify completed turns by their transcript', () => {
// R3-18 sentinel: classifying a probe turn from wall clock alone lets a
// failed turn journal green beside a wedged one's evidence; the
// transcript gate is the only honest completed-path verdict — removing
// it must turn this pin red.
const source = readFileSync(
join(
dirname(fileURLToPath(import.meta.url)),
'hosted-recovery-blocked-wedge-driver.ts',
),
'utf8',
);
const completedArm = source.slice(
source.indexOf('async function driveProbeTurn('),
source.indexOf('async function wedgeEvidence('),
);
expect(completedArm).toContain('/transcript?cursor=0&limit=256');
expect(completedArm).toContain("event.type === 'turn_complete'");
expect(completedArm).toContain('event.promptId === turn.promptId');
});
});