Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion e2e/path-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ import {
setSessionMode,
setSessionDangerLevel,
answerPathConfirmation,
stopSessionChat,
type TestClient,
type TestProject,
type TestServerHandle,
} from './utils/index.js'
import type { ServerMessage } from '@openfox/shared/protocol'
import type { ServerMessage, SessionStatePayload } from '@openfox/shared/protocol'
// Type for path confirmation payload
interface PathConfirmationPayload {
callId: string
Expand Down Expand Up @@ -499,4 +500,68 @@ describe('Path Security', () => {
expect(confirmations.length).toBe(0)
})
})

describe('Stopping While Waiting for a Path Confirmation', () => {
it('closes out the pending confirmation and clears the waiting state (no resurrection on reload)', async () => {
client.clearEvents()

// /home/test is outside the workdir (and outside /tmp) → confirmation.
// The write never completes: the query is stopped while it waits.
await client.send('chat.send', {
content: 'Write to /home/test/denied.txt with content "denied"',
})

const confirmation = await client.waitFor('chat.path_confirmation', undefined, 10000)
const callId = (confirmation.payload as PathConfirmationPayload).callId
const session = client.getSession()!

client.clearEvents()
await stopSessionChat(server.url, session.id)

// The final session.state broadcast must carry the clean state: not
// running and no pending confirmations. The client derives
// "Waiting for input" and the Allow/Deny buttons from exactly these
// fields, so both must clear.
const finalState = await client.waitFor(
'session.state',
(p) => {
const payload = p as SessionStatePayload
return payload.session.isRunning === false && payload.pendingConfirmations.length === 0
},
15000,
)
const state = finalState.payload as SessionStatePayload
expect(state.session.isRunning).toBe(false)
expect(state.pendingConfirmations).toEqual([])
expect(state.pendingQuestions ?? []).toEqual([])

// The resolution is also announced so other same-project clients clear
// their home-page waiting dot for this session.
const resolved = await client.waitFor(
'session.confirmation_resolved',
(p) => (p as { callId: string }).callId === callId,
10000,
)
expect((resolved.payload as { callId: string }).callId).toBe(callId)

// The REST status projection behind the "Waiting for input" tooltip
// must no longer report waiting for user input.
const status = (await fetch(`${server.url}/api/sessions/${session.id}/status`).then((r) => r.json())) as {
state: string
waitingForUser: boolean
}
expect(status.waitingForUser).toBe(false)
expect(status.state).not.toBe('waiting')

// Reload parity: a full session refetch must not resurrect the
// cancelled confirmation either.
const reloaded = (await fetch(`${server.url}/api/sessions/${session.id}`).then((r) => r.json())) as {
session: { isRunning: boolean }
pendingConfirmations?: Array<{ callId: string }>
}
expect(reloaded.session.isRunning).toBe(false)
expect(reloaded.pendingConfirmations ?? []).toEqual([])
expect(callId).not.toBe('')
})
})
})
38 changes: 23 additions & 15 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,14 +1046,12 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
}

// Cancel any active execution before deleting — mirrors /stop endpoint
const { stopSessionExecution } = await import('./session/chat-handler.js')
const { cancelQuestionsForSession, cancelPathConfirmationsForSession } = await import('./tools/index.js')
const { stopSessionExecution, cancelSessionInteractions } = await import('./session/chat-handler.js')

sessionManager.clearMessageQueue(sessionId)
stopSessionExecution(sessionId, sessionManager)
abortSession(sessionId)
cancelQuestionsForSession(sessionId, 'Session deleted')
cancelPathConfirmationsForSession(sessionId, 'Session deleted')
await cancelSessionInteractions(sessionId, sessionManager, 'Session deleted')

sessionManager.deleteSession(sessionId)
wssExports.broadcastAll({
Expand Down Expand Up @@ -1648,8 +1646,7 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
return res.status(404).json({ error: 'Session not found' })
}

const { stopSessionExecution } = await import('./session/chat-handler.js')
const { cancelQuestionsForSession, cancelPathConfirmationsForSession } = await import('./tools/index.js')
const { stopSessionExecution, cancelSessionInteractions } = await import('./session/chat-handler.js')

// Drain queued messages BEFORE stopping execution, so the QueueProcessor
// doesn't pick them up when running_changed fires from setRunning(false)
Expand All @@ -1660,11 +1657,18 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
stopSessionExecution(sessionId, sessionManager)
abortSession(sessionId)

cancelQuestionsForSession(sessionId, 'Session stopped by user')
cancelPathConfirmationsForSession(sessionId, 'Session stopped by user')
const cancelledCallIds = await cancelSessionInteractions(sessionId, sessionManager, 'Session stopped by user')

const eventStore = (await import('./events/index.js')).getEventStore()
eventStore.append(sessionId, { type: 'running.changed', data: { isRunning: false } })
// Mirror /confirm-path: tell every same-project client the confirmation
// is resolved so its home-page "waiting for input" dot clears (the
// session-scoped session.state above only reaches the session's own clients).
for (const callId of cancelledCallIds) {
wssExports.broadcastForSession(sessionId, {
type: 'session.confirmation_resolved',
sessionId,
payload: { sessionId, callId },
})
}

res.json({ success: true, queuedMessages })
})
Expand Down Expand Up @@ -3764,14 +3768,18 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
launchWorkflow: (sessionId, launch) => deferTasksLaunchWorkflow(sessionId, launch),
stopSession: (sessionId) => {
void (async () => {
const { stopSessionExecution } = await import('./session/chat-handler.js')
const { cancelQuestionsForSession, cancelPathConfirmationsForSession } = await import('./tools/index.js')
const { stopSessionExecution, cancelSessionInteractions } = await import('./session/chat-handler.js')
sessionManager.clearMessageQueue(sessionId)
stopSessionExecution(sessionId, sessionManager)
abortSession(sessionId)
cancelQuestionsForSession(sessionId, 'Session stopped by user')
cancelPathConfirmationsForSession(sessionId, 'Session stopped by user')
getEventStore().append(sessionId, { type: 'running.changed', data: { isRunning: false } })
const cancelledCallIds = await cancelSessionInteractions(sessionId, sessionManager, 'Session stopped by user')
for (const callId of cancelledCallIds) {
wssExports.broadcastForSession(sessionId, {
type: 'session.confirmation_resolved',
sessionId,
payload: { sessionId, callId },
})
}
})().catch((error) => {
logger.error(`MCP stopSession failed for ${sessionId}`, {
error: error instanceof Error ? error.message : String(error),
Expand Down
179 changes: 179 additions & 0 deletions src/server/session/chat-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { initDatabase, closeDatabase, getDatabase } from '../db/index.js'
import { initEventStore, getEventStore } from '../events/index.js'
import { foldPendingConfirmations, foldIsRunning } from '../events/folding.js'
import { SessionManager, type SessionEvent } from './manager.js'
import { createProject } from '../db/projects.js'
import { projectSessionStatus } from '../routes/session-status.js'
import {
AskUserInterrupt,
askUserTool,
cancelQuestionsForSession,
getPendingQuestionsForSession,
} from '../tools/ask.js'
import { hasPendingPathConfirmation, requestPathAccess } from '../tools/path-security.js'
import type { ToolContext } from '../tools/types.js'
import type { Config } from '../../shared/types.js'
import { cancelSessionInteractions } from './chat-handler.js'

// Default to a Unix shell so the path-extraction assumptions hold on any host.
vi.mock('../utils/platform.js', () => ({
getPlatformShell: vi.fn(() => ({ command: '/bin/sh', args: ['-c'] })),
}))

const REAL_PLATFORM = process.platform

const mockProviderManager = {
getCurrentModelContext: () => 200000,
}

function createTestConfig(): Config {
return {
llm: { baseUrl: 'http://localhost:8000/v1', model: 'test', timeout: 1000, idleTimeout: 30000, backend: 'vllm' },
context: { maxTokens: 100000, compactionThreshold: 0.85, compactionTarget: 0.6 },
agent: { maxIterations: 10, maxConsecutiveFailures: 3, toolTimeout: 1000 },
server: { port: 3000, host: 'localhost' },
database: { path: ':memory:' },
workdir: process.cwd(),
}
}

async function waitForPending(callId: string): Promise<void> {
for (let attempt = 0; attempt < 200; attempt++) {
if (hasPendingPathConfirmation(callId)) return
await new Promise<void>((resolve) => setTimeout(resolve, 5))
}
throw new Error(`Confirmation ${callId} never went pending`)
}

/**
* cancelSessionInteractions — the shared stop-tail used by the /stop endpoint,
* the MCP stopSession tool, and session deletion.
*
* Stopping a query while it waits on user interaction (Allow/Deny path
* confirmation or ask_user) must leave the session in a clean, converging
* state: no pending questions, no pending confirmations (closed out in the
* event log so they cannot resurrect on the next session.state broadcast or
* reload), a terminal running.changed, and a final session_updated emission so
* live clients re-derive the clean state.
*/
describe('cancelSessionInteractions', () => {
let testDir: string
let workdir: string
let sessionManager: SessionManager
let sessionId: string
let emitted: SessionEvent[]
let unsubscribe: () => void

beforeEach(async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
closeDatabase()
initDatabase(createTestConfig())
initEventStore(getDatabase())

testDir = join(tmpdir(), `openfox-stop-tail-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await mkdir(testDir, { recursive: true })
workdir = testDir

sessionManager = new SessionManager(mockProviderManager as any)
const project = createProject('stop-tail-test', testDir)
sessionId = sessionManager.createSession(project.id).id

emitted = []
unsubscribe = sessionManager.subscribe((event) => emitted.push(event))
})

afterEach(async () => {
unsubscribe()
Object.defineProperty(process, 'platform', { value: REAL_PLATFORM, configurable: true })
closeDatabase()
await rm(testDir, { recursive: true, force: true }).catch(() => {})
})

it('cancels questions and path confirmations, records the terminal state, and forces a final session_updated', async () => {
// One pending path confirmation (with its persisted pending event).
const onEvent = vi.fn()
const confirmationPromise = requestPathAccess(
['/etc/passwd'],
workdir,
sessionId,
'call-stop-1',
'run_command',
onEvent,
'normal',
'cat /etc/passwd',
)
await waitForPending('call-stop-1')
const rejectedConfirmation = expect(confirmationPromise).rejects.toThrow('Session stopped by user')

// One pending ask_user question.
const context: ToolContext = {
workdir,
sessionId,
sessionManager,
toolCallId: 'ask-stop-1',
}
let interrupt: unknown = null
try {
await askUserTool.execute({ question: 'What should I do?' }, context)
} catch (err) {
interrupt = err
}
expect(interrupt).toBeInstanceOf(AskUserInterrupt)
expect(getPendingQuestionsForSession(sessionId)).toHaveLength(1)

const cancelledCallIds = await cancelSessionInteractions(sessionId, sessionManager, 'Session stopped by user')

// The cancelled path confirmation is reported back so the caller can
// broadcast a session.confirmation_resolved for it.
expect(cancelledCallIds).toEqual(['call-stop-1'])

// In-memory interaction gates are cleared.
expect(getPendingQuestionsForSession(sessionId)).toEqual([])
expect(hasPendingPathConfirmation('call-stop-1')).toBe(false)
await rejectedConfirmation

// Event log: the confirmation is closed out (no resurrection on fold),
// and the terminal running state is recorded.
const events = getEventStore().getEvents(sessionId)
expect(foldPendingConfirmations(events)).toEqual([])
expect(foldIsRunning(events)).toBe(false)
const runningChanged = events.filter((e) => e.type === 'running.changed')
expect(runningChanged.length).toBeGreaterThan(0)
expect(runningChanged[runningChanged.length - 1]!.data).toEqual({ isRunning: false })

// The final emission is a session_updated so the WS layer re-broadcasts
// session.state with the clean fold (the last broadcast converges).
const last = emitted[emitted.length - 1]
expect(last?.type).toBe('session_updated')
if (last?.type === 'session_updated') {
expect(last.session.id).toBe(sessionId)
}

// The status projection (drives the "Waiting for input" tooltip) no
// longer reports waiting for user input.
const status = projectSessionStatus({
session: sessionManager.getSession(sessionId)!,
pendingQuestionsCount: getPendingQuestionsForSession(sessionId).length,
pendingConfirmationsCount: foldPendingConfirmations(events).length,
activeWorkflowStepName: null,
})
expect(status.state).not.toBe('waiting')
expect(status.waitingForUser).toBe(false)
})

it('is a no-op for a session with nothing pending and never throws', async () => {
expect(await cancelSessionInteractions(sessionId, sessionManager, 'Session stopped by user')).toEqual([])

const events = getEventStore().getEvents(sessionId)
expect(foldIsRunning(events)).toBe(false)
const runningChanged = events.filter((e) => e.type === 'running.changed')
expect(runningChanged[runningChanged.length - 1]!.data).toEqual({ isRunning: false })
expect(getPendingQuestionsForSession(sessionId)).toEqual([])
expect(cancelQuestionsForSession(sessionId, 'noop')).toBe(0)
expect(emitted.some((e) => e.type === 'session_updated')).toBe(true)
})
})
31 changes: 31 additions & 0 deletions src/server/session/chat-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,34 @@ export function stopSessionExecution(sessionId: string, sessionManager: SessionM
sessionManager.clearPauseState(sessionId)
sessionManager.setRunning(sessionId, false)
}

/**
* Cancel all pending user-interaction gates for a session (ask_user
* questions, path confirmations), record the terminal running state in the
* event log, and force a final session.state re-broadcast.
*
* The session.state broadcast triggered by setRunning(false) fires BEFORE the
* gates are cancelled, and cancelled path confirmations only leave the
* event-sourced fold once a path.confirmation_responded event closes them
* out. Without the explicit re-broadcast here, clients would converge on the
* stale "waiting for input" state — the Allow/Deny buttons and tooltip would
* stick after Stop.
*
* @returns the callIds of the cancelled path confirmations, so the caller can
* broadcast a session.confirmation_resolved for each (cross-delivered to
* other clients of the same project, clearing their home-page waiting dots).
*/
export async function cancelSessionInteractions(
sessionId: string,
sessionManager: SessionManager,
reason: string,
): Promise<string[]> {
const { cancelQuestionsForSession, cancelPathConfirmationsForSession, getPendingPathConfirmationCallIds } =
await import('../tools/index.js')
const cancelledCallIds = getPendingPathConfirmationCallIds(sessionId)
cancelQuestionsForSession(sessionId, reason)
cancelPathConfirmationsForSession(sessionId, reason)
getEventStore().append(sessionId, { type: 'running.changed', data: { isRunning: false } })
sessionManager.emitBranchChange(sessionId)
return cancelledCallIds
}
1 change: 1 addition & 0 deletions src/server/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ export {
PathAccessDeniedError,
requestPathAccess,
cancelPathConfirmationsForSession,
getPendingPathConfirmationCallIds,
autoApprovePendingConfirmationsForSession,
providePathConfirmation,
getConfirmationSessionId,
Expand Down
Loading
Loading