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: 3 additions & 1 deletion bin/rstack-agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -611,13 +611,15 @@ program
.option('-f, --framework <framework>', `host framework to check wiring for: ${DOCTOR_FRAMEWORKS.join(' | ')} (auto-detected if omitted)`)
.option('-p, --project <path>', 'project root (defaults to current directory)')
.option('--json', 'print the structured report as JSON for CI')
.option('--start-runtime', 'opt-in: if the sandbox container engine (docker/podman) is installed but stopped, launch it and wait (bounded) for it to become ready — never launched otherwise')
.action(async (opts) => {
try {
if (opts.framework && !DOCTOR_FRAMEWORKS.includes(opts.framework)) {
log.error(`Unknown framework "${opts.framework}". Expected one of: ${DOCTOR_FRAMEWORKS.join(', ')}`);
process.exit(1);
}
const report = await runDoctor({ framework: opts.framework, project: opts.project });
const autostart = opts.startRuntime === true || process.env.RSTACK_SANDBOX_AUTOSTART === '1';
const report = await runDoctor({ framework: opts.framework, project: opts.project, autostart });
if (opts.json) {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} else {
Expand Down
64 changes: 63 additions & 1 deletion src/commands/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { validateProjectConfigs } from '../core/harness/config-validation.js';
// #452 PR3: report the active sandbox execution tier + opt-in bounded auto-start.
import { detectContainerRuntime, detectInstalledRuntime, loadSandboxConfig, startContainerRuntime } from '../core/harness/sandbox.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = resolve(__dirname, '..', '..');
Expand Down Expand Up @@ -190,6 +192,61 @@ function checkEmailNotifications(projectRoot, env = process.env) {
'Set RSTACK_ACS_CONNECTION_STRING in the environment and add channels.email.sender + recipients/routing to .rstack/notifications.json (see docs/mintlify/reference/approvals.mdx)');
}

// --- sandbox execution tier (#452 PR3) --------------------------------------

// Pure decision: given what's detected + the config, what tier is active and
// what does the operator need to know? Separated from the I/O below so the
// verdict logic is unit-testable without a container daemon. `autostart` is the
// startContainerRuntime result (or null when not requested).
export function sandboxTierCheck({ readyRuntime, installedRuntime, config, autostart }) {
const note = autostart?.message ? ` (auto-start: ${autostart.message})` : '';
if (config && config.enabled === false) {
return check('sandbox execution tier', WARN,
`sandbox execution is DISABLED in config — sdlc_validate uses the self-reported tests_run only, never container-verified${note}`,
'Set sandbox.enabled=true in .rstack/rstack.config.json to run tests in a container');
}
if (readyRuntime) {
const hasCommand = Boolean(config?.command) || Object.keys(config?.perStage ?? {}).length > 0;
if (hasCommand) {
return check('sandbox execution tier', PASS,
`container-verified (${readyRuntime}) — sdlc_validate runs the authoritative command in a locked-down ${readyRuntime} container and authors execution evidence from the REAL exit code${note}`);
}
return check('sandbox execution tier', WARN,
`${readyRuntime} engine is ready, but NO authoritative test command is configured (sandbox.command / sandbox.per_stage) — execution stays UNVERIFIED until one is set or a task carries test_command${note}`,
'Add sandbox.command (e.g. "npm test") to .rstack/rstack.config.json to turn on container-verified execution');
}
if (installedRuntime) {
const fix = installedRuntime === 'podman'
? 'Start the engine: podman machine start (or: rstack-agents doctor --start-runtime)'
: process.platform === 'darwin'
? 'Start Docker Desktop (open -a Docker), or: rstack-agents doctor --start-runtime'
: `Start the ${installedRuntime} engine, or: rstack-agents doctor --start-runtime`;
return check('sandbox execution tier', WARN,
`${installedRuntime} is installed but its engine is not running — execution degrades to UNVERIFIED (contract validation only), never a false green${note}`,
fix);
}
return check('sandbox execution tier', WARN,
`no container runtime (docker/podman) available — execution is UNVERIFIED: sdlc_validate falls back to contract validation + self-reported tests_run, never a false green${note}`,
'Install Docker or Podman for container-verified execution (https://docs.docker.com/get-docker/ or https://podman.io)');
}

async function checkSandboxTier(projectRoot, { autostart = false } = {}) {
let config;
try {
config = await loadSandboxConfig(projectRoot);
} catch (error) {
return check('sandbox execution tier', WARN, `could not read sandbox config: ${error.message}`, null);
}
let readyRuntime = detectContainerRuntime({});
const installedRuntime = detectInstalledRuntime({});
let autostartResult = null;
if (autostart && !readyRuntime) {
autostartResult = await startContainerRuntime({});
if (autostartResult.ready) readyRuntime = autostartResult.runtime;
}
return sandboxTierCheck({ readyRuntime, installedRuntime, config, autostart: autostartResult });
}

// --- framework wiring -------------------------------------------------------

async function detectFrameworkLocal(projectRoot) {
Expand Down Expand Up @@ -737,7 +794,7 @@ async function checkSelfDependency(cwd) {

// --- orchestration ----------------------------------------------------------

export async function runDoctor({ framework, project, cwd = process.cwd() } = {}) {
export async function runDoctor({ framework, project, cwd = process.cwd(), autostart = false } = {}) {
const projectRoot = resolve(project ?? cwd);
const checks = [];

Expand All @@ -761,6 +818,11 @@ export async function runDoctor({ framework, project, cwd = process.cwd() } = {}
const emailCheck = checkEmailNotifications(projectRoot);
if (emailCheck) checks.push(emailCheck);

// Sandbox execution tier (#452): container-verified vs. unverified — the honest
// answer to "are my test results real or self-reported?". With autostart, tries
// an opt-in bounded engine start first (never a hidden launch otherwise).
checks.push(await checkSandboxTier(projectRoot, { autostart }));

// Framework wiring (explicit --framework, else auto-detect; else all-generic)
const detected = framework ?? await detectFrameworkLocal(projectRoot);
const effectiveFramework = framework ?? detected ?? 'custom';
Expand Down
91 changes: 87 additions & 4 deletions src/core/harness/sandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,41 @@ export const MAX_TIMEOUT_MS = 600_000;
const OUTPUT_TAIL_BYTES = 8 * 1024; // bounded like goal-check's maxBuffer precedent
const RUNTIMES = ['docker', 'podman'];

// Detect an available container runtime. Injectable probe so tests never shell
// out. Returns 'docker' | 'podman' | null.
export function detectContainerRuntime({ probe = defaultProbe } = {}) {
// Detect a USABLE container runtime — one whose engine daemon is actually
// reachable, not merely installed. `docker info` / `podman info` exit 0 only
// when the daemon is up; a present binary with a STOPPED daemon must NOT count,
// or runInSandbox would spawn `docker run`, hit "Cannot connect to the Docker
// daemon", and mis-record that connection error as a test FAILURE instead of an
// honest 'unverified' degrade. Injectable probe so tests never shell out.
// Returns 'docker' | 'podman' | null.
export function detectContainerRuntime({ probe = defaultReadyProbe } = {}) {
for (const runtime of RUNTIMES) {
if (probe(runtime)) return runtime;
}
return null;
}

function defaultProbe(runtime) {
function defaultReadyProbe(runtime) {
try {
const res = spawnSync(runtime, ['info'], { stdio: 'ignore', timeout: 10_000 });
return res.status === 0;
} catch {
return false;
}
}

// Detect an INSTALLED runtime binary (daemon may or may not be running). Used by
// the auto-start path to tell "installed but stopped" (startable) apart from
// "not installed at all" (nothing to start). `--version` succeeds even when the
// daemon is down.
export function detectInstalledRuntime({ probe = defaultInstalledProbe } = {}) {
for (const runtime of RUNTIMES) {
if (probe(runtime)) return runtime;
}
return null;
}

function defaultInstalledProbe(runtime) {
try {
const res = spawnSync(runtime, ['--version'], { stdio: 'ignore', timeout: 5_000 });
return res.status === 0;
Expand All @@ -47,6 +72,64 @@ function defaultProbe(runtime) {
}
}

// The command that boots each runtime's engine, per platform. Docker Desktop is
// a GUI app on mac/Windows (`open -a Docker` / launch "Docker Desktop"); its
// daemon then takes ~10-30s to become ready. Podman is headless
// (`podman machine start`). On Linux the docker daemon is a system service we
// will not `sudo systemctl` for the user — returns null there (manual start).
export function runtimeStartCommand(runtime, platform = process.platform) {
if (runtime === 'podman') return { cmd: 'podman', args: ['machine', 'start'] };
if (runtime === 'docker') {
if (platform === 'darwin') return { cmd: 'open', args: ['-a', 'Docker'] };
if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '', 'Docker Desktop'] };
return null; // linux: `sudo systemctl start docker` — not ours to run
}
return null;
}

// OPT-IN, bounded auto-start. NEVER runs unless the caller explicitly asks
// (doctor --start-runtime or RSTACK_SANDBOX_AUTOSTART=1) — a gate must never
// silently launch a heavyweight GUI mid-validation. Launches the engine, then
// polls readiness with a HARD timeout so nothing blocks indefinitely on a
// booting daemon. Fully injectable for tests.
export async function startContainerRuntime({
installedProbe = defaultInstalledProbe,
readyProbe = defaultReadyProbe,
spawnImpl = spawn,
platform = process.platform,
timeoutMs = 60_000,
pollMs = 2_000,
now = Date.now,
sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)),
} = {}) {
const runtime = detectInstalledRuntime({ probe: installedProbe });
if (!runtime) {
return { runtime: null, started: false, ready: false, message: 'no docker/podman binary on PATH — install one first' };
}
if (readyProbe(runtime)) {
return { runtime, started: false, ready: true, message: `${runtime} engine already running` };
}
const startCmd = runtimeStartCommand(runtime, platform);
if (!startCmd) {
return { runtime, started: false, ready: false, message: `${runtime} engine is not running and RStack cannot auto-start it on ${platform} — start it manually` };
}
try {
const child = spawnImpl(startCmd.cmd, startCmd.args, { stdio: 'ignore' });
child?.on?.('error', () => {});
child?.unref?.();
} catch (err) {
return { runtime, started: false, ready: false, message: `failed to launch ${runtime}: ${err?.message ?? err}` };
}
const start = now();
while (now() - start < timeoutMs) {
await sleep(pollMs);
if (readyProbe(runtime)) {
return { runtime, started: true, ready: true, message: `${runtime} engine started and is ready` };
}
}
return { runtime, started: true, ready: false, message: `${runtime} launch initiated but the engine was not ready within ${timeoutMs}ms — try again shortly` };
}

// Build the locked-down `docker/podman run` argv for an untrusted command.
// Exported so the security flags are testable without a daemon.
export function buildSandboxArgv(runtime, { runDir, command, network = false, image = 'alpine:3.20', limits = {}, containerName }) {
Expand Down
136 changes: 136 additions & 0 deletions tests/sandbox-doctor-452.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Transient Sandbox — "The Scientist" (#452), PR 3: doctor tier report +
* opt-in bounded runtime auto-start.
*
* Covers the pure tier verdict (sandboxTierCheck), readiness-vs-installed
* detection, per-platform start commands, and the injectable bounded auto-start
* (already-running / not-installed / becomes-ready / times-out) — all without a
* real container daemon.
*
* owner: RStack developed by Richardson Gunde
*/

import test from 'node:test';
import assert from 'node:assert/strict';

import {
detectInstalledRuntime,
runtimeStartCommand,
startContainerRuntime,
resolveSandboxConfig,
} from '../src/core/harness/sandbox.js';
import { sandboxTierCheck } from '../src/commands/doctor.js';

// --- sandboxTierCheck (pure verdict) ---------------------------------------

test('sandboxTierCheck: ready runtime + configured command → PASS container-verified', () => {
const c = sandboxTierCheck({ readyRuntime: 'docker', installedRuntime: 'docker', config: resolveSandboxConfig({ command: 'npm test' }) });
assert.equal(c.status, 'PASS');
assert.match(c.detail, /container-verified \(docker\)/);
});

test('sandboxTierCheck: ready runtime but NO command → WARN (still unverified)', () => {
const c = sandboxTierCheck({ readyRuntime: 'docker', installedRuntime: 'docker', config: resolveSandboxConfig() });
assert.equal(c.status, 'WARN');
assert.match(c.detail, /NO authoritative test command/);
});

test('sandboxTierCheck: installed but engine stopped → WARN with start hint', () => {
const c = sandboxTierCheck({ readyRuntime: null, installedRuntime: 'podman', config: resolveSandboxConfig({ command: 'npm test' }) });
assert.equal(c.status, 'WARN');
assert.match(c.detail, /engine is not running/);
assert.match(c.fix, /podman machine start|--start-runtime/);
});

test('sandboxTierCheck: no runtime at all → WARN unverified, never a false green', () => {
const c = sandboxTierCheck({ readyRuntime: null, installedRuntime: null, config: resolveSandboxConfig({ command: 'npm test' }) });
assert.equal(c.status, 'WARN');
assert.match(c.detail, /no container runtime/);
assert.match(c.detail, /never a false green/);
});

test('sandboxTierCheck: disabled config → WARN, self-report only', () => {
const c = sandboxTierCheck({ readyRuntime: 'docker', installedRuntime: 'docker', config: resolveSandboxConfig({ enabled: false }) });
assert.equal(c.status, 'WARN');
assert.match(c.detail, /DISABLED/);
});

test('sandboxTierCheck: surfaces the auto-start outcome note', () => {
const c = sandboxTierCheck({ readyRuntime: 'docker', installedRuntime: 'docker', config: resolveSandboxConfig({ command: 'npm test' }), autostart: { message: 'docker engine started and is ready' } });
assert.match(c.detail, /auto-start: docker engine started and is ready/);
});

// --- detection + start command ---------------------------------------------

test('detectInstalledRuntime prefers docker, falls back to podman, else null', () => {
assert.equal(detectInstalledRuntime({ probe: (r) => r === 'docker' }), 'docker');
assert.equal(detectInstalledRuntime({ probe: (r) => r === 'podman' }), 'podman');
assert.equal(detectInstalledRuntime({ probe: () => false }), null);
});

test('runtimeStartCommand is platform-aware', () => {
assert.deepEqual(runtimeStartCommand('podman'), { cmd: 'podman', args: ['machine', 'start'] });
assert.deepEqual(runtimeStartCommand('docker', 'darwin'), { cmd: 'open', args: ['-a', 'Docker'] });
assert.equal(runtimeStartCommand('docker', 'linux'), null, 'no auto-start of a linux system daemon');
assert.equal(runtimeStartCommand('nope', 'darwin'), null);
});

// --- startContainerRuntime (bounded, injectable) ----------------------------

test('startContainerRuntime: not installed → runtime null, nothing launched', async () => {
let launched = false;
const res = await startContainerRuntime({ installedProbe: () => false, spawnImpl: () => { launched = true; } });
assert.equal(res.runtime, null);
assert.equal(res.ready, false);
assert.equal(launched, false);
});

test('startContainerRuntime: already running → no launch, ready', async () => {
let launched = false;
const res = await startContainerRuntime({ installedProbe: (r) => r === 'docker', readyProbe: () => true, spawnImpl: () => { launched = true; } });
assert.equal(res.started, false);
assert.equal(res.ready, true);
assert.equal(launched, false, 'never relaunch an already-running engine');
});

test('startContainerRuntime: installed+stopped → launches, polls, becomes ready', async () => {
let ready = false;
const res = await startContainerRuntime({
installedProbe: (r) => r === 'docker',
readyProbe: () => ready,
spawnImpl: () => { ready = true; return { on() {}, unref() {} }; },
platform: 'darwin',
sleep: async () => {},
now: (() => { let t = 0; return () => (t += 1000); })(),
pollMs: 1, timeoutMs: 100000,
});
assert.equal(res.started, true);
assert.equal(res.ready, true);
assert.equal(res.runtime, 'docker');
});

test('startContainerRuntime: never ready → bounded timeout, ready false', async () => {
const res = await startContainerRuntime({
installedProbe: (r) => r === 'docker',
readyProbe: () => false,
spawnImpl: () => ({ on() {}, unref() {} }),
platform: 'darwin',
sleep: async () => {},
now: (() => { let t = 0; return () => (t += 50000); })(),
pollMs: 1, timeoutMs: 100000,
});
assert.equal(res.started, true);
assert.equal(res.ready, false);
assert.match(res.message, /not ready within/);
});

test('startContainerRuntime: installed but no platform start path → honest message', async () => {
const res = await startContainerRuntime({
installedProbe: (r) => r === 'docker',
readyProbe: () => false,
platform: 'linux', // docker on linux is a system daemon we won't sudo-start
spawnImpl: () => { throw new Error('should not spawn'); },
});
assert.equal(res.ready, false);
assert.match(res.message, /cannot auto-start/);
});
Loading