maestro/src/bridge/space-python-packages-api.ts
oss-sync b1292e34b2
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (edc775f2)
2026-07-06 01:04:12 +00:00

213 lines
9.0 KiB
TypeScript

import express, { Router } from 'express';
import { canManageSpace } from './visibility.js';
import { viewerOf } from './space-viewer.js';
import { logger } from '../logger.js';
import {
resolvePythonPackagesConfig,
parseAndValidateSpec,
assertNotShadowing,
installSpacePackages,
spaceKeyFor,
pipPreflight,
type ParsedSpec,
} from '../engine/python-packages.js';
import { isBwrapAvailable } from '../engine/tools/sandbox.js';
import type { SpaceApiDeps } from './space-api.js';
/**
* Per-space Python package management (admin form entry point).
*
* GET /:id/python-packages — viewer: list installed + feature state
* POST /:id/python-packages — canManageSpace: add a wheel + rebuild overlay
* DELETE /:id/python-packages/:name — canManageSpace: remove + rebuild overlay
*
* The persisted desired-state (a JSON array on spaces.python_packages) is the
* source of truth; each mutation rebuilds the whole content-addressed overlay
* and atomically repoints `current`. Installs run out-of-band in a separate,
* network-enabled bwrap with no workspace/secret access (see python-packages.ts).
*/
interface PersistedPkg {
name: string; // PEP 503 normalized
spec: string; // canonical pip argv, e.g. "requests==2.32.3"
addedAt: string;
}
interface PersistedState {
packages: PersistedPkg[];
lockHash?: string;
updatedAt?: string;
}
function readState(json: string | null): PersistedState {
if (!json) return { packages: [] };
try {
const v = JSON.parse(json);
if (v && Array.isArray(v.packages)) return v as PersistedState;
} catch {
// fall through
}
return { packages: [] };
}
// Per-space in-process mutex so two concurrent installs never race the overlay
// or the read-modify-write of the desired-state JSON.
const spaceLocks = new Map<string, Promise<unknown>>();
function withSpaceLock<T>(spaceId: string, fn: () => Promise<T>): Promise<T> {
const prev = spaceLocks.get(spaceId) ?? Promise.resolve();
const next = prev.catch(() => {}).then(fn);
spaceLocks.set(spaceId, next.catch(() => {}));
return next;
}
export function registerSpacePythonPackagesRoutes(router: Router, deps: SpaceApiDeps): void {
const { repo } = deps;
const jsonParser = express.json({ limit: '256kb' });
function cfg() {
return resolvePythonPackagesConfig(deps.getPythonPackagesConfig?.());
}
// Installs REQUIRE the isolated bwrap sandbox (fail-closed). Report pip + bwrap
// readiness so the UI can explain why the form is unavailable.
async function computePreflight(enabled: boolean): Promise<{ ok: boolean; reason?: string }> {
if (!enabled) return { ok: false, reason: 'feature disabled' };
if (!(await isBwrapAvailable())) {
return { ok: false, reason: 'The bwrap sandbox is unavailable on this server, so packages cannot be installed safely.' };
}
return pipPreflight();
}
// GET — viewer may read the list + feature state.
router.get('/:id/python-packages', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const c = cfg();
const state = readState(repo.getSpacePythonPackages(space.id));
const preflight = await computePreflight(c.enabled);
// NOTE: indexUrl is deliberately NOT returned. A private index can embed
// credentials (https://user:token@host/simple); this endpoint is readable by
// any space viewer, so leaking it would expose the token to non-managers.
res.json({
enabled: c.enabled,
maxPackagesPerSpace: c.maxPackagesPerSpace,
preflight,
packages: state.packages,
});
});
// POST { spec } — canManageSpace: add one package and rebuild the overlay.
router.post('/:id/python-packages', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const c = cfg();
if (!c.enabled) return res.status(400).json({ error: 'Python package management is disabled (config: python_packages.enabled)' });
let parsed: ParsedSpec;
try {
parsed = parseAndValidateSpec(String(req.body?.spec ?? ''));
assertNotShadowing(parsed.normalizedName);
} catch (e) {
return res.status(400).json({ error: (e as Error).message });
}
const pre = await computePreflight(true);
if (!pre.ok) return res.status(503).json({ error: pre.reason });
try {
const result = await withSpaceLock(space.id, async () => {
const state = readState(repo.getSpacePythonPackages(space.id));
if (state.packages.some((p) => p.name === parsed.normalizedName)) {
return { conflict: true as const };
}
if (state.packages.length + 1 > c.maxPackagesPerSpace) {
return { tooMany: true as const };
}
// Rebuild the FULL set (existing + new) into a fresh overlay.
const specs: ParsedSpec[] = [
...state.packages.map((p) => parseAndValidateSpec(p.spec)),
parsed,
];
const install = await installSpacePackages({
pkgRoot: c.dir,
spaceKey: spaceKeyFor(space.id, space.ownerId),
specs,
cfg: c,
bwrapAvailable: await isBwrapAvailable(),
});
if (!install.ok) return { installError: install.error ?? 'install failed' };
const next: PersistedState = {
packages: [
...state.packages,
{ name: parsed.normalizedName, spec: parsed.spec, addedAt: new Date().toISOString() },
],
lockHash: install.lockHash,
updatedAt: new Date().toISOString(),
};
repo.setSpacePythonPackages(space.id, JSON.stringify(next));
return { ok: true as const, state: next };
});
if ('conflict' in result) return res.status(409).json({ error: `already installed: ${parsed.normalizedName}` });
if ('tooMany' in result) return res.status(400).json({ error: `too many packages (max ${c.maxPackagesPerSpace})` });
if ('installError' in result) return res.status(422).json({ error: result.installError });
logger.info(`[space-api] python-package added space=${space.id} pkg=${parsed.spec}`);
res.json({ packages: result.state.packages, lockHash: result.state.lockHash });
} catch (err) {
logger.error(`[space-api] python-package POST failed space=${space.id} err=${(err as Error).message}`);
res.status(500).json({ error: 'internal error' });
}
});
// DELETE /:name — canManageSpace: remove one package and rebuild the overlay.
router.delete('/:id/python-packages/:name', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const c = cfg();
if (!c.enabled) return res.status(400).json({ error: 'Python package management is disabled (config: python_packages.enabled)' });
const name = String(req.params.name || '').toLowerCase();
try {
const result = await withSpaceLock(space.id, async () => {
const state = readState(repo.getSpacePythonPackages(space.id));
const remaining = state.packages.filter((p) => p.name !== name);
if (remaining.length === state.packages.length) return { notFound: true as const };
const specs: ParsedSpec[] = remaining.map((p) => parseAndValidateSpec(p.spec));
const install = await installSpacePackages({
pkgRoot: c.dir,
spaceKey: spaceKeyFor(space.id, space.ownerId),
specs,
cfg: c,
bwrapAvailable: await isBwrapAvailable(),
});
if (!install.ok) return { installError: install.error ?? 'rebuild failed' };
const next: PersistedState = {
packages: remaining,
lockHash: install.lockHash,
updatedAt: new Date().toISOString(),
};
repo.setSpacePythonPackages(space.id, JSON.stringify(next));
return { ok: true as const, state: next };
});
if ('notFound' in result) return res.status(404).json({ error: `not installed: ${name}` });
if ('installError' in result) return res.status(422).json({ error: result.installError });
logger.info(`[space-api] python-package removed space=${space.id} pkg=${name}`);
res.json({ packages: result.state.packages, lockHash: result.state.lockHash });
} catch (err) {
logger.error(`[space-api] python-package DELETE failed space=${space.id} err=${(err as Error).message}`);
res.status(500).json({ error: 'internal error' });
}
});
}