fix(cli): harden agent view store joins, writes, and coverage (#7799)

This commit is contained in:
qwen-code-dev-bot 2026-07-31 19:04:10 +00:00
parent 57764676ac
commit 9203dbd73f
6 changed files with 168 additions and 11 deletions

View file

@ -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);

View file

@ -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}`;

View file

@ -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);

View file

@ -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(

View file

@ -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']);

View file

@ -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();