diff --git a/packages/tasks-capability/opsle-capability.json b/packages/tasks-capability/opsle-capability.json index 515c6cf..40e1807 100644 --- a/packages/tasks-capability/opsle-capability.json +++ b/packages/tasks-capability/opsle-capability.json @@ -2,7 +2,7 @@ "schema": "opsle.capability-manifest.v1", "id": "opsle.affected-verification", "name": "Affected Verification", - "version": "0.2.0", + "version": "0.2.1", "adapter": "adapter.js", "default_enabled": false, "configuration_schema": "opsle.affected-verification.tasks-config.v2", diff --git a/packages/tasks-capability/package.json b/packages/tasks-capability/package.json index cdde4de..c50c705 100644 --- a/packages/tasks-capability/package.json +++ b/packages/tasks-capability/package.json @@ -1,6 +1,6 @@ { "name": "@opsle/affected-verification-tasks-capability", - "version": "0.2.0", + "version": "0.2.1", "description": "Independent Affected Verification authority for the generic Opsle Tasks capability contract", "type": "module", "license": "Apache-2.0", diff --git a/packages/tasks-capability/schemas/evidence-v1.schema.json b/packages/tasks-capability/schemas/evidence-v1.schema.json index 9c8b38d..fb4a87b 100644 --- a/packages/tasks-capability/schemas/evidence-v1.schema.json +++ b/packages/tasks-capability/schemas/evidence-v1.schema.json @@ -56,7 +56,8 @@ "kind": { "enum": [ "ssh", - "test-local" + "test-local", + "local" ] }, "path": { diff --git a/packages/tasks-capability/src/adapter.js b/packages/tasks-capability/src/adapter.js index 844428f..89cf7ac 100644 --- a/packages/tasks-capability/src/adapter.js +++ b/packages/tasks-capability/src/adapter.js @@ -26,7 +26,7 @@ export function createAdapter({ manifest, configuration, services }, packageIden // retained worktree are lifecycle state: Tasks may advance them after a // verified merge or restore them during same-attempt continuation. // Planning/finalization bind their exact source tree independently. - for (const key of ['id', 'repo_id', 'repo_name', 'repo_path', 'ssh_host', 'ssh_user', 'sshHost', 'sshUser']) { + for (const key of ['id', 'repo_id', 'repo_name', 'repo_path', 'ssh_host', 'ssh_user', 'sshHost', 'sshUser', 'execution_transport', 'executionTransport']) { if (payload.task[key] !== binding[key]) { throw new Error(`Verification request has a different task binding: ${key}`); } diff --git a/packages/tasks-capability/src/execution.js b/packages/tasks-capability/src/execution.js index f3b7416..299c0dc 100644 --- a/packages/tasks-capability/src/execution.js +++ b/packages/tasks-capability/src/execution.js @@ -6,16 +6,29 @@ import { writeFileSync } from 'node:fs'; export const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`; const argv = args => args.map(quote).join(' '); export function executionTarget(project = {}) { + const transport = project.execution_transport ?? project.executionTransport ?? ''; const host = project.ssh_host ?? project.sshHost ?? ''; const user = project.ssh_user ?? project.sshUser ?? ''; const path = project.repo_path ?? project.path; if (!host && !user && process.env.NODE_ENV === 'test') return { kind: 'test-local', path }; + if (transport === 'LOCAL') { + if (!/^[a-z_][a-z0-9_-]{0,63}$/.test(user)) throw new Error('Project local execution user is required or invalid.'); + if (typeof path !== 'string' || !path.startsWith('/') || /[\x00-\x1f]/.test(path) || path === '/') throw new Error('Project repository path must be an absolute path inside the Opsle instance.'); + return { kind: process.env.NODE_ENV === 'test' ? 'test-local' : 'local', user, path }; + } + if (transport && transport !== 'SSH') throw new Error('Project execution transport must be LOCAL or SSH.'); if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(host)) throw new Error('Project execution host is required: configure its private Incus hostname. Local execution is disabled.'); if (!/^[a-z_][a-z0-9_-]{0,63}$/.test(user)) throw new Error('Project SSH user is required or invalid.'); if (typeof path !== 'string' || !path.startsWith('/') || /[\x00-\x1f]/.test(path) || path === '/') throw new Error('Project repository path must be an absolute path inside its container.'); return { kind: 'ssh', host, user, path }; } +export function localArguments(config, target, script, seconds = 30) { + if (target.kind !== 'local') throw new Error('Local execution requires an explicit local project target.'); + return ['-n', '-H', '-u', target.user, '--', config.localExecBin, + String(Math.max(1, seconds)), script]; +} + export function sshArguments(config, target, script, seconds = 30) { if (target.kind !== 'ssh') throw new Error('SSH requires an explicit project execution target.'); if (!config.sshKeyPath) throw new Error('Tasks SSH key is not configured (OPSLE_SSH_KEY_PATH).'); @@ -32,7 +45,7 @@ export function sshArguments(config, target, script, seconds = 30) { export function executionError(result, target, label = 'Remote command') { const detail = String(result.stderr || result.error?.message || '').slice(-2000); - const where = target.kind === 'ssh' ? `${target.user}@${target.host}` : 'test-local'; + const where = target.kind === 'ssh' ? `${target.user}@${target.host}` : target.kind === 'local' ? `${target.user}@local` : 'test-local'; let reason; if (result.error?.code === 'ETIMEDOUT' || [124, 137].includes(result.status ?? result.code)) reason = 'command timeout'; else if ((result.status ?? result.code) === 255 && /Permission denied|Authentication failed/i.test(detail)) reason = 'SSH authentication failure'; @@ -51,6 +64,7 @@ export function executionError(result, target, label = 'Remote command') { function invocation(config, target, script, seconds, cwd) { if (target.kind === 'test-local' && process.env.NODE_ENV === 'test') return { command: '/bin/sh', args: ['-c', script], cwd }; + if (target.kind === 'local') return { command: config.sudoBin || 'sudo', args: localArguments(config, target, script, seconds) }; return { command: config.sshBin || 'ssh', args: sshArguments(config, target, script, seconds) }; } diff --git a/tests/tasks-capability.test.js b/tests/tasks-capability.test.js index 88430ae..98d2bdf 100644 --- a/tests/tasks-capability.test.js +++ b/tests/tasks-capability.test.js @@ -39,7 +39,7 @@ before(() => { // Build a second, synthetic compatible patch tarball for the restart contract. for (const name of ['package.json', 'opsle-capability.json']) { const path = resolve(packagePath, name); - const value = JSON.parse(readFileSync(path)); value.version = '0.2.1'; + const value = JSON.parse(readFileSync(path)); value.version = '0.2.2'; writeFileSync(path, JSON.stringify(value)); } upgradeTarball = resolve(temp, JSON.parse(run('npm', ['pack', '--json', '--pack-destination', temp], packagePath))[0].filename); @@ -80,7 +80,7 @@ async function direct(directory, f) { const { createCapability } = await import(pathToFileURL(resolve(directory, 'adapter.js'))); const manifest = JSON.parse(readFileSync(resolve(directory, 'opsle-capability.json'))); return createCapability({ manifest, configuration: {}, services: { task: f.task, attemptId: 1, - executionId: 'exec-1', executionConfig: { logsDir: f.logsDir } } }); + executionId: 'exec-1', executionConfig: { logsDir: f.logsDir, ...f.executionConfig } } }); } const request = f => ({ schema, task: f.task, attemptId: 1, executionId: 'exec-1', generation: 1 }); const shadowRequest = (f, plan, overrides = {}) => ({ @@ -343,7 +343,7 @@ const runtime = await createCapabilityRuntime(context); const value = await runtime.authority('verification.capture', {schema:'opsle.execution.change-capture-request.v1',task:context.task}); process.stdout.write(JSON.stringify({version:runtime.status[0].version,value}));`); const restarted = JSON.parse(run(process.execPath, [restart, JSON.stringify({ config: { ...config, capabilityRoots: [upgraded] }, task: f.task, attemptId: 1, executionId: 'exec-1', selection: emptySelection })], temp)); - assert.equal(restarted.version, '0.2.1'); + assert.equal(restarted.version, '0.2.2'); assert.equal(restarted.value.identity, capture.identity); assert.deepEqual(readFileSync(analysis.evidencePath), history); assert.equal(git(tasksRoot, ['diff', 'HEAD', '--', 'src']), before); @@ -352,3 +352,33 @@ process.stdout.write(JSON.stringify({version:runtime.status[0].version,value})); artifact_version: packed.version, artifact_sha256: hash(readFileSync(tarball)), upgrade_artifact_sha256: hash(readFileSync(upgradeTarball)), central_source_unchanged: true })); }); + +test('explicit LOCAL production planning uses the bounded deploy wrapper and binds its transport', async t => { + const directory = install('local-production'); + const f = fixture(t, 'local-production-project'); + f.task.execution_transport = 'LOCAL'; + f.task.ssh_user = 'deploy'; + const calls = resolve(temp, 'local-wrapper-calls'); + const sudo = resolve(temp, 'fixture-sudo'); + writeFileSync(sudo, '#!/bin/sh\n[ "$1 $2 $3 $4 $5 $6" = "-n -H -u deploy -- /fixture/project-exec" ] || exit 90\nprintf "%s\\n" "$7" >> ' + JSON.stringify(calls) + '\nexec /bin/sh -c "$8"\n', { mode: 0o700 }); + f.executionConfig = { sudoBin: sudo, localExecBin: '/fixture/project-exec' }; + const adapter = await direct(directory, f); + const previous = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + const plan = adapter.invoke('verification.plan', request(f)).value; + assert.equal(plan.error, null); + assert.ok(plan.decision); + assert.ok(readFileSync(calls, 'utf8').trim().split('\n').every(value => Number(value) > 0)); + assert.throws(() => adapter.invoke('verification.plan', { + ...request(f), task: { ...f.task, execution_transport: 'SSH' }, + }), /different task binding: execution_transport/); + const { executionTarget } = await import(pathToFileURL(resolve(directory, 'runtime/execution.js'))); + assert.throws(() => executionTarget({ ...f.task, execution_transport: '', ssh_host: '' }), /execution host/); + assert.throws(() => executionTarget({ ...f.task, ssh_user: 'deploy;id' }), /local execution user/); + assert.throws(() => executionTarget({ ...f.task, repo_path: '/' }), /absolute path/); + } finally { + if (previous === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previous; + } +});