diff --git a/packages/cli/src/agent-view/supervisor-process.test.ts b/packages/cli/src/agent-view/supervisor-process.test.ts index 413fb33aab..82cc06a6e1 100644 --- a/packages/cli/src/agent-view/supervisor-process.test.ts +++ b/packages/cli/src/agent-view/supervisor-process.test.ts @@ -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); diff --git a/packages/cli/src/agent-view/supervisor-process.ts b/packages/cli/src/agent-view/supervisor-process.ts index 7cb18705ca..8ec637e160 100644 --- a/packages/cli/src/agent-view/supervisor-process.ts +++ b/packages/cli/src/agent-view/supervisor-process.ts @@ -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}`; diff --git a/packages/cli/src/agent-view/supervisor-server.test.ts b/packages/cli/src/agent-view/supervisor-server.test.ts index 5ebc78f0fb..4878d47de4 100644 --- a/packages/cli/src/agent-view/supervisor-server.test.ts +++ b/packages/cli/src/agent-view/supervisor-server.test.ts @@ -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((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); diff --git a/packages/cli/src/agent-view/supervisor-store.test.ts b/packages/cli/src/agent-view/supervisor-store.test.ts index 21d080e7e3..f12dc17a00 100644 --- a/packages/cli/src/agent-view/supervisor-store.test.ts +++ b/packages/cli/src/agent-view/supervisor-store.test.ts @@ -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( diff --git a/packages/cli/src/agent-view/supervisor-store.ts b/packages/cli/src/agent-view/supervisor-store.ts index c88c8e803a..5cbc657c58 100644 --- a/packages/cli/src/agent-view/supervisor-store.ts +++ b/packages/cli/src/agent-view/supervisor-store.ts @@ -167,7 +167,7 @@ export async function writeAgentViewSessionState( options: StoreOptions = {}, ): Promise { 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 { 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 { 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 { 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 { 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 { + 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']); diff --git a/packages/cli/src/agent-view/terminal-bridge.test.ts b/packages/cli/src/agent-view/terminal-bridge.test.ts index 701ca16a82..9f75f50209 100644 --- a/packages/cli/src/agent-view/terminal-bridge.test.ts +++ b/packages/cli/src/agent-view/terminal-bridge.test.ts @@ -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();