From daf97f7228c21a39cc2426f4561c005264c881fb Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Wed, 9 Sep 2026 13:14:38 -0400 Subject: [PATCH] fix(development): resume manager selections with boot-bound process custody --- package-lock.json | 4 +-- package.json | 2 +- .../commands/development-support/overlays.ts | 17 +++++----- .../development-support/process-identity.ts | 23 ++++++++++++++ src/cli/commands/development.ts | 31 +++++++++++++++---- src/cli/development/boot-resume.ts | 8 +++++ .../development/process-identity.test.ts | 21 +++++++++++++ 7 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 src/cli/commands/development-support/process-identity.ts create mode 100644 src/cli/development/boot-resume.ts create mode 100644 tests/unit/command-boundary/development/process-identity.test.ts diff --git a/package-lock.json b/package-lock.json index 864d8629..bef5b352 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treeseed/cli", - "version": "0.13.0-rc.71", + "version": "0.13.0-rc.72", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/cli", - "version": "0.13.0-rc.71", + "version": "0.13.0-rc.72", "bundleDependencies": [ "ink", "react", diff --git a/package.json b/package.json index 9e4cdef9..cbbd42ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/cli", - "version": "0.13.0-rc.71", + "version": "0.13.0-rc.72", "description": "Operator-facing Treeseed CLI package.", "license": "Apache-2.0", "repository": { diff --git a/src/cli/commands/development-support/overlays.ts b/src/cli/commands/development-support/overlays.ts index 1806a55e..ef4e600c 100644 --- a/src/cli/commands/development-support/overlays.ts +++ b/src/cli/commands/development-support/overlays.ts @@ -4,21 +4,22 @@ import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { DevelopmentRuntime, DevelopmentTarget } from '@treeseed/sdk/development'; import { developmentStateRoot } from '../development-cli-selection.js'; +import { ownsDevelopmentProcess, processIdentity } from './process-identity.js'; interface OverlaySessionState { sessionId: string; - processes: Record; + processes: Record; overlays: Array<{ projectId: string; packageName: string; link: string; backup: string | null; overlayRoot: string }>; } export async function stopProcesses(state: OverlaySessionState) { - const running = Object.values(state.processes); + const running = Object.values(state.processes).filter(entry => ownsDevelopmentProcess(entry, state.sessionId)); for (const processState of running) { try { process.kill(-processState.pid, 'SIGTERM'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; } } const deadline = Date.now() + 5_000; while (Date.now() < deadline && running.some((processState) => { try { process.kill(processState.pid, 0); return true; } catch { return false; } })) await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); - for (const processState of running) { try { process.kill(-processState.pid, 'SIGKILL'); } catch { /* process exited during the grace period */ } } + for (const processState of running) { if (ownsDevelopmentProcess(processState, state.sessionId)) try { process.kill(-processState.pid, 'SIGKILL'); } catch { /* process exited during the grace period */ } } state.processes = {}; return running; } @@ -82,7 +83,8 @@ export function relativeOverlayTarget(link: string, overlayRoot: string) { export function startPackageSynchronizer(state: OverlaySessionState, runtime: DevelopmentRuntime, target: DevelopmentTarget, worktree: string, env: NodeJS.ProcessEnv, cliWorktree?: string) { const key = `overlay-sync.${runtime.project.id}.${target.id}`, overlayRoot = resolve(worktree, '.treeseed', 'cache', 'development-sessions', state.sessionId, target.id); - const existing = state.processes[key]; if (existing) { try { process.kill(existing.pid, 0); return overlayRoot; } catch { delete state.processes[key]; } } + const existing = state.processes[key]; if (existing && ownsDevelopmentProcess(existing, state.sessionId)) return overlayRoot; + delete state.processes[key]; const installedCompiledModule = fileURLToPath(new URL('../../development/package-overlay-sync.js', import.meta.url)); const worktreeCompiledModule = cliWorktree ? resolve(cliWorktree, 'dist/cli/development/package-overlay-sync.js') : ''; const worktreeSourceModule = cliWorktree ? resolve(cliWorktree, 'src/cli/development/package-overlay-sync.ts') : ''; @@ -94,8 +96,8 @@ export function startPackageSynchronizer(state: OverlaySessionState, runtime: De mkdirSync(dirname(log), { recursive: true, mode: 0o700 }); const descriptor = openSync(log, 'a', 0o600); try { if (target.ready.kind !== 'marker') throw new Error(`${key} requires marker readiness.`); - const child = spawn(process.execPath, [...moduleArguments, worktree, overlayRoot, JSON.stringify(target.outputs.map((output) => output.path)), target.ready.path], { cwd: worktree, env, detached: true, stdio: ['ignore', descriptor, descriptor] }); - child.unref(); if (!child.pid) throw new Error(`Failed to start ${key}.`); state.processes[key] = { pid: child.pid, projectId: runtime.project.id, targetId: target.id, log }; + const child = spawn(process.execPath, [...moduleArguments, worktree, overlayRoot, JSON.stringify(target.outputs.map((output) => output.path)), target.ready.path], { cwd: worktree, env: { ...env, TREESEED_DEVELOPMENT_SESSION_ID: state.sessionId }, detached: true, stdio: ['ignore', descriptor, descriptor] }); + child.unref(); if (!child.pid) throw new Error(`Failed to start ${key}.`); state.processes[key] = { pid: child.pid, identity: processIdentity(child.pid), projectId: runtime.project.id, targetId: target.id, log }; } finally { closeSync(descriptor); } return overlayRoot; } @@ -129,13 +131,14 @@ export async function waitForNewPackageOverlay(target: DevelopmentTarget, worktr export async function stopProcess(state: OverlaySessionState, key: string) { const processState = state.processes[key]; if (!processState) return; + if (!ownsDevelopmentProcess(processState, state.sessionId)) { delete state.processes[key]; return; } try { process.kill(-processState.pid, 'SIGTERM'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; } const deadline = Date.now() + 5_000; while (Date.now() < deadline) { try { process.kill(processState.pid, 0); } catch { break; } await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); } - try { process.kill(-processState.pid, 'SIGKILL'); } catch { /* it exited during the grace period */ } + if (ownsDevelopmentProcess(processState, state.sessionId)) try { process.kill(-processState.pid, 'SIGKILL'); } catch { /* it exited during the grace period */ } delete state.processes[key]; } diff --git a/src/cli/commands/development-support/process-identity.ts b/src/cli/commands/development-support/process-identity.ts new file mode 100644 index 00000000..ba29c21a --- /dev/null +++ b/src/cli/commands/development-support/process-identity.ts @@ -0,0 +1,23 @@ +import { readFileSync, statSync } from 'node:fs'; + +/** A PID alone is not process custody: it may be reused, including after boot. */ +export function processIdentity(pid: number): string | undefined { + try { + if (!Number.isSafeInteger(pid) || pid <= 1 || statSync(`/proc/${pid}`).uid !== process.getuid?.()) return; + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + if (Number(fields[2]) !== pid || fields[0] === 'Z') return; + return `${readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim()}:${fields[19]}`; + } catch { return; } +} + +export function ownsDevelopmentProcess(entry: { pid: number; identity?: string }, sessionId: string): boolean { + const current = processIdentity(entry.pid); + if (!current) return false; + if (entry.identity) return entry.identity === current; + // Existing saved selections require same-user, session-marked process evidence. + try { + return readFileSync(`/proc/${entry.pid}/environ`, 'utf8').split('\0') + .includes(`TREESEED_DEVELOPMENT_SESSION_ID=${sessionId}`); + } catch { return false; } +} diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index 10a09ae2..3ae80e38 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -12,6 +12,7 @@ import { dependentReactions, installPackageOverlay, overlayGeneration, relativeO import { artifactPaths, compatibilityAttestations, withFreezeLock } from './development-support/candidate.js'; import { runHostDevelopment } from './development-support/host-runtime.js'; import { applyDevelopmentRecovery, planDevelopmentRecovery } from './development-support/recovery.js'; +import { ownsDevelopmentProcess, processIdentity } from './development-support/process-identity.js'; export { relativeOverlayTarget, startPackageSynchronizer, stopProcess, waitForNewPackageOverlay } from './development-support/overlays.js'; export { developmentCliEntrypointPath, selectDevelopmentCli } from './development-cli-selection.js'; @@ -20,7 +21,7 @@ interface LocalSessionState { sessionId: string; manifest: string; workspaceRoot?: string; - processes: Record; + processes: Record; overlays: Array<{ projectId: string; packageName: string; link: string; backup: string | null; overlayRoot: string }>; candidates: string[]; } @@ -144,7 +145,8 @@ function startOperation(state: LocalSessionState, runtime: DevelopmentRuntime, t if (!operation) return null; const key = `${runtime.project.id}.${target.id}`; const existing = state.processes[key]; - if (existing) { try { process.kill(existing.pid, 0); return existing; } catch { delete state.processes[key]; } } + if (existing && ownsDevelopmentProcess(existing, state.sessionId)) return existing; + delete state.processes[key]; const root = operation.cwd ? resolve(worktree, operation.cwd) : worktree; const log = resolve(developmentStateRoot(env), state.sessionId, `${key}.log`); mkdirSync(dirname(log), { recursive: true, mode: 0o700 }); @@ -152,13 +154,14 @@ function startOperation(state: LocalSessionState, runtime: DevelopmentRuntime, t try { const child = spawn(operation.command, operation.args, { cwd: root, env: developmentOperationEnvironment(state, worktree, mode, env, resolvedEnvironment, operation.environment), detached: true, stdio: ['ignore', descriptor, descriptor] }); child.unref(); if (!child.pid) throw new Error(`Failed to start ${key}.`); - return state.processes[key] = { pid: child.pid, projectId: runtime.project.id, targetId: target.id, log }; + return state.processes[key] = { pid: child.pid, identity: processIdentity(child.pid), projectId: runtime.project.id, targetId: target.id, log }; } finally { closeSync(descriptor); } } function operationIsRunning(state: LocalSessionState, key: string) { const existing = state.processes[key]; if (!existing) return false; - try { process.kill(existing.pid, 0); return true; } catch { delete state.processes[key]; return false; } + if (ownsDevelopmentProcess(existing, state.sessionId)) return true; + delete state.processes[key]; return false; } async function waitForDirectReadiness(target: DevelopmentTarget, timeoutSeconds: number, state?: LocalSessionState, key?: string) { @@ -196,9 +199,10 @@ async function startSession(invocation: ParsedInvocation, context: CommandContex saveState({ sessionId, manifest, processes: {}, overlays: [], candidates: [] }, context.env); return result; } -async function useTargets(invocation: ParsedInvocation, context: CommandContext) { +async function useTargets(invocation: Pick, context: CommandContext) { const state = loadState(context.env,invocation.options.session), sessionId = String(invocation.options.session ?? state.sessionId); - const record = await invoke(context, 'local.dev.status', { sessionId, all: false }) as { session: { repositories: Array<{ projectId: string; worktree: string }> }; runtimes: DevelopmentRuntime[] }; + const record = await invoke(context, 'local.dev.status', { sessionId, all: false }) as { session: { status?: string; repositories: Array<{ projectId: string; worktree: string }> }; runtimes: DevelopmentRuntime[] }; + if (record.session.status === 'stopped') throw new Error('The development session has been explicitly stopped.'); const selections = [invocation.arguments[0]!, ...(Array.isArray(invocation.options.target) ? invocation.options.target : [])].map(parseSelection); if (invocation.options.plan === true) return { sessionId, selections, mutation: false }; for (const selection of selections) { @@ -394,6 +398,21 @@ async function rebuild(invocation: ParsedInvocation, context: CommandContext, st return { sessionId, target: `${selection.projectId}.${selection.targetId}`, manual, record: await invoke(context, 'local.dev.status', { sessionId, all: false }) }; } +/** Resume only current manager selections; never reconstruct desired state from stale PIDs. */ +export async function resumeDevelopmentSession(sessionId: string, context: CommandContext) { + if (!/^dev-[a-z0-9-]{1,64}$/.test(sessionId)) throw new Error('An exact development session is required.'); + loadState(context.env, sessionId); + const record = await invoke(context, 'local.dev.status', { sessionId, all: false }) as DevelopmentStatusRecord & { session: { status: string } }; + if (record.session.status === 'stopped') return; + for (const target of record.session.targets.filter(target => target.mode !== 'released')) { + const current = await invoke(context, 'local.dev.status', { sessionId, all: false }) as typeof record; + if (current.session.status === 'stopped') return; + const selected = current.session.targets.find(entry => entry.projectId === target.projectId && entry.targetId === target.targetId); + if (!selected || selected.mode !== target.mode) continue; + await useTargets({ arguments: [`${target.projectId}.${target.targetId}=${target.mode}`], options: { session: sessionId } }, context); + } +} + export async function runDevelopment(invocation: ParsedInvocation, context: CommandContext) { if (invocation.command.name === 'dev session recover') { const sessionId = String(invocation.options.session ?? ''); diff --git a/src/cli/development/boot-resume.ts b/src/cli/development/boot-resume.ts new file mode 100644 index 00000000..a1aa6a9d --- /dev/null +++ b/src/cli/development/boot-resume.ts @@ -0,0 +1,8 @@ +import { resumeDevelopmentSession } from '../commands/development.js'; + +// Invoked by the manager's fixed, unprivileged boot job, not by project source. +if (!process.getuid || process.getuid() === 0) throw new Error('Development processes must never resume as root.'); +await resumeDevelopmentSession(process.argv[2] ?? '', { + cwd: process.cwd(), env: process.env, outputFormat: 'json', interactiveUi: false, + write: () => undefined, +}); diff --git a/tests/unit/command-boundary/development/process-identity.test.ts b/tests/unit/command-boundary/development/process-identity.test.ts new file mode 100644 index 00000000..ad8499be --- /dev/null +++ b/tests/unit/command-boundary/development/process-identity.test.ts @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { ownsDevelopmentProcess, processIdentity } from '../../../../src/cli/commands/development-support/process-identity.ts'; + +test('process custody rejects reused PID identity and foreign sessions', async () => { + const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + detached: true, stdio: 'ignore', env: { ...process.env, TREESEED_DEVELOPMENT_SESSION_ID: 'dev-test' }, + }); + await once(child, 'spawn'); + try { + const pid = child.pid!; + const identity = processIdentity(pid); + assert.ok(identity); + assert.equal(ownsDevelopmentProcess({ pid, identity }, 'dev-test'), true); + assert.equal(ownsDevelopmentProcess({ pid, identity: 'previous-boot:1' }, 'dev-test'), false); + assert.equal(ownsDevelopmentProcess({ pid }, 'dev-other'), false); + assert.equal(ownsDevelopmentProcess({ pid }, 'dev-test'), true); + } finally { child.kill(); await once(child, 'exit'); } +});