diff --git a/MarvinOutputStyle/common/opencode-plugin.js b/MarvinOutputStyle/common/opencode-plugin.js index 0a24e9a..d2573be 100644 --- a/MarvinOutputStyle/common/opencode-plugin.js +++ b/MarvinOutputStyle/common/opencode-plugin.js @@ -83,6 +83,10 @@ export function createOpenCodePlugin(pluginRootUrl) { ownedSandboxes: new Map(), ownedSandboxIdentities: new Map(), deletedSandboxSessions: new Set(), + sessionParents: new Map(), + freeSandboxes: [], + lastSessionSandbox: new Map(), + activeBashCounts: new Map(), sandboxSweepDone: false, disposed: false, }; @@ -115,6 +119,11 @@ export function createOpenCodePlugin(pluginRootUrl) { "tool.execute.before": async (input, output) => { loadMetadata(pluginRoot, state); applyPreToolUseRules(state, input, output); + trackBashStart(state, input); + }, + + "tool.execute.after": async (input) => { + trackBashEnd(state, input); }, "shell.env": async (input, output) => { @@ -737,9 +746,7 @@ async function configureShellEnvironment(pluginRoot, state, input, output) { const sessionID = safeSessionID(input.sessionID ?? input.cwd ?? "default"); if (state.disposed || state.deletedSandboxSessions.has(sessionID)) return; const sandboxRoot = secureSandboxRoot(state); - // Plugin instances never reuse a sandbox path, so an older instance cannot - // delete a replacement instance's active sandbox after an ownership check. - const sandboxBase = path.join(sandboxRoot.path, `${sessionID}-${state.instanceID}`); + const sandboxBase = leaseSandboxPath(state, sandboxRoot, sessionID); state.ownedSandboxes.set(sessionID, sandboxBase); if (!state.processStartToken) { @@ -1129,19 +1136,180 @@ function releaseSandboxOwnerLock(ownerLock) { } } +// Field-diagnosable pool decisions: when $TMPDIR/xbt-sandbox-debug exists, +// every lease/release decision (and every refusal, with its reason) is +// appended to $TMPDIR/xbt-sandbox-debug.log. Inert without the flag file. +function sandboxDebugLog(message) { + try { + const flag = path.join(os.tmpdir(), "xbt-sandbox-debug"); + if (!fs.existsSync(flag)) return; + fs.appendFileSync(`${flag}.log`, `${new Date().toISOString()} ${message}\n`); + } catch { + // Diagnostics must never interfere with sandbox management. + } +} + async function handlePluginEvent(state, event) { + if (event?.type === "session.created" || event?.type === "session.updated") { + if (state.disposed) return; + recordSessionParent(state, event.properties?.info); + return; + } + + if (event?.type === "session.idle") { + const rawSessionID = event.properties?.sessionID; + if (state.disposed || typeof rawSessionID !== "string" || !rawSessionID) return; + const safeID = safeSessionID(rawSessionID); + // A stale idle — delivered after the session was already re-prompted — + // must not pool a sandbox an in-flight bash command is still writing to. + // Skipping is safe: the re-prompted turn ends with its own idle, which + // performs the release once no command is running. + if ((state.activeBashCounts.get(safeID) ?? 0) > 0) { + sandboxDebugLog( + `idle ${safeID}: release refused, ${state.activeBashCounts.get(safeID)} bash in flight`, + ); + return; + } + // Only subagent (child) sessions release on idle. A main session going + // idle is just the end of a turn; it keeps its sandbox for the next one. + if (state.sessionParents.get(safeID)) { + releaseSandboxToPool(state, safeID); + } else { + sandboxDebugLog( + `idle ${safeID}: release refused, parent=${JSON.stringify(state.sessionParents.get(safeID) ?? null)} known=${state.sessionParents.has(safeID)}`, + ); + } + return; + } + if (event?.type !== "session.deleted") return; const sessionID = event.properties?.info?.id; if (typeof sessionID !== "string" || !sessionID) return; const safeID = safeSessionID(sessionID); + const parentID = + event.properties?.info?.parentID ?? state.sessionParents.get(safeID); + // Only a session this instance actually observed as parentless counts as a + // main session. A deletion for a session we never tracked (created before + // the plugin loaded) proves nothing about the pool, so it must not drain + // warm caches that may belong to a still-live main. + const knownMain = !parentID && state.sessionParents.has(safeID); state.deletedSandboxSessions.add(safeID); + state.sessionParents.delete(safeID); + state.lastSessionSandbox.delete(safeID); + state.activeBashCounts.delete(safeID); state.xcodeMcpCache.delete(safeID); state.xcodeMcpApprovalSessions.delete(safeID); const activeApproval = state.xcodeMcpApprovalHelpers.get(safeID); await stopXcodeMcpApproval(activeApproval); await removeOwnedSandbox(state, safeID); + // A deleted main session drains the free pool: pooled sandboxes existed to + // serve its subagents, so reclaim the disk now rather than at dispose. + // Sandboxes still leased by live sessions are untouched. + sandboxDebugLog( + `deleted ${safeID}: knownMain=${knownMain} drain=${knownMain ? state.freeSandboxes.length : 0} pooled`, + ); + if (knownMain) await drainFreeSandboxes(state); +} + +// Leasing order: the sandbox this session already holds, then the sandbox it +// held before releasing (warm caches for a re-prompted subagent), then the +// most recently freed pool entry, then a fresh directory. Pool entries are +// revalidated before reuse and dropped if they fail the identity checks. +// Reused directories keep their original - name and +// are never renamed — SPM and Xcode state files embed absolute paths. +function leaseSandboxPath(state, sandboxRootIdentity, sessionID) { + const existing = state.ownedSandboxes.get(sessionID); + if (existing) return existing; + + const preferred = state.lastSessionSandbox.get(sessionID); + const preferredIndex = + typeof preferred === "string" + ? state.freeSandboxes.findIndex((entry) => entry.path === preferred) + : -1; + if (preferredIndex !== -1) { + const [entry] = state.freeSandboxes.splice(preferredIndex, 1); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused previous ${path.basename(entry.path)}`); + return entry.path; + } + // Discarded entries stay marked pending: this instance's sweep already + // ran, so without the marker a transient inspect failure would orphan + // the directory until another process claims the sandbox root. + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + while (state.freeSandboxes.length > 0) { + const entry = state.freeSandboxes.pop(); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused pooled ${path.basename(entry.path)}`); + return entry.path; + } + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + // Plugin instances never create a sandbox path used by another instance, so + // an older instance cannot delete a replacement instance's active sandbox + // after an ownership check. Within this instance the deterministic name can + // collide: pooling decouples directory names from sessions, so a sandbox + // named after this session may now be leased by a different session. Never + // share a live sandbox — fall back to a unique suffix instead. + const fresh = path.join(sandboxRootIdentity.path, `${sessionID}-${state.instanceID}`); + const leasedPaths = new Set(state.ownedSandboxes.values()); + if (!leasedPaths.has(fresh)) { + sandboxDebugLog(`lease ${sessionID}: fresh ${path.basename(fresh)}`); + return fresh; + } + const disambiguated = path.join(sandboxRootIdentity.path, `${sessionID}-${randomUUID()}`); + sandboxDebugLog(`lease ${sessionID}: fresh (collision) ${path.basename(disambiguated)}`); + return disambiguated; +} + +function recordSessionParent(state, info) { + const id = info?.id; + if (typeof id !== "string" || !id) return; + const safeID = safeSessionID(id); + const parentID = + typeof info.parentID === "string" && info.parentID ? info.parentID : null; + // A payload without parentID normally means "main session", but never let + // it downgrade a session we already classified: a partial update would + // otherwise stop a known child from releasing on idle and let its + // deletion drain the pool. + if (parentID === null && state.sessionParents.has(safeID)) return; + state.sessionParents.set(safeID, parentID); +} + +// In-flight bash tracking exists solely to gate the idle-release against +// stale events. Counts only ever prevent a release, so a missed +// tool.execute.after degrades to "the sandbox stays leased" — the pre-pool +// behavior, reclaimed on deletion/dispose — never to a premature release. +function trackBashStart(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + state.activeBashCounts.set(safeID, (state.activeBashCounts.get(safeID) ?? 0) + 1); +} + +function trackBashEnd(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + const count = state.activeBashCounts.get(safeID) ?? 0; + if (count <= 1) state.activeBashCounts.delete(safeID); + else state.activeBashCounts.set(safeID, count - 1); +} + +function releaseSandboxToPool(state, sessionID) { + const sandboxPath = state.ownedSandboxes.get(sessionID); + const identity = state.ownedSandboxIdentities.get(sessionID); + if (!sandboxPath || !identity) return; + + state.ownedSandboxes.delete(sessionID); + state.ownedSandboxIdentities.delete(sessionID); + state.lastSessionSandbox.set(sessionID, sandboxPath); + state.freeSandboxes.push({ path: sandboxPath, identity }); + sandboxDebugLog(`release ${sessionID}: pooled ${path.basename(sandboxPath)}`); } async function removeOwnedSandbox(state, sessionID) { @@ -1154,6 +1322,38 @@ async function removeOwnedSandbox(state, sessionID) { state.ownedSandboxIdentities.delete(sessionID); if (!state.sandboxRoot || !sandboxIdentity) return; + await quarantineAndRemoveSandbox(state, sandboxPath, sandboxIdentity); +} + +async function drainFreeSandboxes(state) { + const entries = state.freeSandboxes.splice(0, state.freeSandboxes.length); + if (entries.length === 0) return; + + const drainedPaths = new Set(entries.map((entry) => entry.path)); + for (const [sessionID, sandboxPath] of state.lastSessionSandbox) { + if (drainedPaths.has(sandboxPath)) state.lastSessionSandbox.delete(sessionID); + } + if (!state.sandboxRoot) return; + + // Removal is detached (/bin/rm) rather than awaited: draining several + // multi-GB trees in-process would stall serialized event delivery — the + // very lag that turns a delayed session.idle stale. + await Promise.all( + entries.map(async ({ path: sandboxPath, identity }) => { + markPendingSandboxCleanup(sandboxPath); + await quarantineAndRemoveSandbox(state, sandboxPath, identity, { + detach: true, + }); + }), + ); +} + +async function quarantineAndRemoveSandbox( + state, + sandboxPath, + sandboxIdentity, + { detach = false } = {}, +) { const quarantinedPath = await quarantineOwnedSandbox( sandboxPath, state.instanceID, @@ -1163,9 +1363,20 @@ async function removeOwnedSandbox(state, sessionID) { const cleanupIdentity = quarantinedPath ? relocatedSandboxIdentity(sandboxIdentity, quarantinedPath) : sandboxIdentity; + const removalPath = quarantinedPath || sandboxPath; + const expectedInstanceID = quarantinedPath ? "" : state.instanceID; + if (detach) { + await detachManagedSandbox( + removalPath, + expectedInstanceID, + state.sandboxRoot, + cleanupIdentity, + ); + return; + } await removeManagedSandbox( - quarantinedPath || sandboxPath, - quarantinedPath ? "" : state.instanceID, + removalPath, + expectedInstanceID, state.sandboxRoot, cleanupIdentity, ); @@ -1173,14 +1384,26 @@ async function removeOwnedSandbox(state, sessionID) { async function detachOwnedSandboxes(state) { state.disposed = true; - const sandboxes = [...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ - sandboxPath, - sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), - })); + const sandboxes = [ + ...[...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ + sandboxPath, + sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), + })), + ...state.freeSandboxes.map((entry) => ({ + sandboxPath: entry.path, + sandboxIdentity: entry.identity, + })), + ]; const sandboxPaths = [...new Set(sandboxes.map(({ sandboxPath }) => sandboxPath))]; for (const sandboxPath of sandboxPaths) markPendingSandboxCleanup(sandboxPath); state.ownedSandboxes.clear(); state.ownedSandboxIdentities.clear(); + // Pooled entries were folded into `sandboxes` above; clear the pool and + // session-tracking state so a disposed instance can never lease or release. + state.freeSandboxes.length = 0; + state.sessionParents.clear(); + state.lastSessionSandbox.clear(); + state.activeBashCounts.clear(); state.xcodeMcpCache.clear(); state.xcodeMcpApprovalSessions.clear(); state.xcodeMcpApprovalHelpers.clear(); diff --git a/PluginBase/common/opencode-plugin.js b/PluginBase/common/opencode-plugin.js index 0a24e9a..d2573be 100644 --- a/PluginBase/common/opencode-plugin.js +++ b/PluginBase/common/opencode-plugin.js @@ -83,6 +83,10 @@ export function createOpenCodePlugin(pluginRootUrl) { ownedSandboxes: new Map(), ownedSandboxIdentities: new Map(), deletedSandboxSessions: new Set(), + sessionParents: new Map(), + freeSandboxes: [], + lastSessionSandbox: new Map(), + activeBashCounts: new Map(), sandboxSweepDone: false, disposed: false, }; @@ -115,6 +119,11 @@ export function createOpenCodePlugin(pluginRootUrl) { "tool.execute.before": async (input, output) => { loadMetadata(pluginRoot, state); applyPreToolUseRules(state, input, output); + trackBashStart(state, input); + }, + + "tool.execute.after": async (input) => { + trackBashEnd(state, input); }, "shell.env": async (input, output) => { @@ -737,9 +746,7 @@ async function configureShellEnvironment(pluginRoot, state, input, output) { const sessionID = safeSessionID(input.sessionID ?? input.cwd ?? "default"); if (state.disposed || state.deletedSandboxSessions.has(sessionID)) return; const sandboxRoot = secureSandboxRoot(state); - // Plugin instances never reuse a sandbox path, so an older instance cannot - // delete a replacement instance's active sandbox after an ownership check. - const sandboxBase = path.join(sandboxRoot.path, `${sessionID}-${state.instanceID}`); + const sandboxBase = leaseSandboxPath(state, sandboxRoot, sessionID); state.ownedSandboxes.set(sessionID, sandboxBase); if (!state.processStartToken) { @@ -1129,19 +1136,180 @@ function releaseSandboxOwnerLock(ownerLock) { } } +// Field-diagnosable pool decisions: when $TMPDIR/xbt-sandbox-debug exists, +// every lease/release decision (and every refusal, with its reason) is +// appended to $TMPDIR/xbt-sandbox-debug.log. Inert without the flag file. +function sandboxDebugLog(message) { + try { + const flag = path.join(os.tmpdir(), "xbt-sandbox-debug"); + if (!fs.existsSync(flag)) return; + fs.appendFileSync(`${flag}.log`, `${new Date().toISOString()} ${message}\n`); + } catch { + // Diagnostics must never interfere with sandbox management. + } +} + async function handlePluginEvent(state, event) { + if (event?.type === "session.created" || event?.type === "session.updated") { + if (state.disposed) return; + recordSessionParent(state, event.properties?.info); + return; + } + + if (event?.type === "session.idle") { + const rawSessionID = event.properties?.sessionID; + if (state.disposed || typeof rawSessionID !== "string" || !rawSessionID) return; + const safeID = safeSessionID(rawSessionID); + // A stale idle — delivered after the session was already re-prompted — + // must not pool a sandbox an in-flight bash command is still writing to. + // Skipping is safe: the re-prompted turn ends with its own idle, which + // performs the release once no command is running. + if ((state.activeBashCounts.get(safeID) ?? 0) > 0) { + sandboxDebugLog( + `idle ${safeID}: release refused, ${state.activeBashCounts.get(safeID)} bash in flight`, + ); + return; + } + // Only subagent (child) sessions release on idle. A main session going + // idle is just the end of a turn; it keeps its sandbox for the next one. + if (state.sessionParents.get(safeID)) { + releaseSandboxToPool(state, safeID); + } else { + sandboxDebugLog( + `idle ${safeID}: release refused, parent=${JSON.stringify(state.sessionParents.get(safeID) ?? null)} known=${state.sessionParents.has(safeID)}`, + ); + } + return; + } + if (event?.type !== "session.deleted") return; const sessionID = event.properties?.info?.id; if (typeof sessionID !== "string" || !sessionID) return; const safeID = safeSessionID(sessionID); + const parentID = + event.properties?.info?.parentID ?? state.sessionParents.get(safeID); + // Only a session this instance actually observed as parentless counts as a + // main session. A deletion for a session we never tracked (created before + // the plugin loaded) proves nothing about the pool, so it must not drain + // warm caches that may belong to a still-live main. + const knownMain = !parentID && state.sessionParents.has(safeID); state.deletedSandboxSessions.add(safeID); + state.sessionParents.delete(safeID); + state.lastSessionSandbox.delete(safeID); + state.activeBashCounts.delete(safeID); state.xcodeMcpCache.delete(safeID); state.xcodeMcpApprovalSessions.delete(safeID); const activeApproval = state.xcodeMcpApprovalHelpers.get(safeID); await stopXcodeMcpApproval(activeApproval); await removeOwnedSandbox(state, safeID); + // A deleted main session drains the free pool: pooled sandboxes existed to + // serve its subagents, so reclaim the disk now rather than at dispose. + // Sandboxes still leased by live sessions are untouched. + sandboxDebugLog( + `deleted ${safeID}: knownMain=${knownMain} drain=${knownMain ? state.freeSandboxes.length : 0} pooled`, + ); + if (knownMain) await drainFreeSandboxes(state); +} + +// Leasing order: the sandbox this session already holds, then the sandbox it +// held before releasing (warm caches for a re-prompted subagent), then the +// most recently freed pool entry, then a fresh directory. Pool entries are +// revalidated before reuse and dropped if they fail the identity checks. +// Reused directories keep their original - name and +// are never renamed — SPM and Xcode state files embed absolute paths. +function leaseSandboxPath(state, sandboxRootIdentity, sessionID) { + const existing = state.ownedSandboxes.get(sessionID); + if (existing) return existing; + + const preferred = state.lastSessionSandbox.get(sessionID); + const preferredIndex = + typeof preferred === "string" + ? state.freeSandboxes.findIndex((entry) => entry.path === preferred) + : -1; + if (preferredIndex !== -1) { + const [entry] = state.freeSandboxes.splice(preferredIndex, 1); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused previous ${path.basename(entry.path)}`); + return entry.path; + } + // Discarded entries stay marked pending: this instance's sweep already + // ran, so without the marker a transient inspect failure would orphan + // the directory until another process claims the sandbox root. + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + while (state.freeSandboxes.length > 0) { + const entry = state.freeSandboxes.pop(); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused pooled ${path.basename(entry.path)}`); + return entry.path; + } + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + // Plugin instances never create a sandbox path used by another instance, so + // an older instance cannot delete a replacement instance's active sandbox + // after an ownership check. Within this instance the deterministic name can + // collide: pooling decouples directory names from sessions, so a sandbox + // named after this session may now be leased by a different session. Never + // share a live sandbox — fall back to a unique suffix instead. + const fresh = path.join(sandboxRootIdentity.path, `${sessionID}-${state.instanceID}`); + const leasedPaths = new Set(state.ownedSandboxes.values()); + if (!leasedPaths.has(fresh)) { + sandboxDebugLog(`lease ${sessionID}: fresh ${path.basename(fresh)}`); + return fresh; + } + const disambiguated = path.join(sandboxRootIdentity.path, `${sessionID}-${randomUUID()}`); + sandboxDebugLog(`lease ${sessionID}: fresh (collision) ${path.basename(disambiguated)}`); + return disambiguated; +} + +function recordSessionParent(state, info) { + const id = info?.id; + if (typeof id !== "string" || !id) return; + const safeID = safeSessionID(id); + const parentID = + typeof info.parentID === "string" && info.parentID ? info.parentID : null; + // A payload without parentID normally means "main session", but never let + // it downgrade a session we already classified: a partial update would + // otherwise stop a known child from releasing on idle and let its + // deletion drain the pool. + if (parentID === null && state.sessionParents.has(safeID)) return; + state.sessionParents.set(safeID, parentID); +} + +// In-flight bash tracking exists solely to gate the idle-release against +// stale events. Counts only ever prevent a release, so a missed +// tool.execute.after degrades to "the sandbox stays leased" — the pre-pool +// behavior, reclaimed on deletion/dispose — never to a premature release. +function trackBashStart(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + state.activeBashCounts.set(safeID, (state.activeBashCounts.get(safeID) ?? 0) + 1); +} + +function trackBashEnd(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + const count = state.activeBashCounts.get(safeID) ?? 0; + if (count <= 1) state.activeBashCounts.delete(safeID); + else state.activeBashCounts.set(safeID, count - 1); +} + +function releaseSandboxToPool(state, sessionID) { + const sandboxPath = state.ownedSandboxes.get(sessionID); + const identity = state.ownedSandboxIdentities.get(sessionID); + if (!sandboxPath || !identity) return; + + state.ownedSandboxes.delete(sessionID); + state.ownedSandboxIdentities.delete(sessionID); + state.lastSessionSandbox.set(sessionID, sandboxPath); + state.freeSandboxes.push({ path: sandboxPath, identity }); + sandboxDebugLog(`release ${sessionID}: pooled ${path.basename(sandboxPath)}`); } async function removeOwnedSandbox(state, sessionID) { @@ -1154,6 +1322,38 @@ async function removeOwnedSandbox(state, sessionID) { state.ownedSandboxIdentities.delete(sessionID); if (!state.sandboxRoot || !sandboxIdentity) return; + await quarantineAndRemoveSandbox(state, sandboxPath, sandboxIdentity); +} + +async function drainFreeSandboxes(state) { + const entries = state.freeSandboxes.splice(0, state.freeSandboxes.length); + if (entries.length === 0) return; + + const drainedPaths = new Set(entries.map((entry) => entry.path)); + for (const [sessionID, sandboxPath] of state.lastSessionSandbox) { + if (drainedPaths.has(sandboxPath)) state.lastSessionSandbox.delete(sessionID); + } + if (!state.sandboxRoot) return; + + // Removal is detached (/bin/rm) rather than awaited: draining several + // multi-GB trees in-process would stall serialized event delivery — the + // very lag that turns a delayed session.idle stale. + await Promise.all( + entries.map(async ({ path: sandboxPath, identity }) => { + markPendingSandboxCleanup(sandboxPath); + await quarantineAndRemoveSandbox(state, sandboxPath, identity, { + detach: true, + }); + }), + ); +} + +async function quarantineAndRemoveSandbox( + state, + sandboxPath, + sandboxIdentity, + { detach = false } = {}, +) { const quarantinedPath = await quarantineOwnedSandbox( sandboxPath, state.instanceID, @@ -1163,9 +1363,20 @@ async function removeOwnedSandbox(state, sessionID) { const cleanupIdentity = quarantinedPath ? relocatedSandboxIdentity(sandboxIdentity, quarantinedPath) : sandboxIdentity; + const removalPath = quarantinedPath || sandboxPath; + const expectedInstanceID = quarantinedPath ? "" : state.instanceID; + if (detach) { + await detachManagedSandbox( + removalPath, + expectedInstanceID, + state.sandboxRoot, + cleanupIdentity, + ); + return; + } await removeManagedSandbox( - quarantinedPath || sandboxPath, - quarantinedPath ? "" : state.instanceID, + removalPath, + expectedInstanceID, state.sandboxRoot, cleanupIdentity, ); @@ -1173,14 +1384,26 @@ async function removeOwnedSandbox(state, sessionID) { async function detachOwnedSandboxes(state) { state.disposed = true; - const sandboxes = [...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ - sandboxPath, - sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), - })); + const sandboxes = [ + ...[...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ + sandboxPath, + sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), + })), + ...state.freeSandboxes.map((entry) => ({ + sandboxPath: entry.path, + sandboxIdentity: entry.identity, + })), + ]; const sandboxPaths = [...new Set(sandboxes.map(({ sandboxPath }) => sandboxPath))]; for (const sandboxPath of sandboxPaths) markPendingSandboxCleanup(sandboxPath); state.ownedSandboxes.clear(); state.ownedSandboxIdentities.clear(); + // Pooled entries were folded into `sandboxes` above; clear the pool and + // session-tracking state so a disposed instance can never lease or release. + state.freeSandboxes.length = 0; + state.sessionParents.clear(); + state.lastSessionSandbox.clear(); + state.activeBashCounts.clear(); state.xcodeMcpCache.clear(); state.xcodeMcpApprovalSessions.clear(); state.xcodeMcpApprovalHelpers.clear(); diff --git a/SwiftScaffolding/common/opencode-plugin.js b/SwiftScaffolding/common/opencode-plugin.js index 0a24e9a..d2573be 100644 --- a/SwiftScaffolding/common/opencode-plugin.js +++ b/SwiftScaffolding/common/opencode-plugin.js @@ -83,6 +83,10 @@ export function createOpenCodePlugin(pluginRootUrl) { ownedSandboxes: new Map(), ownedSandboxIdentities: new Map(), deletedSandboxSessions: new Set(), + sessionParents: new Map(), + freeSandboxes: [], + lastSessionSandbox: new Map(), + activeBashCounts: new Map(), sandboxSweepDone: false, disposed: false, }; @@ -115,6 +119,11 @@ export function createOpenCodePlugin(pluginRootUrl) { "tool.execute.before": async (input, output) => { loadMetadata(pluginRoot, state); applyPreToolUseRules(state, input, output); + trackBashStart(state, input); + }, + + "tool.execute.after": async (input) => { + trackBashEnd(state, input); }, "shell.env": async (input, output) => { @@ -737,9 +746,7 @@ async function configureShellEnvironment(pluginRoot, state, input, output) { const sessionID = safeSessionID(input.sessionID ?? input.cwd ?? "default"); if (state.disposed || state.deletedSandboxSessions.has(sessionID)) return; const sandboxRoot = secureSandboxRoot(state); - // Plugin instances never reuse a sandbox path, so an older instance cannot - // delete a replacement instance's active sandbox after an ownership check. - const sandboxBase = path.join(sandboxRoot.path, `${sessionID}-${state.instanceID}`); + const sandboxBase = leaseSandboxPath(state, sandboxRoot, sessionID); state.ownedSandboxes.set(sessionID, sandboxBase); if (!state.processStartToken) { @@ -1129,19 +1136,180 @@ function releaseSandboxOwnerLock(ownerLock) { } } +// Field-diagnosable pool decisions: when $TMPDIR/xbt-sandbox-debug exists, +// every lease/release decision (and every refusal, with its reason) is +// appended to $TMPDIR/xbt-sandbox-debug.log. Inert without the flag file. +function sandboxDebugLog(message) { + try { + const flag = path.join(os.tmpdir(), "xbt-sandbox-debug"); + if (!fs.existsSync(flag)) return; + fs.appendFileSync(`${flag}.log`, `${new Date().toISOString()} ${message}\n`); + } catch { + // Diagnostics must never interfere with sandbox management. + } +} + async function handlePluginEvent(state, event) { + if (event?.type === "session.created" || event?.type === "session.updated") { + if (state.disposed) return; + recordSessionParent(state, event.properties?.info); + return; + } + + if (event?.type === "session.idle") { + const rawSessionID = event.properties?.sessionID; + if (state.disposed || typeof rawSessionID !== "string" || !rawSessionID) return; + const safeID = safeSessionID(rawSessionID); + // A stale idle — delivered after the session was already re-prompted — + // must not pool a sandbox an in-flight bash command is still writing to. + // Skipping is safe: the re-prompted turn ends with its own idle, which + // performs the release once no command is running. + if ((state.activeBashCounts.get(safeID) ?? 0) > 0) { + sandboxDebugLog( + `idle ${safeID}: release refused, ${state.activeBashCounts.get(safeID)} bash in flight`, + ); + return; + } + // Only subagent (child) sessions release on idle. A main session going + // idle is just the end of a turn; it keeps its sandbox for the next one. + if (state.sessionParents.get(safeID)) { + releaseSandboxToPool(state, safeID); + } else { + sandboxDebugLog( + `idle ${safeID}: release refused, parent=${JSON.stringify(state.sessionParents.get(safeID) ?? null)} known=${state.sessionParents.has(safeID)}`, + ); + } + return; + } + if (event?.type !== "session.deleted") return; const sessionID = event.properties?.info?.id; if (typeof sessionID !== "string" || !sessionID) return; const safeID = safeSessionID(sessionID); + const parentID = + event.properties?.info?.parentID ?? state.sessionParents.get(safeID); + // Only a session this instance actually observed as parentless counts as a + // main session. A deletion for a session we never tracked (created before + // the plugin loaded) proves nothing about the pool, so it must not drain + // warm caches that may belong to a still-live main. + const knownMain = !parentID && state.sessionParents.has(safeID); state.deletedSandboxSessions.add(safeID); + state.sessionParents.delete(safeID); + state.lastSessionSandbox.delete(safeID); + state.activeBashCounts.delete(safeID); state.xcodeMcpCache.delete(safeID); state.xcodeMcpApprovalSessions.delete(safeID); const activeApproval = state.xcodeMcpApprovalHelpers.get(safeID); await stopXcodeMcpApproval(activeApproval); await removeOwnedSandbox(state, safeID); + // A deleted main session drains the free pool: pooled sandboxes existed to + // serve its subagents, so reclaim the disk now rather than at dispose. + // Sandboxes still leased by live sessions are untouched. + sandboxDebugLog( + `deleted ${safeID}: knownMain=${knownMain} drain=${knownMain ? state.freeSandboxes.length : 0} pooled`, + ); + if (knownMain) await drainFreeSandboxes(state); +} + +// Leasing order: the sandbox this session already holds, then the sandbox it +// held before releasing (warm caches for a re-prompted subagent), then the +// most recently freed pool entry, then a fresh directory. Pool entries are +// revalidated before reuse and dropped if they fail the identity checks. +// Reused directories keep their original - name and +// are never renamed — SPM and Xcode state files embed absolute paths. +function leaseSandboxPath(state, sandboxRootIdentity, sessionID) { + const existing = state.ownedSandboxes.get(sessionID); + if (existing) return existing; + + const preferred = state.lastSessionSandbox.get(sessionID); + const preferredIndex = + typeof preferred === "string" + ? state.freeSandboxes.findIndex((entry) => entry.path === preferred) + : -1; + if (preferredIndex !== -1) { + const [entry] = state.freeSandboxes.splice(preferredIndex, 1); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused previous ${path.basename(entry.path)}`); + return entry.path; + } + // Discarded entries stay marked pending: this instance's sweep already + // ran, so without the marker a transient inspect failure would orphan + // the directory until another process claims the sandbox root. + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + while (state.freeSandboxes.length > 0) { + const entry = state.freeSandboxes.pop(); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused pooled ${path.basename(entry.path)}`); + return entry.path; + } + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + // Plugin instances never create a sandbox path used by another instance, so + // an older instance cannot delete a replacement instance's active sandbox + // after an ownership check. Within this instance the deterministic name can + // collide: pooling decouples directory names from sessions, so a sandbox + // named after this session may now be leased by a different session. Never + // share a live sandbox — fall back to a unique suffix instead. + const fresh = path.join(sandboxRootIdentity.path, `${sessionID}-${state.instanceID}`); + const leasedPaths = new Set(state.ownedSandboxes.values()); + if (!leasedPaths.has(fresh)) { + sandboxDebugLog(`lease ${sessionID}: fresh ${path.basename(fresh)}`); + return fresh; + } + const disambiguated = path.join(sandboxRootIdentity.path, `${sessionID}-${randomUUID()}`); + sandboxDebugLog(`lease ${sessionID}: fresh (collision) ${path.basename(disambiguated)}`); + return disambiguated; +} + +function recordSessionParent(state, info) { + const id = info?.id; + if (typeof id !== "string" || !id) return; + const safeID = safeSessionID(id); + const parentID = + typeof info.parentID === "string" && info.parentID ? info.parentID : null; + // A payload without parentID normally means "main session", but never let + // it downgrade a session we already classified: a partial update would + // otherwise stop a known child from releasing on idle and let its + // deletion drain the pool. + if (parentID === null && state.sessionParents.has(safeID)) return; + state.sessionParents.set(safeID, parentID); +} + +// In-flight bash tracking exists solely to gate the idle-release against +// stale events. Counts only ever prevent a release, so a missed +// tool.execute.after degrades to "the sandbox stays leased" — the pre-pool +// behavior, reclaimed on deletion/dispose — never to a premature release. +function trackBashStart(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + state.activeBashCounts.set(safeID, (state.activeBashCounts.get(safeID) ?? 0) + 1); +} + +function trackBashEnd(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + const count = state.activeBashCounts.get(safeID) ?? 0; + if (count <= 1) state.activeBashCounts.delete(safeID); + else state.activeBashCounts.set(safeID, count - 1); +} + +function releaseSandboxToPool(state, sessionID) { + const sandboxPath = state.ownedSandboxes.get(sessionID); + const identity = state.ownedSandboxIdentities.get(sessionID); + if (!sandboxPath || !identity) return; + + state.ownedSandboxes.delete(sessionID); + state.ownedSandboxIdentities.delete(sessionID); + state.lastSessionSandbox.set(sessionID, sandboxPath); + state.freeSandboxes.push({ path: sandboxPath, identity }); + sandboxDebugLog(`release ${sessionID}: pooled ${path.basename(sandboxPath)}`); } async function removeOwnedSandbox(state, sessionID) { @@ -1154,6 +1322,38 @@ async function removeOwnedSandbox(state, sessionID) { state.ownedSandboxIdentities.delete(sessionID); if (!state.sandboxRoot || !sandboxIdentity) return; + await quarantineAndRemoveSandbox(state, sandboxPath, sandboxIdentity); +} + +async function drainFreeSandboxes(state) { + const entries = state.freeSandboxes.splice(0, state.freeSandboxes.length); + if (entries.length === 0) return; + + const drainedPaths = new Set(entries.map((entry) => entry.path)); + for (const [sessionID, sandboxPath] of state.lastSessionSandbox) { + if (drainedPaths.has(sandboxPath)) state.lastSessionSandbox.delete(sessionID); + } + if (!state.sandboxRoot) return; + + // Removal is detached (/bin/rm) rather than awaited: draining several + // multi-GB trees in-process would stall serialized event delivery — the + // very lag that turns a delayed session.idle stale. + await Promise.all( + entries.map(async ({ path: sandboxPath, identity }) => { + markPendingSandboxCleanup(sandboxPath); + await quarantineAndRemoveSandbox(state, sandboxPath, identity, { + detach: true, + }); + }), + ); +} + +async function quarantineAndRemoveSandbox( + state, + sandboxPath, + sandboxIdentity, + { detach = false } = {}, +) { const quarantinedPath = await quarantineOwnedSandbox( sandboxPath, state.instanceID, @@ -1163,9 +1363,20 @@ async function removeOwnedSandbox(state, sessionID) { const cleanupIdentity = quarantinedPath ? relocatedSandboxIdentity(sandboxIdentity, quarantinedPath) : sandboxIdentity; + const removalPath = quarantinedPath || sandboxPath; + const expectedInstanceID = quarantinedPath ? "" : state.instanceID; + if (detach) { + await detachManagedSandbox( + removalPath, + expectedInstanceID, + state.sandboxRoot, + cleanupIdentity, + ); + return; + } await removeManagedSandbox( - quarantinedPath || sandboxPath, - quarantinedPath ? "" : state.instanceID, + removalPath, + expectedInstanceID, state.sandboxRoot, cleanupIdentity, ); @@ -1173,14 +1384,26 @@ async function removeOwnedSandbox(state, sessionID) { async function detachOwnedSandboxes(state) { state.disposed = true; - const sandboxes = [...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ - sandboxPath, - sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), - })); + const sandboxes = [ + ...[...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ + sandboxPath, + sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), + })), + ...state.freeSandboxes.map((entry) => ({ + sandboxPath: entry.path, + sandboxIdentity: entry.identity, + })), + ]; const sandboxPaths = [...new Set(sandboxes.map(({ sandboxPath }) => sandboxPath))]; for (const sandboxPath of sandboxPaths) markPendingSandboxCleanup(sandboxPath); state.ownedSandboxes.clear(); state.ownedSandboxIdentities.clear(); + // Pooled entries were folded into `sandboxes` above; clear the pool and + // session-tracking state so a disposed instance can never lease or release. + state.freeSandboxes.length = 0; + state.sessionParents.clear(); + state.lastSessionSandbox.clear(); + state.activeBashCounts.clear(); state.xcodeMcpCache.clear(); state.xcodeMcpApprovalSessions.clear(); state.xcodeMcpApprovalHelpers.clear(); diff --git a/XcodeBuildTools/common/opencode-plugin.js b/XcodeBuildTools/common/opencode-plugin.js index 0a24e9a..d2573be 100644 --- a/XcodeBuildTools/common/opencode-plugin.js +++ b/XcodeBuildTools/common/opencode-plugin.js @@ -83,6 +83,10 @@ export function createOpenCodePlugin(pluginRootUrl) { ownedSandboxes: new Map(), ownedSandboxIdentities: new Map(), deletedSandboxSessions: new Set(), + sessionParents: new Map(), + freeSandboxes: [], + lastSessionSandbox: new Map(), + activeBashCounts: new Map(), sandboxSweepDone: false, disposed: false, }; @@ -115,6 +119,11 @@ export function createOpenCodePlugin(pluginRootUrl) { "tool.execute.before": async (input, output) => { loadMetadata(pluginRoot, state); applyPreToolUseRules(state, input, output); + trackBashStart(state, input); + }, + + "tool.execute.after": async (input) => { + trackBashEnd(state, input); }, "shell.env": async (input, output) => { @@ -737,9 +746,7 @@ async function configureShellEnvironment(pluginRoot, state, input, output) { const sessionID = safeSessionID(input.sessionID ?? input.cwd ?? "default"); if (state.disposed || state.deletedSandboxSessions.has(sessionID)) return; const sandboxRoot = secureSandboxRoot(state); - // Plugin instances never reuse a sandbox path, so an older instance cannot - // delete a replacement instance's active sandbox after an ownership check. - const sandboxBase = path.join(sandboxRoot.path, `${sessionID}-${state.instanceID}`); + const sandboxBase = leaseSandboxPath(state, sandboxRoot, sessionID); state.ownedSandboxes.set(sessionID, sandboxBase); if (!state.processStartToken) { @@ -1129,19 +1136,180 @@ function releaseSandboxOwnerLock(ownerLock) { } } +// Field-diagnosable pool decisions: when $TMPDIR/xbt-sandbox-debug exists, +// every lease/release decision (and every refusal, with its reason) is +// appended to $TMPDIR/xbt-sandbox-debug.log. Inert without the flag file. +function sandboxDebugLog(message) { + try { + const flag = path.join(os.tmpdir(), "xbt-sandbox-debug"); + if (!fs.existsSync(flag)) return; + fs.appendFileSync(`${flag}.log`, `${new Date().toISOString()} ${message}\n`); + } catch { + // Diagnostics must never interfere with sandbox management. + } +} + async function handlePluginEvent(state, event) { + if (event?.type === "session.created" || event?.type === "session.updated") { + if (state.disposed) return; + recordSessionParent(state, event.properties?.info); + return; + } + + if (event?.type === "session.idle") { + const rawSessionID = event.properties?.sessionID; + if (state.disposed || typeof rawSessionID !== "string" || !rawSessionID) return; + const safeID = safeSessionID(rawSessionID); + // A stale idle — delivered after the session was already re-prompted — + // must not pool a sandbox an in-flight bash command is still writing to. + // Skipping is safe: the re-prompted turn ends with its own idle, which + // performs the release once no command is running. + if ((state.activeBashCounts.get(safeID) ?? 0) > 0) { + sandboxDebugLog( + `idle ${safeID}: release refused, ${state.activeBashCounts.get(safeID)} bash in flight`, + ); + return; + } + // Only subagent (child) sessions release on idle. A main session going + // idle is just the end of a turn; it keeps its sandbox for the next one. + if (state.sessionParents.get(safeID)) { + releaseSandboxToPool(state, safeID); + } else { + sandboxDebugLog( + `idle ${safeID}: release refused, parent=${JSON.stringify(state.sessionParents.get(safeID) ?? null)} known=${state.sessionParents.has(safeID)}`, + ); + } + return; + } + if (event?.type !== "session.deleted") return; const sessionID = event.properties?.info?.id; if (typeof sessionID !== "string" || !sessionID) return; const safeID = safeSessionID(sessionID); + const parentID = + event.properties?.info?.parentID ?? state.sessionParents.get(safeID); + // Only a session this instance actually observed as parentless counts as a + // main session. A deletion for a session we never tracked (created before + // the plugin loaded) proves nothing about the pool, so it must not drain + // warm caches that may belong to a still-live main. + const knownMain = !parentID && state.sessionParents.has(safeID); state.deletedSandboxSessions.add(safeID); + state.sessionParents.delete(safeID); + state.lastSessionSandbox.delete(safeID); + state.activeBashCounts.delete(safeID); state.xcodeMcpCache.delete(safeID); state.xcodeMcpApprovalSessions.delete(safeID); const activeApproval = state.xcodeMcpApprovalHelpers.get(safeID); await stopXcodeMcpApproval(activeApproval); await removeOwnedSandbox(state, safeID); + // A deleted main session drains the free pool: pooled sandboxes existed to + // serve its subagents, so reclaim the disk now rather than at dispose. + // Sandboxes still leased by live sessions are untouched. + sandboxDebugLog( + `deleted ${safeID}: knownMain=${knownMain} drain=${knownMain ? state.freeSandboxes.length : 0} pooled`, + ); + if (knownMain) await drainFreeSandboxes(state); +} + +// Leasing order: the sandbox this session already holds, then the sandbox it +// held before releasing (warm caches for a re-prompted subagent), then the +// most recently freed pool entry, then a fresh directory. Pool entries are +// revalidated before reuse and dropped if they fail the identity checks. +// Reused directories keep their original - name and +// are never renamed — SPM and Xcode state files embed absolute paths. +function leaseSandboxPath(state, sandboxRootIdentity, sessionID) { + const existing = state.ownedSandboxes.get(sessionID); + if (existing) return existing; + + const preferred = state.lastSessionSandbox.get(sessionID); + const preferredIndex = + typeof preferred === "string" + ? state.freeSandboxes.findIndex((entry) => entry.path === preferred) + : -1; + if (preferredIndex !== -1) { + const [entry] = state.freeSandboxes.splice(preferredIndex, 1); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused previous ${path.basename(entry.path)}`); + return entry.path; + } + // Discarded entries stay marked pending: this instance's sweep already + // ran, so without the marker a transient inspect failure would orphan + // the directory until another process claims the sandbox root. + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + while (state.freeSandboxes.length > 0) { + const entry = state.freeSandboxes.pop(); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused pooled ${path.basename(entry.path)}`); + return entry.path; + } + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + // Plugin instances never create a sandbox path used by another instance, so + // an older instance cannot delete a replacement instance's active sandbox + // after an ownership check. Within this instance the deterministic name can + // collide: pooling decouples directory names from sessions, so a sandbox + // named after this session may now be leased by a different session. Never + // share a live sandbox — fall back to a unique suffix instead. + const fresh = path.join(sandboxRootIdentity.path, `${sessionID}-${state.instanceID}`); + const leasedPaths = new Set(state.ownedSandboxes.values()); + if (!leasedPaths.has(fresh)) { + sandboxDebugLog(`lease ${sessionID}: fresh ${path.basename(fresh)}`); + return fresh; + } + const disambiguated = path.join(sandboxRootIdentity.path, `${sessionID}-${randomUUID()}`); + sandboxDebugLog(`lease ${sessionID}: fresh (collision) ${path.basename(disambiguated)}`); + return disambiguated; +} + +function recordSessionParent(state, info) { + const id = info?.id; + if (typeof id !== "string" || !id) return; + const safeID = safeSessionID(id); + const parentID = + typeof info.parentID === "string" && info.parentID ? info.parentID : null; + // A payload without parentID normally means "main session", but never let + // it downgrade a session we already classified: a partial update would + // otherwise stop a known child from releasing on idle and let its + // deletion drain the pool. + if (parentID === null && state.sessionParents.has(safeID)) return; + state.sessionParents.set(safeID, parentID); +} + +// In-flight bash tracking exists solely to gate the idle-release against +// stale events. Counts only ever prevent a release, so a missed +// tool.execute.after degrades to "the sandbox stays leased" — the pre-pool +// behavior, reclaimed on deletion/dispose — never to a premature release. +function trackBashStart(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + state.activeBashCounts.set(safeID, (state.activeBashCounts.get(safeID) ?? 0) + 1); +} + +function trackBashEnd(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + const count = state.activeBashCounts.get(safeID) ?? 0; + if (count <= 1) state.activeBashCounts.delete(safeID); + else state.activeBashCounts.set(safeID, count - 1); +} + +function releaseSandboxToPool(state, sessionID) { + const sandboxPath = state.ownedSandboxes.get(sessionID); + const identity = state.ownedSandboxIdentities.get(sessionID); + if (!sandboxPath || !identity) return; + + state.ownedSandboxes.delete(sessionID); + state.ownedSandboxIdentities.delete(sessionID); + state.lastSessionSandbox.set(sessionID, sandboxPath); + state.freeSandboxes.push({ path: sandboxPath, identity }); + sandboxDebugLog(`release ${sessionID}: pooled ${path.basename(sandboxPath)}`); } async function removeOwnedSandbox(state, sessionID) { @@ -1154,6 +1322,38 @@ async function removeOwnedSandbox(state, sessionID) { state.ownedSandboxIdentities.delete(sessionID); if (!state.sandboxRoot || !sandboxIdentity) return; + await quarantineAndRemoveSandbox(state, sandboxPath, sandboxIdentity); +} + +async function drainFreeSandboxes(state) { + const entries = state.freeSandboxes.splice(0, state.freeSandboxes.length); + if (entries.length === 0) return; + + const drainedPaths = new Set(entries.map((entry) => entry.path)); + for (const [sessionID, sandboxPath] of state.lastSessionSandbox) { + if (drainedPaths.has(sandboxPath)) state.lastSessionSandbox.delete(sessionID); + } + if (!state.sandboxRoot) return; + + // Removal is detached (/bin/rm) rather than awaited: draining several + // multi-GB trees in-process would stall serialized event delivery — the + // very lag that turns a delayed session.idle stale. + await Promise.all( + entries.map(async ({ path: sandboxPath, identity }) => { + markPendingSandboxCleanup(sandboxPath); + await quarantineAndRemoveSandbox(state, sandboxPath, identity, { + detach: true, + }); + }), + ); +} + +async function quarantineAndRemoveSandbox( + state, + sandboxPath, + sandboxIdentity, + { detach = false } = {}, +) { const quarantinedPath = await quarantineOwnedSandbox( sandboxPath, state.instanceID, @@ -1163,9 +1363,20 @@ async function removeOwnedSandbox(state, sessionID) { const cleanupIdentity = quarantinedPath ? relocatedSandboxIdentity(sandboxIdentity, quarantinedPath) : sandboxIdentity; + const removalPath = quarantinedPath || sandboxPath; + const expectedInstanceID = quarantinedPath ? "" : state.instanceID; + if (detach) { + await detachManagedSandbox( + removalPath, + expectedInstanceID, + state.sandboxRoot, + cleanupIdentity, + ); + return; + } await removeManagedSandbox( - quarantinedPath || sandboxPath, - quarantinedPath ? "" : state.instanceID, + removalPath, + expectedInstanceID, state.sandboxRoot, cleanupIdentity, ); @@ -1173,14 +1384,26 @@ async function removeOwnedSandbox(state, sessionID) { async function detachOwnedSandboxes(state) { state.disposed = true; - const sandboxes = [...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ - sandboxPath, - sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), - })); + const sandboxes = [ + ...[...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ + sandboxPath, + sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), + })), + ...state.freeSandboxes.map((entry) => ({ + sandboxPath: entry.path, + sandboxIdentity: entry.identity, + })), + ]; const sandboxPaths = [...new Set(sandboxes.map(({ sandboxPath }) => sandboxPath))]; for (const sandboxPath of sandboxPaths) markPendingSandboxCleanup(sandboxPath); state.ownedSandboxes.clear(); state.ownedSandboxIdentities.clear(); + // Pooled entries were folded into `sandboxes` above; clear the pool and + // session-tracking state so a disposed instance can never lease or release. + state.freeSandboxes.length = 0; + state.sessionParents.clear(); + state.lastSessionSandbox.clear(); + state.activeBashCounts.clear(); state.xcodeMcpCache.clear(); state.xcodeMcpApprovalSessions.clear(); state.xcodeMcpApprovalHelpers.clear(); diff --git a/common/opencode-plugin.js b/common/opencode-plugin.js index fa0719d..9bc8e8c 100644 --- a/common/opencode-plugin.js +++ b/common/opencode-plugin.js @@ -82,6 +82,10 @@ export function createOpenCodePlugin(pluginRootUrl) { ownedSandboxes: new Map(), ownedSandboxIdentities: new Map(), deletedSandboxSessions: new Set(), + sessionParents: new Map(), + freeSandboxes: [], + lastSessionSandbox: new Map(), + activeBashCounts: new Map(), sandboxSweepDone: false, disposed: false, }; @@ -114,6 +118,11 @@ export function createOpenCodePlugin(pluginRootUrl) { "tool.execute.before": async (input, output) => { loadMetadata(pluginRoot, state); applyPreToolUseRules(state, input, output); + trackBashStart(state, input); + }, + + "tool.execute.after": async (input) => { + trackBashEnd(state, input); }, "shell.env": async (input, output) => { @@ -736,9 +745,7 @@ async function configureShellEnvironment(pluginRoot, state, input, output) { const sessionID = safeSessionID(input.sessionID ?? input.cwd ?? "default"); if (state.disposed || state.deletedSandboxSessions.has(sessionID)) return; const sandboxRoot = secureSandboxRoot(state); - // Plugin instances never reuse a sandbox path, so an older instance cannot - // delete a replacement instance's active sandbox after an ownership check. - const sandboxBase = path.join(sandboxRoot.path, `${sessionID}-${state.instanceID}`); + const sandboxBase = leaseSandboxPath(state, sandboxRoot, sessionID); state.ownedSandboxes.set(sessionID, sandboxBase); if (!state.processStartToken) { @@ -1128,19 +1135,180 @@ function releaseSandboxOwnerLock(ownerLock) { } } +// Field-diagnosable pool decisions: when $TMPDIR/xbt-sandbox-debug exists, +// every lease/release decision (and every refusal, with its reason) is +// appended to $TMPDIR/xbt-sandbox-debug.log. Inert without the flag file. +function sandboxDebugLog(message) { + try { + const flag = path.join(os.tmpdir(), "xbt-sandbox-debug"); + if (!fs.existsSync(flag)) return; + fs.appendFileSync(`${flag}.log`, `${new Date().toISOString()} ${message}\n`); + } catch { + // Diagnostics must never interfere with sandbox management. + } +} + async function handlePluginEvent(state, event) { + if (event?.type === "session.created" || event?.type === "session.updated") { + if (state.disposed) return; + recordSessionParent(state, event.properties?.info); + return; + } + + if (event?.type === "session.idle") { + const rawSessionID = event.properties?.sessionID; + if (state.disposed || typeof rawSessionID !== "string" || !rawSessionID) return; + const safeID = safeSessionID(rawSessionID); + // A stale idle — delivered after the session was already re-prompted — + // must not pool a sandbox an in-flight bash command is still writing to. + // Skipping is safe: the re-prompted turn ends with its own idle, which + // performs the release once no command is running. + if ((state.activeBashCounts.get(safeID) ?? 0) > 0) { + sandboxDebugLog( + `idle ${safeID}: release refused, ${state.activeBashCounts.get(safeID)} bash in flight`, + ); + return; + } + // Only subagent (child) sessions release on idle. A main session going + // idle is just the end of a turn; it keeps its sandbox for the next one. + if (state.sessionParents.get(safeID)) { + releaseSandboxToPool(state, safeID); + } else { + sandboxDebugLog( + `idle ${safeID}: release refused, parent=${JSON.stringify(state.sessionParents.get(safeID) ?? null)} known=${state.sessionParents.has(safeID)}`, + ); + } + return; + } + if (event?.type !== "session.deleted") return; const sessionID = event.properties?.info?.id; if (typeof sessionID !== "string" || !sessionID) return; const safeID = safeSessionID(sessionID); + const parentID = + event.properties?.info?.parentID ?? state.sessionParents.get(safeID); + // Only a session this instance actually observed as parentless counts as a + // main session. A deletion for a session we never tracked (created before + // the plugin loaded) proves nothing about the pool, so it must not drain + // warm caches that may belong to a still-live main. + const knownMain = !parentID && state.sessionParents.has(safeID); state.deletedSandboxSessions.add(safeID); + state.sessionParents.delete(safeID); + state.lastSessionSandbox.delete(safeID); + state.activeBashCounts.delete(safeID); state.xcodeMcpCache.delete(safeID); state.xcodeMcpApprovalSessions.delete(safeID); const activeApproval = state.xcodeMcpApprovalHelpers.get(safeID); await stopXcodeMcpApproval(activeApproval); await removeOwnedSandbox(state, safeID); + // A deleted main session drains the free pool: pooled sandboxes existed to + // serve its subagents, so reclaim the disk now rather than at dispose. + // Sandboxes still leased by live sessions are untouched. + sandboxDebugLog( + `deleted ${safeID}: knownMain=${knownMain} drain=${knownMain ? state.freeSandboxes.length : 0} pooled`, + ); + if (knownMain) await drainFreeSandboxes(state); +} + +// Leasing order: the sandbox this session already holds, then the sandbox it +// held before releasing (warm caches for a re-prompted subagent), then the +// most recently freed pool entry, then a fresh directory. Pool entries are +// revalidated before reuse and dropped if they fail the identity checks. +// Reused directories keep their original - name and +// are never renamed — SPM and Xcode state files embed absolute paths. +function leaseSandboxPath(state, sandboxRootIdentity, sessionID) { + const existing = state.ownedSandboxes.get(sessionID); + if (existing) return existing; + + const preferred = state.lastSessionSandbox.get(sessionID); + const preferredIndex = + typeof preferred === "string" + ? state.freeSandboxes.findIndex((entry) => entry.path === preferred) + : -1; + if (preferredIndex !== -1) { + const [entry] = state.freeSandboxes.splice(preferredIndex, 1); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused previous ${path.basename(entry.path)}`); + return entry.path; + } + // Discarded entries stay marked pending: this instance's sweep already + // ran, so without the marker a transient inspect failure would orphan + // the directory until another process claims the sandbox root. + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + while (state.freeSandboxes.length > 0) { + const entry = state.freeSandboxes.pop(); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused pooled ${path.basename(entry.path)}`); + return entry.path; + } + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + // Plugin instances never create a sandbox path used by another instance, so + // an older instance cannot delete a replacement instance's active sandbox + // after an ownership check. Within this instance the deterministic name can + // collide: pooling decouples directory names from sessions, so a sandbox + // named after this session may now be leased by a different session. Never + // share a live sandbox — fall back to a unique suffix instead. + const fresh = path.join(sandboxRootIdentity.path, `${sessionID}-${state.instanceID}`); + const leasedPaths = new Set(state.ownedSandboxes.values()); + if (!leasedPaths.has(fresh)) { + sandboxDebugLog(`lease ${sessionID}: fresh ${path.basename(fresh)}`); + return fresh; + } + const disambiguated = path.join(sandboxRootIdentity.path, `${sessionID}-${randomUUID()}`); + sandboxDebugLog(`lease ${sessionID}: fresh (collision) ${path.basename(disambiguated)}`); + return disambiguated; +} + +function recordSessionParent(state, info) { + const id = info?.id; + if (typeof id !== "string" || !id) return; + const safeID = safeSessionID(id); + const parentID = + typeof info.parentID === "string" && info.parentID ? info.parentID : null; + // A payload without parentID normally means "main session", but never let + // it downgrade a session we already classified: a partial update would + // otherwise stop a known child from releasing on idle and let its + // deletion drain the pool. + if (parentID === null && state.sessionParents.has(safeID)) return; + state.sessionParents.set(safeID, parentID); +} + +// In-flight bash tracking exists solely to gate the idle-release against +// stale events. Counts only ever prevent a release, so a missed +// tool.execute.after degrades to "the sandbox stays leased" — the pre-pool +// behavior, reclaimed on deletion/dispose — never to a premature release. +function trackBashStart(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + state.activeBashCounts.set(safeID, (state.activeBashCounts.get(safeID) ?? 0) + 1); +} + +function trackBashEnd(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + const count = state.activeBashCounts.get(safeID) ?? 0; + if (count <= 1) state.activeBashCounts.delete(safeID); + else state.activeBashCounts.set(safeID, count - 1); +} + +function releaseSandboxToPool(state, sessionID) { + const sandboxPath = state.ownedSandboxes.get(sessionID); + const identity = state.ownedSandboxIdentities.get(sessionID); + if (!sandboxPath || !identity) return; + + state.ownedSandboxes.delete(sessionID); + state.ownedSandboxIdentities.delete(sessionID); + state.lastSessionSandbox.set(sessionID, sandboxPath); + state.freeSandboxes.push({ path: sandboxPath, identity }); + sandboxDebugLog(`release ${sessionID}: pooled ${path.basename(sandboxPath)}`); } async function removeOwnedSandbox(state, sessionID) { @@ -1153,6 +1321,38 @@ async function removeOwnedSandbox(state, sessionID) { state.ownedSandboxIdentities.delete(sessionID); if (!state.sandboxRoot || !sandboxIdentity) return; + await quarantineAndRemoveSandbox(state, sandboxPath, sandboxIdentity); +} + +async function drainFreeSandboxes(state) { + const entries = state.freeSandboxes.splice(0, state.freeSandboxes.length); + if (entries.length === 0) return; + + const drainedPaths = new Set(entries.map((entry) => entry.path)); + for (const [sessionID, sandboxPath] of state.lastSessionSandbox) { + if (drainedPaths.has(sandboxPath)) state.lastSessionSandbox.delete(sessionID); + } + if (!state.sandboxRoot) return; + + // Removal is detached (/bin/rm) rather than awaited: draining several + // multi-GB trees in-process would stall serialized event delivery — the + // very lag that turns a delayed session.idle stale. + await Promise.all( + entries.map(async ({ path: sandboxPath, identity }) => { + markPendingSandboxCleanup(sandboxPath); + await quarantineAndRemoveSandbox(state, sandboxPath, identity, { + detach: true, + }); + }), + ); +} + +async function quarantineAndRemoveSandbox( + state, + sandboxPath, + sandboxIdentity, + { detach = false } = {}, +) { const quarantinedPath = await quarantineOwnedSandbox( sandboxPath, state.instanceID, @@ -1162,9 +1362,20 @@ async function removeOwnedSandbox(state, sessionID) { const cleanupIdentity = quarantinedPath ? relocatedSandboxIdentity(sandboxIdentity, quarantinedPath) : sandboxIdentity; + const removalPath = quarantinedPath || sandboxPath; + const expectedInstanceID = quarantinedPath ? "" : state.instanceID; + if (detach) { + await detachManagedSandbox( + removalPath, + expectedInstanceID, + state.sandboxRoot, + cleanupIdentity, + ); + return; + } await removeManagedSandbox( - quarantinedPath || sandboxPath, - quarantinedPath ? "" : state.instanceID, + removalPath, + expectedInstanceID, state.sandboxRoot, cleanupIdentity, ); @@ -1172,14 +1383,26 @@ async function removeOwnedSandbox(state, sessionID) { async function detachOwnedSandboxes(state) { state.disposed = true; - const sandboxes = [...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ - sandboxPath, - sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), - })); + const sandboxes = [ + ...[...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ + sandboxPath, + sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), + })), + ...state.freeSandboxes.map((entry) => ({ + sandboxPath: entry.path, + sandboxIdentity: entry.identity, + })), + ]; const sandboxPaths = [...new Set(sandboxes.map(({ sandboxPath }) => sandboxPath))]; for (const sandboxPath of sandboxPaths) markPendingSandboxCleanup(sandboxPath); state.ownedSandboxes.clear(); state.ownedSandboxIdentities.clear(); + // Pooled entries were folded into `sandboxes` above; clear the pool and + // session-tracking state so a disposed instance can never lease or release. + state.freeSandboxes.length = 0; + state.sessionParents.clear(); + state.lastSessionSandbox.clear(); + state.activeBashCounts.clear(); state.xcodeMcpCache.clear(); state.xcodeMcpApprovalSessions.clear(); state.xcodeMcpApprovalHelpers.clear(); diff --git a/iOSSimulator/common/opencode-plugin.js b/iOSSimulator/common/opencode-plugin.js index 0a24e9a..d2573be 100644 --- a/iOSSimulator/common/opencode-plugin.js +++ b/iOSSimulator/common/opencode-plugin.js @@ -83,6 +83,10 @@ export function createOpenCodePlugin(pluginRootUrl) { ownedSandboxes: new Map(), ownedSandboxIdentities: new Map(), deletedSandboxSessions: new Set(), + sessionParents: new Map(), + freeSandboxes: [], + lastSessionSandbox: new Map(), + activeBashCounts: new Map(), sandboxSweepDone: false, disposed: false, }; @@ -115,6 +119,11 @@ export function createOpenCodePlugin(pluginRootUrl) { "tool.execute.before": async (input, output) => { loadMetadata(pluginRoot, state); applyPreToolUseRules(state, input, output); + trackBashStart(state, input); + }, + + "tool.execute.after": async (input) => { + trackBashEnd(state, input); }, "shell.env": async (input, output) => { @@ -737,9 +746,7 @@ async function configureShellEnvironment(pluginRoot, state, input, output) { const sessionID = safeSessionID(input.sessionID ?? input.cwd ?? "default"); if (state.disposed || state.deletedSandboxSessions.has(sessionID)) return; const sandboxRoot = secureSandboxRoot(state); - // Plugin instances never reuse a sandbox path, so an older instance cannot - // delete a replacement instance's active sandbox after an ownership check. - const sandboxBase = path.join(sandboxRoot.path, `${sessionID}-${state.instanceID}`); + const sandboxBase = leaseSandboxPath(state, sandboxRoot, sessionID); state.ownedSandboxes.set(sessionID, sandboxBase); if (!state.processStartToken) { @@ -1129,19 +1136,180 @@ function releaseSandboxOwnerLock(ownerLock) { } } +// Field-diagnosable pool decisions: when $TMPDIR/xbt-sandbox-debug exists, +// every lease/release decision (and every refusal, with its reason) is +// appended to $TMPDIR/xbt-sandbox-debug.log. Inert without the flag file. +function sandboxDebugLog(message) { + try { + const flag = path.join(os.tmpdir(), "xbt-sandbox-debug"); + if (!fs.existsSync(flag)) return; + fs.appendFileSync(`${flag}.log`, `${new Date().toISOString()} ${message}\n`); + } catch { + // Diagnostics must never interfere with sandbox management. + } +} + async function handlePluginEvent(state, event) { + if (event?.type === "session.created" || event?.type === "session.updated") { + if (state.disposed) return; + recordSessionParent(state, event.properties?.info); + return; + } + + if (event?.type === "session.idle") { + const rawSessionID = event.properties?.sessionID; + if (state.disposed || typeof rawSessionID !== "string" || !rawSessionID) return; + const safeID = safeSessionID(rawSessionID); + // A stale idle — delivered after the session was already re-prompted — + // must not pool a sandbox an in-flight bash command is still writing to. + // Skipping is safe: the re-prompted turn ends with its own idle, which + // performs the release once no command is running. + if ((state.activeBashCounts.get(safeID) ?? 0) > 0) { + sandboxDebugLog( + `idle ${safeID}: release refused, ${state.activeBashCounts.get(safeID)} bash in flight`, + ); + return; + } + // Only subagent (child) sessions release on idle. A main session going + // idle is just the end of a turn; it keeps its sandbox for the next one. + if (state.sessionParents.get(safeID)) { + releaseSandboxToPool(state, safeID); + } else { + sandboxDebugLog( + `idle ${safeID}: release refused, parent=${JSON.stringify(state.sessionParents.get(safeID) ?? null)} known=${state.sessionParents.has(safeID)}`, + ); + } + return; + } + if (event?.type !== "session.deleted") return; const sessionID = event.properties?.info?.id; if (typeof sessionID !== "string" || !sessionID) return; const safeID = safeSessionID(sessionID); + const parentID = + event.properties?.info?.parentID ?? state.sessionParents.get(safeID); + // Only a session this instance actually observed as parentless counts as a + // main session. A deletion for a session we never tracked (created before + // the plugin loaded) proves nothing about the pool, so it must not drain + // warm caches that may belong to a still-live main. + const knownMain = !parentID && state.sessionParents.has(safeID); state.deletedSandboxSessions.add(safeID); + state.sessionParents.delete(safeID); + state.lastSessionSandbox.delete(safeID); + state.activeBashCounts.delete(safeID); state.xcodeMcpCache.delete(safeID); state.xcodeMcpApprovalSessions.delete(safeID); const activeApproval = state.xcodeMcpApprovalHelpers.get(safeID); await stopXcodeMcpApproval(activeApproval); await removeOwnedSandbox(state, safeID); + // A deleted main session drains the free pool: pooled sandboxes existed to + // serve its subagents, so reclaim the disk now rather than at dispose. + // Sandboxes still leased by live sessions are untouched. + sandboxDebugLog( + `deleted ${safeID}: knownMain=${knownMain} drain=${knownMain ? state.freeSandboxes.length : 0} pooled`, + ); + if (knownMain) await drainFreeSandboxes(state); +} + +// Leasing order: the sandbox this session already holds, then the sandbox it +// held before releasing (warm caches for a re-prompted subagent), then the +// most recently freed pool entry, then a fresh directory. Pool entries are +// revalidated before reuse and dropped if they fail the identity checks. +// Reused directories keep their original - name and +// are never renamed — SPM and Xcode state files embed absolute paths. +function leaseSandboxPath(state, sandboxRootIdentity, sessionID) { + const existing = state.ownedSandboxes.get(sessionID); + if (existing) return existing; + + const preferred = state.lastSessionSandbox.get(sessionID); + const preferredIndex = + typeof preferred === "string" + ? state.freeSandboxes.findIndex((entry) => entry.path === preferred) + : -1; + if (preferredIndex !== -1) { + const [entry] = state.freeSandboxes.splice(preferredIndex, 1); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused previous ${path.basename(entry.path)}`); + return entry.path; + } + // Discarded entries stay marked pending: this instance's sweep already + // ran, so without the marker a transient inspect failure would orphan + // the directory until another process claims the sandbox root. + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + while (state.freeSandboxes.length > 0) { + const entry = state.freeSandboxes.pop(); + if (secureExistingManagedSandbox(entry.path, sandboxRootIdentity, entry.identity)) { + sandboxDebugLog(`lease ${sessionID}: reused pooled ${path.basename(entry.path)}`); + return entry.path; + } + markPendingSandboxCleanup(entry.path); + sandboxDebugLog(`lease ${sessionID}: discarded invalid ${path.basename(entry.path)}`); + } + + // Plugin instances never create a sandbox path used by another instance, so + // an older instance cannot delete a replacement instance's active sandbox + // after an ownership check. Within this instance the deterministic name can + // collide: pooling decouples directory names from sessions, so a sandbox + // named after this session may now be leased by a different session. Never + // share a live sandbox — fall back to a unique suffix instead. + const fresh = path.join(sandboxRootIdentity.path, `${sessionID}-${state.instanceID}`); + const leasedPaths = new Set(state.ownedSandboxes.values()); + if (!leasedPaths.has(fresh)) { + sandboxDebugLog(`lease ${sessionID}: fresh ${path.basename(fresh)}`); + return fresh; + } + const disambiguated = path.join(sandboxRootIdentity.path, `${sessionID}-${randomUUID()}`); + sandboxDebugLog(`lease ${sessionID}: fresh (collision) ${path.basename(disambiguated)}`); + return disambiguated; +} + +function recordSessionParent(state, info) { + const id = info?.id; + if (typeof id !== "string" || !id) return; + const safeID = safeSessionID(id); + const parentID = + typeof info.parentID === "string" && info.parentID ? info.parentID : null; + // A payload without parentID normally means "main session", but never let + // it downgrade a session we already classified: a partial update would + // otherwise stop a known child from releasing on idle and let its + // deletion drain the pool. + if (parentID === null && state.sessionParents.has(safeID)) return; + state.sessionParents.set(safeID, parentID); +} + +// In-flight bash tracking exists solely to gate the idle-release against +// stale events. Counts only ever prevent a release, so a missed +// tool.execute.after degrades to "the sandbox stays leased" — the pre-pool +// behavior, reclaimed on deletion/dispose — never to a premature release. +function trackBashStart(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + state.activeBashCounts.set(safeID, (state.activeBashCounts.get(safeID) ?? 0) + 1); +} + +function trackBashEnd(state, input) { + if (String(input?.tool ?? "").toLowerCase() !== "bash") return; + const safeID = safeSessionID(input?.sessionID ?? "global"); + const count = state.activeBashCounts.get(safeID) ?? 0; + if (count <= 1) state.activeBashCounts.delete(safeID); + else state.activeBashCounts.set(safeID, count - 1); +} + +function releaseSandboxToPool(state, sessionID) { + const sandboxPath = state.ownedSandboxes.get(sessionID); + const identity = state.ownedSandboxIdentities.get(sessionID); + if (!sandboxPath || !identity) return; + + state.ownedSandboxes.delete(sessionID); + state.ownedSandboxIdentities.delete(sessionID); + state.lastSessionSandbox.set(sessionID, sandboxPath); + state.freeSandboxes.push({ path: sandboxPath, identity }); + sandboxDebugLog(`release ${sessionID}: pooled ${path.basename(sandboxPath)}`); } async function removeOwnedSandbox(state, sessionID) { @@ -1154,6 +1322,38 @@ async function removeOwnedSandbox(state, sessionID) { state.ownedSandboxIdentities.delete(sessionID); if (!state.sandboxRoot || !sandboxIdentity) return; + await quarantineAndRemoveSandbox(state, sandboxPath, sandboxIdentity); +} + +async function drainFreeSandboxes(state) { + const entries = state.freeSandboxes.splice(0, state.freeSandboxes.length); + if (entries.length === 0) return; + + const drainedPaths = new Set(entries.map((entry) => entry.path)); + for (const [sessionID, sandboxPath] of state.lastSessionSandbox) { + if (drainedPaths.has(sandboxPath)) state.lastSessionSandbox.delete(sessionID); + } + if (!state.sandboxRoot) return; + + // Removal is detached (/bin/rm) rather than awaited: draining several + // multi-GB trees in-process would stall serialized event delivery — the + // very lag that turns a delayed session.idle stale. + await Promise.all( + entries.map(async ({ path: sandboxPath, identity }) => { + markPendingSandboxCleanup(sandboxPath); + await quarantineAndRemoveSandbox(state, sandboxPath, identity, { + detach: true, + }); + }), + ); +} + +async function quarantineAndRemoveSandbox( + state, + sandboxPath, + sandboxIdentity, + { detach = false } = {}, +) { const quarantinedPath = await quarantineOwnedSandbox( sandboxPath, state.instanceID, @@ -1163,9 +1363,20 @@ async function removeOwnedSandbox(state, sessionID) { const cleanupIdentity = quarantinedPath ? relocatedSandboxIdentity(sandboxIdentity, quarantinedPath) : sandboxIdentity; + const removalPath = quarantinedPath || sandboxPath; + const expectedInstanceID = quarantinedPath ? "" : state.instanceID; + if (detach) { + await detachManagedSandbox( + removalPath, + expectedInstanceID, + state.sandboxRoot, + cleanupIdentity, + ); + return; + } await removeManagedSandbox( - quarantinedPath || sandboxPath, - quarantinedPath ? "" : state.instanceID, + removalPath, + expectedInstanceID, state.sandboxRoot, cleanupIdentity, ); @@ -1173,14 +1384,26 @@ async function removeOwnedSandbox(state, sessionID) { async function detachOwnedSandboxes(state) { state.disposed = true; - const sandboxes = [...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ - sandboxPath, - sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), - })); + const sandboxes = [ + ...[...state.ownedSandboxes.entries()].map(([sessionID, sandboxPath]) => ({ + sandboxPath, + sandboxIdentity: state.ownedSandboxIdentities.get(sessionID), + })), + ...state.freeSandboxes.map((entry) => ({ + sandboxPath: entry.path, + sandboxIdentity: entry.identity, + })), + ]; const sandboxPaths = [...new Set(sandboxes.map(({ sandboxPath }) => sandboxPath))]; for (const sandboxPath of sandboxPaths) markPendingSandboxCleanup(sandboxPath); state.ownedSandboxes.clear(); state.ownedSandboxIdentities.clear(); + // Pooled entries were folded into `sandboxes` above; clear the pool and + // session-tracking state so a disposed instance can never lease or release. + state.freeSandboxes.length = 0; + state.sessionParents.clear(); + state.lastSessionSandbox.clear(); + state.activeBashCounts.clear(); state.xcodeMcpCache.clear(); state.xcodeMcpApprovalSessions.clear(); state.xcodeMcpApprovalHelpers.clear(); diff --git a/tests/test_opencode_plugin.py b/tests/test_opencode_plugin.py index dd8d113..5942015 100644 --- a/tests/test_opencode_plugin.py +++ b/tests/test_opencode_plugin.py @@ -3203,6 +3203,796 @@ def test_sweep_removes_sandbox_when_pid_was_reused(self): self.assertFalse(result["staleExists"]) + def test_subagent_sandbox_is_reused_after_idle(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import fs from "node:fs"; + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-reuse" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + const first = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + first, + ); + const firstSandbox = path.dirname(first.env.SANDBOX_DERIVED_DATA); + + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-2", parentID: "main-1" } }, + }, + }); + const second = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-2" }, + second, + ); + const secondSandbox = path.dirname(second.env.SANDBOX_DERIVED_DATA); + + const root = path.dirname(firstSandbox); + const entries = fs + .readdirSync(root) + .filter((name) => !name.startsWith(".")); + + console.log(JSON.stringify({ + firstSandbox, + secondSandbox, + entries, + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertEqual(result["firstSandbox"], result["secondSandbox"]) + self.assertEqual(len(result["entries"]), 1) + + def test_parallel_subagents_get_distinct_sandboxes(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-parallel" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + const sandboxes = []; + for (const sessionID of ["child-1", "child-2"]) { + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: sessionID, parentID: "main-1" } }, + }, + }); + const output = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID }, + output, + ); + sandboxes.push(path.dirname(output.env.SANDBOX_DERIVED_DATA)); + } + + console.log(JSON.stringify({ sandboxes })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertNotEqual(result["sandboxes"][0], result["sandboxes"][1]) + + def test_main_session_idle_does_not_release_its_sandbox(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-main-idle" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "main-1" } }, + }, + }); + const main = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "main-1" }, + main, + ); + const mainSandbox = path.dirname(main.env.SANDBOX_DERIVED_DATA); + + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "main-1" }, + }, + }); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + const child = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + child, + ); + const childSandbox = path.dirname(child.env.SANDBOX_DERIVED_DATA); + + console.log(JSON.stringify({ mainSandbox, childSandbox })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertNotEqual(result["mainSandbox"], result["childSandbox"]) + + def test_returning_subagent_prefers_its_previous_sandbox(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-prefer" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + const leases = {}; + for (const sessionID of ["child-1", "child-2"]) { + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: sessionID, parentID: "main-1" } }, + }, + }); + const output = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID }, + output, + ); + leases[sessionID] = path.dirname(output.env.SANDBOX_DERIVED_DATA); + } + + // Release child-2 first, then child-1, so child-1's sandbox + // sits on top of the LIFO stack when child-2 returns. + for (const sessionID of ["child-2", "child-1"]) { + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID }, + }, + }); + } + + const returning = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-2" }, + returning, + ); + const returningSandbox = path.dirname( + returning.env.SANDBOX_DERIVED_DATA, + ); + + console.log(JSON.stringify({ + previous: leases["child-2"], + returningSandbox, + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertEqual(result["previous"], result["returningSandbox"]) + + def test_invalid_pool_entries_are_discarded_on_lease(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import fs from "node:fs"; + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-discard" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + const first = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + first, + ); + const firstSandbox = path.dirname(first.env.SANDBOX_DERIVED_DATA); + + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + + // Replace the pooled directory behind the pool's back: same + // path, new inode. Identity revalidation must reject it and + // the next lease must fall through to a fresh directory. + fs.rmSync(firstSandbox, { recursive: true, force: true }); + fs.mkdirSync(firstSandbox, { mode: 0o700 }); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-2", parentID: "main-1" } }, + }, + }); + const second = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-2" }, + second, + ); + const secondSandbox = path.dirname(second.env.SANDBOX_DERIVED_DATA); + + console.log(JSON.stringify({ firstSandbox, secondSandbox })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertNotEqual(result["firstSandbox"], result["secondSandbox"]) + + def test_main_session_deletion_drains_the_free_pool(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import fs from "node:fs"; + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-drain" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + // Register main-1 so the deletion handler can classify it as + // a KNOWN main session — unknown deletions must not drain. + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "main-1" } }, + }, + }); + + const leases = {}; + for (const sessionID of ["child-1", "child-2"]) { + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: sessionID, parentID: "main-1" } }, + }, + }); + const output = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID }, + output, + ); + leases[sessionID] = path.dirname(output.env.SANDBOX_DERIVED_DATA); + } + + // child-1 finishes (its sandbox is pooled); child-2 stays live. + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + + await hooks.event({ + event: { + type: "session.deleted", + properties: { info: { id: "main-1" } }, + }, + }); + + // Drain removal is detached (/bin/rm), so poll for it. + for (let attempt = 0; attempt < 200; attempt += 1) { + if (!fs.existsSync(leases["child-1"])) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + console.log(JSON.stringify({ + pooledExists: fs.existsSync(leases["child-1"]), + leasedExists: fs.existsSync(leases["child-2"]), + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertFalse(result["pooledExists"]) + self.assertTrue(result["leasedExists"]) + + def test_deleting_a_finished_subagent_keeps_the_reused_sandbox(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import fs from "node:fs"; + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-no-double-free" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + const first = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + first, + ); + + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-2", parentID: "main-1" } }, + }, + }); + const second = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-2" }, + second, + ); + const reused = path.dirname(second.env.SANDBOX_DERIVED_DATA); + + // Deleting the finished child must not touch the sandbox that + // child-2 is now leasing. + await hooks.event({ + event: { + type: "session.deleted", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + + console.log(JSON.stringify({ + reusedExists: fs.existsSync(reused), + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertTrue(result["reusedExists"]) + + def test_plugin_dispose_removes_pooled_sandboxes(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import fs from "node:fs"; + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-dispose" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + const output = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + output, + ); + const sandbox = path.dirname(output.env.SANDBOX_DERIVED_DATA); + + // Release to the pool, then dispose: the pooled sandbox must + // be cleaned up even though no session owns it anymore. + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + await hooks.dispose(); + + for (let attempt = 0; attempt < 100; attempt += 1) { + if (!fs.existsSync(sandbox)) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + console.log(JSON.stringify({ + sandboxExists: fs.existsSync(sandbox), + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertFalse(result["sandboxExists"]) + + def test_reprompted_subagent_does_not_share_a_taken_sandbox(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import fs from "node:fs"; + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-reprompt" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + const first = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + first, + ); + const firstSandbox = path.dirname(first.env.SANDBOX_DERIVED_DATA); + + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-2", parentID: "main-1" } }, + }, + }); + const second = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-2" }, + second, + ); + const secondSandbox = path.dirname(second.env.SANDBOX_DERIVED_DATA); + + // child-2 took child-1's pooled sandbox. A re-prompted + // child-1 hits the fresh-path fallback with an empty pool + // and must NOT be handed the sandbox child-2 is leasing. + const reprompted = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + reprompted, + ); + const repromptedSandbox = path.dirname( + reprompted.env.SANDBOX_DERIVED_DATA, + ); + + // Deleting child-1 must remove only child-1's own sandbox, + // never the one child-2 is leasing. + await hooks.event({ + event: { + type: "session.deleted", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + + console.log(JSON.stringify({ + reused: secondSandbox === firstSandbox, + shared: repromptedSandbox === secondSandbox, + child2SandboxExists: fs.existsSync(secondSandbox), + repromptedSandboxExists: fs.existsSync(repromptedSandbox), + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertTrue(result["reused"]) + self.assertFalse(result["shared"]) + self.assertTrue(result["child2SandboxExists"]) + self.assertFalse(result["repromptedSandboxExists"]) + + def test_subagent_deletion_does_not_drain_the_free_pool(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import fs from "node:fs"; + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-child-delete" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + const leases = {}; + for (const sessionID of ["child-1", "child-2"]) { + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: sessionID, parentID: "main-1" } }, + }, + }); + const output = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID }, + output, + ); + leases[sessionID] = path.dirname(output.env.SANDBOX_DERIVED_DATA); + } + + // child-1's sandbox sits in the free pool when the OTHER + // subagent is deleted: only a MAIN session's deletion may + // drain the pool. + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + await hooks.event({ + event: { + type: "session.deleted", + properties: { info: { id: "child-2", parentID: "main-1" } }, + }, + }); + + console.log(JSON.stringify({ + pooledExists: fs.existsSync(leases["child-1"]), + deletedExists: fs.existsSync(leases["child-2"]), + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertTrue(result["pooledExists"]) + self.assertFalse(result["deletedExists"]) + + def test_stale_idle_with_inflight_bash_does_not_pool_the_sandbox(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-stale-idle" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + const first = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + first, + ); + const firstSandbox = path.dirname(first.env.SANDBOX_DERIVED_DATA); + + // A command is in flight when a delayed (stale) idle for the + // re-prompted session is finally processed: the release must + // be skipped, or another session could lease a sandbox the + // running build is writing to. + await hooks["tool.execute.before"]( + { tool: "bash", sessionID: "child-1" }, + { args: { command: "true" } }, + ); + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-2", parentID: "main-1" } }, + }, + }); + const second = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-2" }, + second, + ); + const secondSandbox = path.dirname(second.env.SANDBOX_DERIVED_DATA); + + // Once the command completes, the next idle releases as usual. + await hooks["tool.execute.after"]( + { tool: "bash", sessionID: "child-1" }, + ); + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-3", parentID: "main-1" } }, + }, + }); + const third = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-3" }, + third, + ); + const thirdSandbox = path.dirname(third.env.SANDBOX_DERIVED_DATA); + + console.log(JSON.stringify({ + pooledWhileInflight: secondSandbox === firstSandbox, + pooledAfterCompletion: thirdSandbox === firstSandbox, + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertFalse(result["pooledWhileInflight"]) + self.assertTrue(result["pooledAfterCompletion"]) + + def test_session_created_event_registers_parentage(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-created" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + const sandboxes = {}; + for (const sessionID of ["child-1", "child-2"]) { + await hooks.event({ + event: { + type: "session.created", + properties: { info: { id: sessionID, parentID: "main-1" } }, + }, + }); + } + + const first = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + first, + ); + sandboxes["child-1"] = path.dirname(first.env.SANDBOX_DERIVED_DATA); + + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + + const second = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-2" }, + second, + ); + sandboxes["child-2"] = path.dirname(second.env.SANDBOX_DERIVED_DATA); + + console.log(JSON.stringify({ + reused: sandboxes["child-1"] === sandboxes["child-2"], + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertTrue(result["reused"]) + + def test_partial_session_update_keeps_child_parentage(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = self.run_node( + """ + import path from "node:path"; + + const plugin = (await import( + "./XcodeBuildTools/opencode-plugin.js?test=pool-partial-update" + )).default; + const hooks = await plugin.server({}); + await hooks.config({}); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1", parentID: "main-1" } }, + }, + }); + // A later payload without parentID must not downgrade the + // known child to a main session. + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-1" } }, + }, + }); + + const first = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-1" }, + first, + ); + const firstSandbox = path.dirname(first.env.SANDBOX_DERIVED_DATA); + + await hooks.event({ + event: { + type: "session.idle", + properties: { sessionID: "child-1" }, + }, + }); + + await hooks.event({ + event: { + type: "session.updated", + properties: { info: { id: "child-2", parentID: "main-1" } }, + }, + }); + const second = { env: {} }; + await hooks["shell.env"]( + { cwd: process.cwd(), sessionID: "child-2" }, + second, + ); + const secondSandbox = path.dirname(second.env.SANDBOX_DERIVED_DATA); + + console.log(JSON.stringify({ + released: secondSandbox === firstSandbox, + })); + """, + {"TMPDIR": tmpdir}, + ) + + self.assertTrue(result["released"]) + if __name__ == "__main__": unittest.main()