mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
Lays the foundation for multi-tenant provider spawning isolation per ADR 0014 § Decision. NO production wiring — PR-B (anthropic.mjs spawn wrap) lands separately and requires bubblewrap + socat + ripgrep installed on PI231 first. Files: - package.json: add @anthropic-ai/sandbox-runtime ^0.0.52 - lib/sandbox/doctor.mjs (new): preflight checkSandboxAvailability + describeSandboxStatus; pure module, no state, no initialize() call - server.mjs: /health response gains 'sandbox' field with availability + missing deps + install hint; result memoized via _sandboxStatusCache; __resetSandboxStatusCache() test seam exported - docs/adr/0014-sandbox-runtime-integration.md (new): 4-PR layered rollout decision per Iron Rule 11; PR-B/C/D acceptance criteria previewed; 6 spike pitfalls recorded; 282 lines - CHANGELOG.md: Unreleased entry under Phase 7 PR-A - test-features.mjs Suite 42 (+8 tests, 797 → 805, all pass) Authority: - @anthropic-ai/sandbox-runtime v0.0.52 — anthropic-experimental org https://github.com/anthropic-experimental/sandbox-runtime - 2026-05-28 PoC spike (verdict YELLOW; report at /tmp/sandbox-spike/ on PI231): clean arm64 install, dep check fail-closed behaviour confirmed, three PoC scripts parked - cc-mem incident memory § 4 (prior-art search — ecosystem hasn't solved multi-tenant fs/tool isolation) - ADR 0009 Amendment 1 § Caveats #3 — sandbox is cloud prerequisite - docs/plans/cloud-deployment-family.md § 5 Spike note (macOS): on dev Mac mini with rg via Homebrew, SandboxManager.isSupportedPlatform()=true and checkDependencies() returns no errors — macOS uses built-in sandbox-exec, not bwrap. /health.sandbox.available=true on macOS dev, false on PI231 until apt install. Operational follow-ups (NOT this PR): 1. sudo apt-get install -y bubblewrap socat ripgrep on PI231 (5-min window) 2. PR-B: lib/providers/anthropic.mjs spawn wrap + negative test (in-sandbox cat of OAuth token MUST fail) 3. PR-C: lib/providers/codex.mjs wrap with enableWeakerNestedSandbox:true 4. PR-D: cloud deployment plan update + unblock Tests: 797 → 805 (all pass, 0 fail). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
291 lines
10 KiB
JavaScript
291 lines
10 KiB
JavaScript
/**
|
|
* lib/sandbox/doctor.mjs — Sandbox availability preflight module (Phase 7 PR-A)
|
|
*
|
|
* Authority:
|
|
* @anthropic-ai/sandbox-runtime v0.0.52
|
|
* https://github.com/anthropic-experimental/sandbox-runtime
|
|
*
|
|
* 2026-05-28 PoC spike on PI231 (arm64 Debian Bookworm): dep install clean,
|
|
* isSupportedPlatform()=true, blocked on apt deps (bwrap + socat), three PoC
|
|
* scripts parked at /tmp/sandbox-spike/ on PI231.
|
|
*
|
|
* OLP ADR 0014 — Sandbox-Runtime Integration for Multi-Tenant Provider Spawning
|
|
* OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
|
|
* docs/plans/cloud-deployment-family.md § 5
|
|
*
|
|
* Design:
|
|
* Pure module — no state, no side effects beyond child_process.execFileSync for
|
|
* `which` probes. Does NOT call SandboxManager.initialize(). Does NOT create
|
|
* or interact with any real sandbox. Safe to call from /health on every request
|
|
* (results are memoized process-wide by the caller in server.mjs — see
|
|
* _sandboxStatusCache there).
|
|
*
|
|
* Exports:
|
|
* checkSandboxAvailability() — returns { available, missing, details }
|
|
* describeSandboxStatus() — returns { ok, message } human-readable summary
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { platform as osPlatform } from 'node:os';
|
|
|
|
// ── which probe helper ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check if a binary is in PATH by running `which <binary>`.
|
|
* Returns true if found, false if not found or if `which` is unavailable.
|
|
* Never throws.
|
|
* @param {string} binary
|
|
* @returns {boolean}
|
|
*/
|
|
function isInPath(binary) {
|
|
try {
|
|
execFileSync('which', [binary], { stdio: 'pipe', timeout: 2000 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── Platform helper ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Map Node's process.platform to the sandbox-runtime platform string.
|
|
* @returns {'linux'|'macos'|'other'}
|
|
*/
|
|
function getPlatformName() {
|
|
const p = osPlatform();
|
|
if (p === 'linux') return 'linux';
|
|
if (p === 'darwin') return 'macos';
|
|
return 'other';
|
|
}
|
|
|
|
// ── Library introspection ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Attempt to import @anthropic-ai/sandbox-runtime and call its exported
|
|
* isSupportedPlatform + checkDependencies. Returns structured findings.
|
|
* Never throws — all errors become { libError: <message> }.
|
|
*
|
|
* @returns {Promise<{
|
|
* libLoaded: boolean,
|
|
* libError: string|null,
|
|
* isSupportedPlatform: boolean,
|
|
* libDependencyErrors: string[],
|
|
* libDependencyWarnings: string[],
|
|
* }>}
|
|
*/
|
|
async function probeLibrary() {
|
|
try {
|
|
const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
|
|
|
|
let supportedPlatform = false;
|
|
try {
|
|
supportedPlatform = SandboxManager.isSupportedPlatform();
|
|
} catch (e) {
|
|
return {
|
|
libLoaded: true,
|
|
libError: `isSupportedPlatform() threw: ${e?.message ?? e}`,
|
|
isSupportedPlatform: false,
|
|
libDependencyErrors: [],
|
|
libDependencyWarnings: [],
|
|
};
|
|
}
|
|
|
|
// checkDependencies() requires initialize() to have been called first to
|
|
// set ripgrep/bwrap/socat config. Since PR-A never calls initialize(), we
|
|
// call checkDependencies() with an undefined argument — the library falls
|
|
// back to { command: 'rg' } for ripgrep and PATH lookup for bwrap/socat,
|
|
// which is exactly what we want for the doctor preflight.
|
|
let libDependencyErrors = [];
|
|
let libDependencyWarnings = [];
|
|
if (supportedPlatform) {
|
|
try {
|
|
const depCheck = SandboxManager.checkDependencies(undefined);
|
|
libDependencyErrors = depCheck?.errors ?? [];
|
|
libDependencyWarnings = depCheck?.warnings ?? [];
|
|
} catch (e) {
|
|
// checkDependencies() can throw before initialize() — not fatal
|
|
libDependencyErrors = [`checkDependencies() threw: ${e?.message ?? e}`];
|
|
}
|
|
}
|
|
|
|
return {
|
|
libLoaded: true,
|
|
libError: null,
|
|
isSupportedPlatform: supportedPlatform,
|
|
libDependencyErrors,
|
|
libDependencyWarnings,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
libLoaded: false,
|
|
libError: `@anthropic-ai/sandbox-runtime import failed: ${e?.message ?? e}`,
|
|
isSupportedPlatform: false,
|
|
libDependencyErrors: [],
|
|
libDependencyWarnings: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check sandbox availability (OS deps + library platform support).
|
|
*
|
|
* Returns:
|
|
* {
|
|
* available: boolean, // true only when all hard deps pass on a supported platform
|
|
* missing: string[], // friendly names of missing hard deps (e.g. 'bubblewrap', 'socat')
|
|
* details: {
|
|
* platform: string, // 'linux'|'macos'|'other'
|
|
* bwrap: boolean, // which bwrap → found
|
|
* socat: boolean, // which socat → found
|
|
* ripgrep: boolean, // which rg → found
|
|
* isSupportedPlatform: boolean,
|
|
* libLoaded: boolean,
|
|
* libError: string|null,
|
|
* libDependencyErrors: string[],
|
|
* libDependencyWarnings: string[],
|
|
* }
|
|
* }
|
|
*
|
|
* The `missing` array uses human-readable package names ('bubblewrap', 'socat',
|
|
* 'ripgrep') so that install hints are directly actionable.
|
|
*
|
|
* Does NOT call SandboxManager.initialize() — pure inspection only.
|
|
* Does NOT cache — the caller (server.mjs) memoizes the result.
|
|
*/
|
|
export async function checkSandboxAvailability() {
|
|
const platform = getPlatformName();
|
|
|
|
// Probe OS-level deps independently of the library (the `which` calls are
|
|
// cheap and always correct; library's checkDependencies may be less precise
|
|
// when initialize() hasn't been called).
|
|
const bwrap = isInPath('bwrap');
|
|
const socat = isInPath('socat');
|
|
const ripgrep = isInPath('rg');
|
|
|
|
// Library introspection (import + isSupportedPlatform + checkDependencies)
|
|
const lib = await probeLibrary();
|
|
|
|
// Determine what's missing for the doctor report.
|
|
// Only report OS deps as missing on Linux (where bwrap/socat/rg are required);
|
|
// macOS uses sandbox-exec which is built-in, so these are not hard requirements.
|
|
const missing = [];
|
|
if (platform === 'linux') {
|
|
if (!bwrap) missing.push('bubblewrap');
|
|
if (!socat) missing.push('socat');
|
|
if (!ripgrep) missing.push('ripgrep');
|
|
}
|
|
// If the library itself failed to load, that's also a blocker
|
|
if (!lib.libLoaded) {
|
|
missing.push('@anthropic-ai/sandbox-runtime (import failed)');
|
|
}
|
|
// Library-reported hard dep errors (may overlap with our `which` probes;
|
|
// deduplicate by treating them as additional evidence rather than re-adding)
|
|
for (const errMsg of lib.libDependencyErrors) {
|
|
// Only add if it doesn't overlap with what we already reported
|
|
const isAlreadyCovered =
|
|
(errMsg.includes('bwrap') && !bwrap) ||
|
|
(errMsg.includes('socat') && !socat) ||
|
|
(errMsg.includes('ripgrep') && !ripgrep) ||
|
|
(errMsg.includes('Unsupported platform'));
|
|
if (!isAlreadyCovered && !missing.includes(errMsg)) {
|
|
missing.push(errMsg);
|
|
}
|
|
}
|
|
|
|
const available =
|
|
lib.libLoaded &&
|
|
lib.isSupportedPlatform &&
|
|
missing.length === 0;
|
|
|
|
return {
|
|
available,
|
|
missing,
|
|
details: {
|
|
platform,
|
|
bwrap,
|
|
socat,
|
|
ripgrep,
|
|
isSupportedPlatform: lib.isSupportedPlatform,
|
|
libLoaded: lib.libLoaded,
|
|
libError: lib.libError,
|
|
libDependencyErrors: lib.libDependencyErrors,
|
|
libDependencyWarnings: lib.libDependencyWarnings,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Human-readable sandbox status summary for /health and CLI consumers.
|
|
*
|
|
* Returns:
|
|
* {
|
|
* ok: boolean, // same as checkSandboxAvailability().available
|
|
* message: string, // multi-line, includes install hint when deps are missing
|
|
* }
|
|
*
|
|
* Does NOT call SandboxManager.initialize() — pure inspection only.
|
|
*/
|
|
export async function describeSandboxStatus() {
|
|
const result = await checkSandboxAvailability();
|
|
const { available, missing, details } = result;
|
|
|
|
if (available) {
|
|
return {
|
|
ok: true,
|
|
message:
|
|
`Sandbox available on ${details.platform}` +
|
|
(details.libDependencyWarnings.length > 0
|
|
? `. Warnings: ${details.libDependencyWarnings.join('; ')}`
|
|
: '.'),
|
|
};
|
|
}
|
|
|
|
// Build a friendly explanation
|
|
const lines = [];
|
|
|
|
if (!details.libLoaded) {
|
|
lines.push(`Sandbox library not available: ${details.libError ?? 'import failed'}`);
|
|
} else if (!details.isSupportedPlatform) {
|
|
lines.push(
|
|
`Sandbox dependencies not available: platform '${details.platform}' is not supported by @anthropic-ai/sandbox-runtime v0.0.52.`,
|
|
);
|
|
} else {
|
|
// Platform is supported but OS deps are missing
|
|
const pkgNames = missing.filter(m => !m.includes('import failed'));
|
|
if (pkgNames.length > 0) {
|
|
lines.push(`Sandbox dependencies not available: ${pkgNames.map(m => `${m} not installed`).join(', ')}.`);
|
|
}
|
|
}
|
|
|
|
// Install hint (only for Linux; macOS sandbox uses sandbox-exec which is built-in)
|
|
if (details.platform === 'linux' && (missing.includes('bubblewrap') || missing.includes('socat') || missing.includes('ripgrep'))) {
|
|
const aptPkgs = [];
|
|
if (missing.includes('bubblewrap')) aptPkgs.push('bubblewrap');
|
|
if (missing.includes('socat')) aptPkgs.push('socat');
|
|
if (missing.includes('ripgrep')) aptPkgs.push('ripgrep');
|
|
lines.push(
|
|
`Install on Debian/Ubuntu/Raspbian: sudo apt-get install -y ${aptPkgs.join(' ')}`,
|
|
);
|
|
}
|
|
|
|
// macOS note (PR-A does not wire macOS sandbox-exec; PR-B will)
|
|
if (details.platform === 'macos') {
|
|
lines.push(
|
|
'macOS: sandbox-exec is built-in, but anthropic provider wrapping lands in PR-B. ' +
|
|
'macOS sandbox integration is not yet wired in this PR (PR-A). ',
|
|
);
|
|
}
|
|
|
|
if (details.libDependencyWarnings.length > 0) {
|
|
lines.push(`Warnings: ${details.libDependencyWarnings.join('; ')}`);
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
message: lines.join('\n'),
|
|
};
|
|
}
|