mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 04:42:24 +00:00
Add verified provider update preflight and record first self-host trial
This commit is contained in:
1 parent
2a8ec35dac
commit
f72daadcba
17 files changed
+855
-22
No files matched your search
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user