Add verified provider update preflight and record first self-host trial

This commit is contained in:
thePR0M3TH3AN committed 2026-09-30 14:48:41 -04:00
1 parent 2a8ec35dac
commit f72daadcba
17 files changed
+855 -22

No files matched your search

+4 -3
View File
@@ -4,13 +4,14 @@ import { createCodexAdapter } from './codex.mjs';
import { createPiAdapter } from './pi.mjs';
import { validateRuntimeAdapter } from './runtime.mjs';
import { inspectRuntimePluginTrust, loadTrustedRuntimeAdapters } from './plugins.mjs';
import { managedProvider } from './updates.mjs';
export class RuntimeAdapterRegistry {
constructor({ env = process.env, adapters = [], loadPlugins = false, pluginNames } = {}) {
this.adapters = new Map([
['claude', createClaudeAdapter({ env })],
['codex', createCodexAdapter({ env })],
['pi', createPiAdapter({ env })],
['claude', createClaudeAdapter({ env, executable: managedProvider('claude', { env })?.executable ?? 'claude' })],
['codex', createCodexAdapter({ env, executable: managedProvider('codex', { env })?.executable ?? 'codex' })],
['pi', createPiAdapter({ env, executable: managedProvider('pi', { env })?.executable ?? 'pi' })],
]);
this.pluginErrors = [];
this.trustedPlugins = [];
+147
View File
@@ -0,0 +1,147 @@
import { spawnSync } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { accessSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { torchDataHome } from '../kernel/paths.mjs';
import { TorchError } from '../kernel/errors.mjs';
// Fixed official distribution names. Project input cannot supply npm packages,
// registries, scripts, prefixes or alternate update commands.
export const PROVIDER_PACKAGES = Object.freeze({
codex: '@openai/codex', claude: '@anthropic-ai/claude-code',
pi: '@earendil-works/pi-coding-agent',
});
const REGISTRY = 'https://registry.npmjs.org';
const VERSION = /^\d+\.\d+\.\d+$/;
function fail(message, details) {
return new TorchError(message, { code: 'RUNTIME_UPDATE_FAILED', details });
}
function safePath(path) {
let current = resolve(path);
for (;;) {
if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
throw fail('Provider store must not traverse symlink directories', { path: current });
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
}
export function providerStore(env = process.env) {
return join(torchDataHome(env), 'provider-runtimes');
}
function pointer(root, name) { return join(root, `${name}.json`); }
function hash(path) { return createHash('sha256').update(readFileSync(path)).digest('hex'); }
export function managedProvider(name, { env = process.env } = {}) {
if (!Object.hasOwn(PROVIDER_PACKAGES, name)) return null;
const root = providerStore(env);
safePath(root);
const path = pointer(root, name);
if (!existsSync(path)) return null;
safePath(path);
let record;
try { record = JSON.parse(readFileSync(path, 'utf8')); } catch {
throw fail('Provider activation record is unreadable', { name });
}
const prefix = resolve(record.prefix ?? '');
if (record.name !== name || record.package !== PROVIDER_PACKAGES[name]
|| !VERSION.test(record.version ?? '') || dirname(prefix) !== resolve(root)
|| !prefix.startsWith(join(root, `${name}-${record.version}-`))) {
throw fail('Provider activation record is invalid', { name });
}
safePath(prefix);
const executable = join(prefix, 'node_modules', '.bin', name);
try {
const target = realpathSync(executable);
if (relative(prefix, target).startsWith(`..${sep}`) || !target.startsWith(`${prefix}${sep}`)) throw Error('outside prefix');
accessSync(executable, constants.X_OK);
if (hash(target) !== record.sha256) throw Error('changed executable');
} catch {
throw fail('Managed provider executable is missing or changed', { name, executable });
}
return { ...record, executable };
}
export function planProviderUpdates(names, { env = process.env } = {}) {
return {
action: 'provider-update', scope: 'torch-managed-only', registry: REGISTRY,
providers: [...new Set(names)].map((name) => {
if (!Object.hasOwn(PROVIDER_PACKAGES, name)) {
throw fail('Automatic updates support only built-in providers', { name });
}
return { name, package: PROVIDER_PACKAGES[name], active: managedProvider(name, { env }) };
}),
root: providerStore(env), networkRequired: true, executesPackageInstallScripts: true,
modifiesGlobalInstallations: false, mutationPerformed: false,
};
}
export function updateProviders(names, { env = process.env, runner = spawnSync,
authorized = false, force = false, maxAgeHours = 24, now = () => new Date() } = {}) {
if (!authorized) throw new TorchError('Provider downloads require owner approval', { code: 'APPROVAL_REQUIRED' });
if (!Number.isInteger(maxAgeHours) || maxAgeHours < 1 || maxAgeHours > 168) {
throw fail('Provider freshness must be 1–168 hours');
}
const plan = planProviderUpdates(names, { env });
safePath(plan.root);
mkdirSync(plan.root, { recursive: true, mode: 0o700 });
const lock = join(plan.root, 'update.lock');
try { mkdirSync(lock); } catch {
throw new TorchError('Provider update already running; inspect update.lock before recovery', { code: 'RUNTIME_UPDATE_BUSY' });
}
const run = (command, args, cwd, timeout) => {
const result = runner(command, args, { cwd, env, encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'], timeout, maxBuffer: 8 * 1024 * 1024 });
if (result.status !== 0 || result.error || result.signal) {
throw fail('Provider update command failed; previous activation retained', {
command, args, status: result.status, signal: result.signal,
message: result.error?.message, stderr: String(result.stderr ?? '').slice(-2000),
});
}
return String(result.stdout ?? '').trim();
};
const results = [];
try {
for (const provider of plan.providers) {
const { name, package: pkg, active } = provider;
const age = active ? now().getTime() - Date.parse(active.checkedAt) : NaN;
if (!force && Number.isFinite(age) && age >= 0 && age < maxAgeHours * 3600000) {
results.push({ ...active, outcome: 'fresh' }); continue;
}
const version = JSON.parse(run('npm', ['view', `${pkg}@latest`, 'version', '--json', '--registry', REGISTRY], plan.root, 30000));
if (typeof version !== 'string' || !VERSION.test(version)) throw fail('Registry returned an invalid stable version', { name });
let record = active;
if (!active || active.version !== version) {
const prefix = mkdtempSync(join(plan.root, `${name}-${version}-`));
const args = ['install', '--prefix', prefix, '--no-audit', '--no-fund', '--save-exact', '--registry', REGISTRY];
if (name === 'pi') args.push('--ignore-scripts');
args.push(`${pkg}@${version}`);
run('npm', args, prefix, 180000);
const executable = join(prefix, 'node_modules', '.bin', name);
const target = realpathSync(executable);
if (!target.startsWith(`${prefix}${sep}`)) throw fail('Provider binary escaped its owned prefix', { name });
const actual = run(executable, ['--version'], prefix, 20000);
if (!actual.match(/\b\d+\.\d+\.\d+\b/) || actual.match(/\b\d+\.\d+\.\d+\b/)[0] !== version) {
throw fail('Installed provider version does not match the resolved release', { name, expected: version, actual });
}
// Smoke-check parser startup before activation. Real model/account and
// adapter-specific compatibility still require their own live gates.
run(executable, ['--help'], prefix, 20000);
record = { name, package: pkg, version, prefix, sha256: hash(target), previous: active?.prefix ?? null };
}
record = { ...record, checkedAt: now().toISOString() };
const temporary = join(plan.root, `${name}-${randomUUID()}.tmp`);
writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
renameSync(temporary, pointer(plan.root, name));
results.push({ ...managedProvider(name, { env }), outcome: active?.version === version ? 'current' : 'updated' });
}
return { ...plan, results, mutationPerformed: true };
} finally {
rmdirSync(lock);
}
}
+67 -5
View File
@@ -3,6 +3,7 @@ import { BacklogService } from './backlog/service.mjs';
import { ArtifactService } from './artifacts/service.mjs';
import { createClaudeAdapter } from './adapters/claude.mjs';
import { createRuntimeAdapterRegistry } from './adapters/registry.mjs';
import { planProviderUpdates, updateProviders } from './adapters/updates.mjs';
import { profileCapabilityIssues, resolveLaunchPolicy } from './adapters/runtime.mjs';
import {
inspectRuntimePluginTrust, planRuntimePluginRevoke, planRuntimePluginTrust,
@@ -69,7 +70,7 @@ Usage:
torch architect validate --brief <path> --response <path> [--json]
torch architect run --brief <path> --provider <claude|codex> --output <path> [--model <model>] [--max-budget-usd <amount>] --yes [--json]
torch domains [--repo <path>] [--spec <path>] [--output <path>] [--json]
torch install --proposal <path> [--runtime <adapter,adapter>] [--default-runtime <adapter>] [--dry-run] [--yes] [--json]
torch install --proposal <path> [--runtime <adapter,adapter>] [--default-runtime <adapter>] [--update-runtimes] [--dry-run] [--yes] [--json]
torch worktrees [--parent <path>] [--dry-run] [--yes] [--json]
torch up [--fresh] [--only <id,id>] [--dry-run] [--yes] [--json]
torch down [--dry-run] [--yes] [--json]
@@ -91,6 +92,8 @@ Usage:
torch profile set --area <id> [--runtime <adapter>] [--model <model>|--inherit-model] [--reasoning <level|none>] [--launch-policy <field=value> ...] [--reset|--reset-launch-policy] [--dry-run|--yes] [--json]
torch profile defaults --runtime <adapter> [--model <model>] [--reasoning <level|none>] [--launch-policy <field=value> ...] [--reset|--reset-launch-policy] --yes [--json]
torch runtimes list [--json]
torch runtimes update --runtime <codex,claude,pi> [--force] [--dry-run|--yes] [--json]
torch runtimes update-policy --mode <auto|off> [--max-age-hours <1-168>] --yes [--json]
torch runtimes trust --name <id> --module </absolute/path/adapter.cjs> [--yes --sha256 <reviewed-hash>] [--json]
torch runtimes revoke --name <id> [--yes --sha256 <reviewed-hash>] [--json]
torch complete --area <id> --summary <text> [--task <id>] [--evidence <text>] [--commit <sha>] [--json]
@@ -415,6 +418,7 @@ export const CANDIDATE_ACCEPTANCE_EVIDENCE = Object.freeze({
'branches-worktrees': ['SCN-worktree-bootstrap'],
'runtime-identities': ['SCN-mixed-runtime'],
'runtime-profiles': ['SCN-runtime-profiles', 'SCN-runtime-cost-ceiling', 'SCN-runtime-profile-cli'],
'provider-updates': ['SCN-provider-updates', 'SCN-provider-updates-recovery', 'SCN-provider-update-safety', 'SCN-provider-update-cli'],
'console-runtime-profile': ['SCN-console-runtime-profile'],
'console-schedule-launcher': ['SCN-console-schedule-launcher'],
'runtime-adapters': [
@@ -552,6 +556,38 @@ export async function runCli(argv = process.argv.slice(2), {
}
if (command === 'runtimes') {
const operation = argv[1] ?? 'list';
if (operation === 'update') {
const names = commaList(optionValue(argv, '--runtime'));
if (!names.length) throw new TorchError('Select --runtime codex,claude,pi', { code: 'RUNTIME_UPDATE_INVALID' });
const plan = planProviderUpdates(names, { env });
if (argv.includes('--dry-run') || !argv.includes('--yes')) {
print({ ...plan, requiresApproval: true }, { json }); return 0;
}
print(updateProviders(names, { env, runner: spawn, authorized: true, force: argv.includes('--force') }), { json });
return 0;
}
if (operation === 'update-policy') {
const repository = inspectRepository(optionValue(argv, '--repo') ?? cwd);
const config = loadProjectConfig(repository.root);
const mode = optionValue(argv, '--mode');
const maxAge = Number(optionValue(argv, '--max-age-hours') ?? 24);
const policy = { mode, max_age_hours: maxAge };
const names = optionValue(argv, '--runtime') === undefined
? Object.keys(config.runtimes).filter(name => name !== 'default')
: commaList(optionValue(argv, '--runtime'));
planProviderUpdates(names, { env });
for (const name of names) {
if (!Object.hasOwn(config.runtimes, name)) throw new TorchError('Runtime is not configured', { code: 'RUNTIME_UPDATE_INVALID', details: { name } });
config.runtimes[name].updatePolicy = policy;
}
validateProjectConfig(config);
if (argv.includes('--dry-run') || !argv.includes('--yes')) {
print({ policy, runtimes: names, requiresApproval: true, mutationPerformed: false }, { json }); return 0;
}
print({ path: persistProfileConfig(repository.root, config), policy, runtimes: names,
sessionsStarted: false, mutationPerformed: true }, { json });
return 0;
}
if (operation === 'list') {
const builtIns = createRuntimeAdapterRegistry({ env, loadPlugins: false }).names();
let trust;
@@ -967,12 +1003,15 @@ export async function runCli(argv = process.argv.slice(2), {
const runtimes = runtimeOption === undefined ? undefined : commaList(runtimeOption);
if (argv.includes('--dry-run')) {
const proposal = loadProposal(cwd, proposalPath);
print({ ...planInstall({
const plan = planInstall({
repository, proposal, env, runtimes, defaultRuntime,
runtimeRegistry: createRuntimeAdapterRegistry({
env, loadPlugins: true, pluginNames: [...(runtimes ?? proposalRuntimeNames(proposal)), ...[defaultRuntime].filter(Boolean)],
}),
}), proposal: resolve(cwd, proposalPath) }, { json });
});
print({ ...plan, proposal: resolve(cwd, proposalPath),
...(argv.includes('--update-runtimes') ? { providerUpdates: planProviderUpdates(plan.runtimes, { env }) } : {}),
}, { json });
return 0;
}
if (!argv.includes('--yes')) {
@@ -981,12 +1020,25 @@ export async function runCli(argv = process.argv.slice(2), {
});
}
const proposal = loadProposal(cwd, proposalPath);
print(installProject({
if (argv.includes('--update-runtimes')) {
const installPlan = planInstall({ repository, proposal, env, runtimes, defaultRuntime });
if (!installPlan.canProceed) throw new TorchError('Install plan has blockers', { code: 'INSTALL_BLOCKED', details: installPlan.blockers });
updateProviders(installPlan.runtimes, { env, runner: spawn, authorized: true });
}
const installed = installProject({
repository, proposal, env, runtimes, defaultRuntime,
runtimeRegistry: createRuntimeAdapterRegistry({
env, loadPlugins: true, pluginNames: [...(runtimes ?? proposalRuntimeNames(proposal)), ...[defaultRuntime].filter(Boolean)],
}),
}), { json });
});
if (argv.includes('--update-runtimes')) {
const config = loadProjectConfig(repository.root);
for (const name of Object.keys(config.runtimes).filter(name => name !== 'default')) {
config.runtimes[name].updatePolicy = { mode: 'auto', max_age_hours: 24 };
}
persistProfileConfig(repository.root, config);
}
print(installed, { json });
return 0;
}
if (command === 'doctor') {
@@ -1010,6 +1062,16 @@ export async function runCli(argv = process.argv.slice(2), {
return 0;
}
if (command === 'up') {
const updateConfig = loadProjectConfig(repository.root);
if (argv.includes('--yes') && !argv.includes('--dry-run')) {
const selected = optionValue(argv, '--only') === undefined ? undefined : commaList(optionValue(argv, '--only'));
for (const name of configuredRuntimeNames(repository.root, selected)) {
const policy = updateConfig.runtimes[name]?.updatePolicy;
if (policy?.mode === 'auto') updateProviders([name], {
env, runner: spawn, authorized: true, maxAgeHours: policy.max_age_hours,
});
}
}
const control = openControlPlane({ repositoryRoot: repository.root, env });
try {
const only = optionValue(argv, '--only') === undefined ? undefined : commaList(optionValue(argv, '--only'));
+7
View File
@@ -320,6 +320,13 @@ export const projectConfigSchema = z.object({
}
}
for (const [runtime, profile] of Object.entries(config.runtimes)) {
if (runtime !== 'default' && profile?.updatePolicy !== undefined) {
const policy = z.object({ mode: z.enum(['auto', 'off']),
max_age_hours: z.number().int().min(1).max(168) }).strict().safeParse(profile.updatePolicy);
if (!policy.success) for (const issue of policy.error.issues) {
context.addIssue({ ...issue, path: ['runtimes', runtime, 'updatePolicy', ...issue.path] });
}
}
if (runtime === 'default' || !profile || typeof profile !== 'object' || Array.isArray(profile)) continue;
if (profile.launchPolicy !== undefined) {
const result = launchPolicy.safeParse(profile.launchPolicy);
+3 -2
View File
@@ -23,6 +23,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([
'branches-worktrees',
'runtime-identities',
'runtime-profiles',
'provider-updates',
'console-runtime-profile',
'console-schedule-launcher',
'runtime-adapters',
@@ -150,10 +151,10 @@ function gitSourceDescriptor(root) {
const excludedUntracked = execFileSync('git', ['-C', root, 'ls-files', '--others', '--exclude-standard', '-z'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).split('\0').filter(Boolean).sort();
const include = (name, entry) => name === 'node_modules'
const include = (name, entry) => name !== '.torch' && !name.startsWith('.torch/') && (name === 'node_modules'
|| name.startsWith('node_modules/')
|| tracked.has(name)
|| (entry.isDirectory() && [...tracked].some((path) => path.startsWith(`${name}/`)));
|| (entry.isDirectory() && [...tracked].some((path) => path.startsWith(`${name}/`))));
return { mode: 'git-commit', commit, include, excludedUntracked };
}