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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
17 changes: 10 additions & 7 deletions src/cli/commands/development-support/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { pid: number; projectId: string; targetId: string; log: string }>;
processes: Record<string, { pid: number; identity?: string; projectId: string; targetId: string; log: string }>;
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;
}
Expand Down Expand Up @@ -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') : '';
Expand All @@ -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;
}
Expand Down Expand Up @@ -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];
}

Expand Down
23 changes: 23 additions & 0 deletions src/cli/commands/development-support/process-identity.ts
Original file line number Diff line number Diff line change
@@ -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; }
}
31 changes: 25 additions & 6 deletions src/cli/commands/development.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,7 +21,7 @@ interface LocalSessionState {
sessionId: string;
manifest: string;
workspaceRoot?: string;
processes: Record<string, { pid: number; projectId: string; targetId: string; log: string }>;
processes: Record<string, { pid: number; identity?: string; projectId: string; targetId: string; log: string }>;
overlays: Array<{ projectId: string; packageName: string; link: string; backup: string | null; overlayRoot: string }>;
candidates: string[];
}
Expand Down Expand Up @@ -144,21 +145,23 @@ 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 });
const descriptor = openSync(log, 'a', 0o600);
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) {
Expand Down Expand Up @@ -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<ParsedInvocation, 'arguments' | 'options'>, 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) {
Expand Down Expand Up @@ -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 ?? '');
Expand Down
8 changes: 8 additions & 0 deletions src/cli/development/boot-resume.ts
Original file line number Diff line number Diff line change
@@ -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,
});
21 changes: 21 additions & 0 deletions tests/unit/command-boundary/development/process-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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'); }
});
Loading