mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* 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]>
171 lines
6.4 KiB
TypeScript
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');
|
|
});
|
|
});
|