mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-16 12:14:52 +00:00
fix(cli): harden agent view store joins, writes, and coverage (#7799)
This commit is contained in:
parent
57764676ac
commit
9203dbd73f
6 changed files with 168 additions and 11 deletions
|
|
@ -265,6 +265,23 @@ describe('createAgentViewSupervisorHandler', () => {
|
|||
expect(onShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('requests shutdown when only-exited managed sessions pass the grace period', async () => {
|
||||
const globalDir = await makeGlobalDir();
|
||||
await writeSession(globalDir, { processState: 'exited' });
|
||||
const onShutdown = vi.fn();
|
||||
const handler = createAgentViewSupervisorHandler({
|
||||
globalDir,
|
||||
hibernationPolicy: { autoExitGraceMs: 0 },
|
||||
onShutdown,
|
||||
});
|
||||
|
||||
await expect(handler.tickIdleHibernation()).resolves.toEqual({
|
||||
hibernated: [],
|
||||
shutdownRequested: true,
|
||||
});
|
||||
expect(onShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for the grace period to elapse before requesting shutdown', async () => {
|
||||
const globalDir = await makeGlobalDir();
|
||||
await writeSession(globalDir);
|
||||
|
|
|
|||
|
|
@ -77,9 +77,11 @@ export function getAgentViewSupervisorSocketPath(
|
|||
return primaryPath;
|
||||
}
|
||||
|
||||
// Fall back to a per-uid directory (created 0700 by prepareSocketPath) so a
|
||||
// predictable socket in a shared tmpdir cannot be squatted or read by
|
||||
// another local user.
|
||||
// Fall back to a per-uid directory under the runtime dir. prepareSocketPath
|
||||
// creates it 0700 when missing and the socket file is 0600, but the directory
|
||||
// name is predictable: on a shared multi-user tmpdir a pre-existing directory
|
||||
// is reused with its current owner and mode. Callers that need a hardened
|
||||
// path should pass a private 0700 runtimeDir (e.g. XDG_RUNTIME_DIR).
|
||||
const uid = process.getuid?.();
|
||||
const fallbackDir =
|
||||
uid === undefined ? `qwen-agent-view-${digest}` : `qwen-agent-view-${uid}`;
|
||||
|
|
|
|||
|
|
@ -301,6 +301,42 @@ describe('Agent View supervisor server', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rejects incompatible protocol versions on streaming ops', async () => {
|
||||
const { dir, socketPath } = await makeSocketPath();
|
||||
cleanupPaths.push(dir);
|
||||
const handler = {
|
||||
status: vi.fn(() => ({})),
|
||||
list: vi.fn(() => []),
|
||||
shutdown: vi.fn(() => ({})),
|
||||
subscribe: vi.fn(),
|
||||
};
|
||||
const server = createAgentViewSupervisorServer(handler, { socketPath });
|
||||
|
||||
await server.listen();
|
||||
try {
|
||||
const socket = net.createConnection(socketPath);
|
||||
socket.setEncoding('utf8');
|
||||
await new Promise<void>((resolve) => socket.once('connect', resolve));
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: 'bad-protocol-stream',
|
||||
protocolVersion: 999,
|
||||
op: 'subscribe',
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const line = await readLine(socket);
|
||||
expect(JSON.parse(line)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'incompatible_protocol' },
|
||||
});
|
||||
expect(handler.subscribe).not.toHaveBeenCalled();
|
||||
socket.destroy();
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('serves peek requests through the JSON IPC client', async () => {
|
||||
const { dir, socketPath } = await makeSocketPath();
|
||||
cleanupPaths.push(dir);
|
||||
|
|
|
|||
|
|
@ -357,6 +357,48 @@ describe('agent view supervisor store', () => {
|
|||
getAgentViewSessionPaths('abc123', { globalDir: tempDir }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes a dot-only session id instead of escaping the jobs dir', () => {
|
||||
expect(
|
||||
getAgentViewSessionPaths('..', { globalDir: tempDir }).sessionDir,
|
||||
).toBe(path.join(tempDir, 'jobs', '_'));
|
||||
});
|
||||
|
||||
it('joins roster entries to snapshots case-insensitively', async () => {
|
||||
await writeAgentViewSessionState(sessionState('ABC123'), {
|
||||
globalDir: tempDir,
|
||||
});
|
||||
await upsertAgentViewRosterEntry(
|
||||
rosterEntry('ABC123', { displayName: 'Upper', pinned: true }),
|
||||
{ globalDir: tempDir },
|
||||
);
|
||||
|
||||
const snapshots = await listAgentViewSessionSnapshots({
|
||||
globalDir: tempDir,
|
||||
});
|
||||
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(snapshots[0]).toMatchObject({
|
||||
sessionId: 'abc123',
|
||||
rosterEntry: { sessionId: 'ABC123', displayName: 'Upper', pinned: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unknown fields from a prior writer when merging a write', async () => {
|
||||
const paths = getAgentViewSessionPaths('session-1', { globalDir: tempDir });
|
||||
fs.mkdirSync(paths.sessionDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
paths.statePath,
|
||||
JSON.stringify({ ...sessionState('session-1'), futureField: 'keep' }),
|
||||
);
|
||||
|
||||
await writeAgentViewSessionState(sessionState('session-1'), {
|
||||
globalDir: tempDir,
|
||||
});
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(paths.statePath, 'utf8'));
|
||||
expect(raw.futureField).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
function rosterEntry(
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export async function writeAgentViewSessionState(
|
|||
options: StoreOptions = {},
|
||||
): Promise<void> {
|
||||
const paths = getAgentViewSessionPaths(state.sessionId, options);
|
||||
const existing = await readJsonRecord(paths.statePath);
|
||||
const existing = await readJsonRecordForWrite(paths.statePath);
|
||||
await writeJsonFile(paths.statePath, {
|
||||
...existing,
|
||||
...state,
|
||||
|
|
@ -204,7 +204,7 @@ export async function listAgentViewSessionSnapshots(
|
|||
const states = await listAgentViewSessionStates(options);
|
||||
const roster = await readAgentViewRoster(options);
|
||||
const rosterEntries = new Map(
|
||||
roster.sessions.map((entry) => [entry.sessionId, entry]),
|
||||
roster.sessions.map((entry) => [sanitizeSessionId(entry.sessionId), entry]),
|
||||
);
|
||||
const snapshots = await Promise.all(
|
||||
states.map(async (state) => ({
|
||||
|
|
@ -235,7 +235,7 @@ export async function writeAgentViewLaunch(
|
|||
options: StoreOptions = {},
|
||||
): Promise<void> {
|
||||
const paths = getAgentViewSessionPaths(launch.sessionId, options);
|
||||
const existing = await readJsonRecord(paths.launchPath);
|
||||
const existing = await readJsonRecordForWrite(paths.launchPath);
|
||||
await writeJsonFile(paths.launchPath, {
|
||||
...existing,
|
||||
...launch,
|
||||
|
|
@ -259,7 +259,7 @@ export async function writeAgentViewActivity(
|
|||
options: StoreOptions = {},
|
||||
): Promise<void> {
|
||||
const paths = getAgentViewSessionPaths(sessionId, options);
|
||||
const existing = await readJsonRecord(paths.activityPath);
|
||||
const existing = await readJsonRecordForWrite(paths.activityPath);
|
||||
await writeJsonFile(paths.activityPath, {
|
||||
...existing,
|
||||
...activity,
|
||||
|
|
@ -283,7 +283,7 @@ export async function writeAgentViewWorker(
|
|||
options: StoreOptions = {},
|
||||
): Promise<void> {
|
||||
const paths = getAgentViewSessionPaths(sessionId, options);
|
||||
const existing = await readJsonRecord(paths.workerPath);
|
||||
const existing = await readJsonRecordForWrite(paths.workerPath);
|
||||
await writeJsonFile(paths.workerPath, {
|
||||
...existing,
|
||||
...worker,
|
||||
|
|
@ -305,7 +305,7 @@ export async function writeAgentViewSupervisor(
|
|||
options: StoreOptions = {},
|
||||
): Promise<void> {
|
||||
const paths = getAgentViewStorePaths(options);
|
||||
const existing = await readJsonRecord(paths.supervisorPath);
|
||||
const existing = await readJsonRecordForWrite(paths.supervisorPath);
|
||||
await writeJsonFile(paths.supervisorPath, {
|
||||
...existing,
|
||||
...supervisor,
|
||||
|
|
@ -348,6 +348,30 @@ async function readJsonRecord(
|
|||
}
|
||||
}
|
||||
|
||||
async function readJsonRecordForWrite(
|
||||
filePath: string,
|
||||
): Promise<JsonRecord | undefined> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
// A missing file means there is nothing to merge. Any other read failure
|
||||
// (EMFILE, EIO) must surface: treating it as empty would silently drop
|
||||
// fields a previous or newer writer populated.
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return isRecord(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
// Corrupt contents have no fields worth preserving; overwriting recovers.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(
|
||||
filePath: string,
|
||||
value: JsonRecord,
|
||||
|
|
@ -449,7 +473,9 @@ function normalizeLaunch(
|
|||
expectedSessionId: string,
|
||||
): AgentViewLaunchFile | undefined {
|
||||
if (!raw) return undefined;
|
||||
const sessionId = stringValue(raw['sessionId']) ?? expectedSessionId;
|
||||
// Mirror normalizeSessionState: the sanitized directory name is the source
|
||||
// of truth, so a tampered launch.json cannot impersonate another session.
|
||||
const sessionId = expectedSessionId || stringValue(raw['sessionId']);
|
||||
const entrypoint = stringValue(raw['entrypoint']);
|
||||
const projectCwd = stringValue(raw['projectCwd']);
|
||||
const activeCwd = stringValue(raw['activeCwd']);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
bridgeAgentViewTerminal,
|
||||
type AgentViewTerminalBytes,
|
||||
|
|
@ -115,6 +115,40 @@ describe('bridgeAgentViewTerminal', () => {
|
|||
expect(pty.resizes).toEqual([{ columns: 120, rows: 40 }]);
|
||||
});
|
||||
|
||||
it('swallows a rejecting pty.resize without an unhandled rejection', async () => {
|
||||
const unhandled = vi.fn();
|
||||
process.on('unhandledRejection', unhandled);
|
||||
try {
|
||||
let resize: ((size: AgentViewTerminalSize) => void) | undefined;
|
||||
let releaseInput: (() => void) | undefined;
|
||||
const pty: AgentViewTerminalPty = {
|
||||
write: () => {},
|
||||
onData: () => ({ dispose: () => {} }),
|
||||
resize: () => Promise.reject(new Error('pty gone')),
|
||||
};
|
||||
const done = bridgeAgentViewTerminal({
|
||||
stdin: delayedInput((release) => {
|
||||
releaseInput = release;
|
||||
}),
|
||||
stdout: new MemoryWritable(),
|
||||
pty,
|
||||
onResize: (callback) => {
|
||||
resize = callback;
|
||||
},
|
||||
});
|
||||
|
||||
resize?.({ columns: 120, rows: 40 });
|
||||
// Let the rejected resize promise settle before ending the bridge.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
releaseInput?.();
|
||||
|
||||
await expect(done).resolves.toEqual({ reason: 'stdin-ended' });
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.removeListener('unhandledRejection', unhandled);
|
||||
}
|
||||
});
|
||||
|
||||
it('disposes listeners and resolves when detached', async () => {
|
||||
const pty = new FakeTerminalPty();
|
||||
const stdout = new MemoryWritable();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue