import path from 'node:path'; import { developerToolsFromPolicy } from './capabilities.mjs'; import { buildSessionMemoryEntries } from './user-memory-profile.mjs'; import { buildSandboxSessionConstraints } from './user-publish.mjs'; function extensionName(config) { return config?.name ?? null; } export function allowedExtensionNames(extensionOverrides) { if (!extensionOverrides) return null; return new Set(extensionOverrides.map((item) => item.name).filter(Boolean)); } export function extensionToolsKey(config) { const tools = config?.available_tools ?? config?.availableTools ?? []; return [...tools].sort().join('\0'); } export function extensionConfigsMatch(sessionExt, desiredConfig) { if (extensionName(sessionExt) !== extensionName(desiredConfig)) return false; return extensionToolsKey(sessionExt) === extensionToolsKey(desiredConfig); } export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) { const current = currentExtensions ?? []; const desired = desiredExtensions ?? []; const toRemove = []; const toAdd = []; for (const ext of current) { const name = extensionName(ext); if (!name) continue; const wanted = desired.find((item) => extensionName(item) === name); if (!wanted) { toRemove.push(name); continue; } if (!extensionConfigsMatch(ext, wanted)) { toRemove.push(name); toAdd.push(wanted); } } for (const config of desired) { const name = extensionName(config); if (!name) continue; const exists = current.some((ext) => extensionName(ext) === name); if (!exists) { toAdd.push(config); } } return { toRemove, toAdd }; } function samePath(left, right) { if (!left || !right) return false; return path.resolve(left) === path.resolve(right); } async function readJson(upstream) { const text = await upstream.text(); if (!upstream.ok) { throw new Error(text || `upstream ${upstream.status}`); } return text ? JSON.parse(text) : null; } function isInvalidDirectoryPathError(err) { return err instanceof Error && /Invalid directory path/i.test(err.message); } /** * Re-apply H5 session policy after resume so stale extension sets cannot bypass capability limits. */ export async function reconcileAgentSession( apiFetch, sessionId, { workingDir, sessionPolicy, sandboxConstraints = null, userContext = null, userMemories = null, tolerateInvalidWorkingDir = false, }, ) { if (sessionPolicy?.unrestricted) return; const session = await readJson(await apiFetch(`/sessions/${sessionId}`)); let needsRestart = false; if (workingDir && !samePath(session?.working_dir, workingDir)) { try { await readJson( await apiFetch('/agent/update_working_dir', { method: 'POST', body: JSON.stringify({ session_id: sessionId, working_dir: workingDir }), }), ); } catch (err) { if (!(tolerateInvalidWorkingDir && isInvalidDirectoryPathError(err))) { throw err; } console.warn( `[session-reconcile] skip invalid working_dir during resume for session ${sessionId}: ${ err.message }`, ); } } if (sessionPolicy?.gooseMode && session?.goose_mode !== sessionPolicy.gooseMode) { await readJson( await apiFetch('/agent/update_session', { method: 'POST', body: JSON.stringify({ session_id: sessionId, goose_mode: sessionPolicy.gooseMode, }), }), ); needsRestart = true; } const desired = sessionPolicy?.extensionOverrides ?? []; const allowed = allowedExtensionNames(desired); const currentPayload = await readJson(await apiFetch(`/sessions/${sessionId}/extensions`)); const current = currentPayload?.extensions ?? []; let removedAny = false; for (const ext of current) { const name = extensionName(ext); if (!name || allowed.has(name)) continue; await readJson( await apiFetch('/agent/remove_extension', { method: 'POST', body: JSON.stringify({ session_id: sessionId, name }), }), ); removedAny = true; } const refreshed = removedAny ? ((await readJson(await apiFetch(`/sessions/${sessionId}/extensions`)))?.extensions ?? []) : current; const { toRemove, toAdd } = extensionsNeedingRefresh(refreshed, desired); for (const name of toRemove) { await readJson( await apiFetch('/agent/remove_extension', { method: 'POST', body: JSON.stringify({ session_id: sessionId, name }), }), ); needsRestart = true; } for (const config of toAdd) { await readJson( await apiFetch('/agent/add_extension', { method: 'POST', body: JSON.stringify({ session_id: sessionId, config }), }), ); needsRestart = true; } if (needsRestart) { await readJson( await apiFetch('/agent/restart', { method: 'POST', body: JSON.stringify({ session_id: sessionId }), }), ); } const sandboxText = sandboxConstraints?.trim() ? buildSandboxSessionConstraints({ baseConstraints: sandboxConstraints, developerTools: developerToolsFromPolicy(sessionPolicy), }) : null; const memoryEntries = buildSessionMemoryEntries({ workingDir, sessionPolicy, sandboxConstraints: sandboxText, userContext, userMemories, }); if (memoryEntries.length > 0) { for (const entry of memoryEntries) { if (!entry.content?.trim()) continue; await apiFetch('/agent/harness_remember', { method: 'POST', body: JSON.stringify({ sessionId, content: entry.content, title: entry.title, }), }); } await apiFetch('/agent/harness_bootstrap', { method: 'POST', body: JSON.stringify({ sessionId, force: true, }), }); } }