Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/tasks-capability/opsle-capability.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/tasks-capability/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/tasks-capability/schemas/evidence-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
"kind": {
"enum": [
"ssh",
"test-local"
"test-local",
"local"
]
},
"path": {
Expand Down
2 changes: 1 addition & 1 deletion packages/tasks-capability/src/adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
16 changes: 15 additions & 1 deletion packages/tasks-capability/src/execution.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).');
Expand All @@ -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';
Expand All @@ -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) };
}

Expand Down
36 changes: 33 additions & 3 deletions tests/tasks-capability.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 = {}) => ({
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
});
Loading