@juangcarmona/lighthouse-sdlc 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/capabilities.json +26 -0
- package/host-contract.md +2 -0
- package/package.json +3 -2
- package/skills/refine/SKILL.md +1 -1
- package/src/capabilities.mjs +1 -0
- package/src/github-profile.mjs +305 -0
- package/src/host-contract.mjs +1 -1
- package/src/install-workflows.mjs +24 -0
- package/src/issue-actions.mjs +124 -0
- package/src/opencode-execute.mjs +90 -0
- package/workflows/lighthouse-sdlc-refine.lock.yml +217 -1274
- package/workflows/lighthouse-sdlc-refine.md +140 -29
- package/workflows/lighthouse-sdlc-router.yml +33 -160
- package/workflows/shared/lighthouse-opencode.md +20 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// gh-aw engine adapter: one foreground container, no service or orchestration state.
|
|
2
|
+
import { readFile, writeFile, mkdir, mkdtemp, readdir, copyFile, chmod, rm, lstat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path';
|
|
4
|
+
import { openSync, closeSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export function redactLog(text) {
|
|
9
|
+
return text.replace(/Bearer\s+[^\s"']+/gi, 'Bearer [REDACTED]')
|
|
10
|
+
.replace(/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{15,}/g, '[REDACTED]')
|
|
11
|
+
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED]');
|
|
12
|
+
}
|
|
13
|
+
export async function snapshotSource(root, destination) {
|
|
14
|
+
const files = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
|
15
|
+
if (files.length > 5000) throw Error('Source snapshot exceeds file bound');
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
if (file.split('/').some(part => part === '..' || part === '.git') || file.startsWith('/')) throw Error('Unsafe source path');
|
|
19
|
+
const source = join(root, file), stat = await lstat(source);
|
|
20
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
21
|
+
bytes += stat.size;
|
|
22
|
+
if (bytes > 32 * 1024 * 1024) throw Error('Source snapshot exceeds byte bound');
|
|
23
|
+
const target = join(destination, file);
|
|
24
|
+
await mkdir(dirname(target), { recursive: true }); await copyFile(source, target); await chmod(target, 0o444);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function acquireEngineSlot(path, seconds = 240) {
|
|
28
|
+
if (!isAbsolute(path ?? '')) throw Error('Operator engine lock must be an absolute path');
|
|
29
|
+
const fd = openSync(path, 'a', 0o600);
|
|
30
|
+
try {
|
|
31
|
+
// Linux flock belongs to the shared open file description, held until closeSync.
|
|
32
|
+
execFileSync('flock', ['--exclusive', '--wait', String(seconds), '3'], { stdio: ['ignore', 'inherit', 'inherit', fd] });
|
|
33
|
+
return fd;
|
|
34
|
+
} catch { closeSync(fd); throw Error('Host engine capacity unavailable'); }
|
|
35
|
+
}
|
|
36
|
+
async function main() {
|
|
37
|
+
const env = process.env;
|
|
38
|
+
const config = JSON.parse(await readFile(env.LIGHTHOUSE_ENGINE_CONFIG, 'utf8'));
|
|
39
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(config.image) || !/^[\w-]+$/.test(config.authVolume) || !Number.isInteger(config.uid)) throw Error('Invalid operator engine configuration');
|
|
40
|
+
const runDir = join(env.RUNNER_TEMP, `lighthouse-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`);
|
|
41
|
+
const request = JSON.parse(await readFile(join(runDir, 'request.json'), 'utf8'));
|
|
42
|
+
if (!/^[a-f0-9]{64}$/.test(request.runId)) throw Error('Invalid execution identity');
|
|
43
|
+
const dir = await mkdtemp(join(env.RUNNER_TEMP, 'lighthouse-engine-'));
|
|
44
|
+
await chmod(dir, 0o755);
|
|
45
|
+
const source = join(dir, 'source'), output = join(dir, 'output');
|
|
46
|
+
await mkdir(source); await mkdir(output); await chmod(output, 0o777);
|
|
47
|
+
const name = `lighthouse-engine-${request.runId}`;
|
|
48
|
+
const docker = args => execFileSync('docker', args, { stdio: 'ignore', timeout: 30000 });
|
|
49
|
+
let child, timer, slot, cancelled = false;
|
|
50
|
+
const cancel = () => { cancelled = true; child?.kill('SIGTERM'); try { docker(['rm', '-f', name]); } catch {} };
|
|
51
|
+
process.once('SIGTERM', cancel); process.once('SIGINT', cancel);
|
|
52
|
+
try {
|
|
53
|
+
console.log('Waiting for the operator host engine slot');
|
|
54
|
+
slot = acquireEngineSlot(config.lock);
|
|
55
|
+
console.log('Engine slot acquired; starting isolated OpenCode');
|
|
56
|
+
await snapshotSource(env.GITHUB_WORKSPACE, source);
|
|
57
|
+
const context = join(dir, 'context.json');
|
|
58
|
+
await copyFile(join(runDir, 'context.json'), context); await chmod(context, 0o444);
|
|
59
|
+
const prompt = join(dir, 'prompt.txt');
|
|
60
|
+
await writeFile(prompt, await readFile(env.GH_AW_PROMPT), { mode: 0o444 });
|
|
61
|
+
const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
62
|
+
const pkg = JSON.parse(await readFile(join(harness, 'package.json'), 'utf8'));
|
|
63
|
+
await writeFile(join(runDir, 'runtime.json'), JSON.stringify({ package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, model: config.model, engine: 'OpenCode 2.1.0', isolation: 'Docker; no AWF egress filtering', skills: await readdir(join(harness, 'skills')), sourceRevision: request.sourceRevision }));
|
|
64
|
+
const args = ['run', '--rm', '--name', name, '--user', `${config.uid}:${config.uid}`, '--cpus', '2', '--memory', '2g', '--pids-limit', '128', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--read-only',
|
|
65
|
+
'--tmpfs', '/tmp:rw,size=256m,mode=1777', '--tmpfs', `/home/engine/.cache:rw,size=256m,uid=${config.uid},gid=${config.uid}`, '--tmpfs', `/home/engine/.config:rw,size=16m,uid=${config.uid},gid=${config.uid}`, '--tmpfs', `/home/engine/.local/state:rw,size=64m,uid=${config.uid},gid=${config.uid}`,
|
|
66
|
+
'--mount', `type=bind,src=${source},dst=/workspace,readonly`, '--mount', `type=bind,src=${output},dst=/output`, '--mount', `type=bind,src=${context},dst=/input/context.json,readonly`, '--mount', `type=bind,src=${prompt},dst=/input/prompt.txt,readonly`, '--mount', `type=bind,src=${harness},dst=/harness,readonly`,
|
|
67
|
+
'--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, '--env', `ENGINE_SESSION=ses_${request.runId}`, '--env', `ENGINE_MODEL=${config.model}`, config.image];
|
|
68
|
+
await new Promise((ok, no) => {
|
|
69
|
+
child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
70
|
+
// Keep line boundaries across chunks so credential shapes cannot bypass redaction.
|
|
71
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
72
|
+
let pending = ''; stream.setEncoding('utf8');
|
|
73
|
+
stream.on('data', chunk => { pending += chunk; let end; while ((end = pending.indexOf('\n')) >= 0) { process.stdout.write(redactLog(pending.slice(0, end + 1))); pending = pending.slice(end + 1); } if (pending.length > 1024 * 1024) { no(Error('Engine log line exceeds bound')); cancel(); } });
|
|
74
|
+
stream.on('end', () => { if (pending) process.stdout.write(redactLog(pending) + '\n'); });
|
|
75
|
+
}
|
|
76
|
+
child.on('error', no); child.on('exit', code => code === 0 && !cancelled ? ok() : no(Error('OpenCode failed or was cancelled')));
|
|
77
|
+
timer = setTimeout(cancel, 600000);
|
|
78
|
+
});
|
|
79
|
+
const result = join(output, 'envelope.json'), stat = await lstat(result);
|
|
80
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128000) throw Error('Invalid engine result file');
|
|
81
|
+
await writeFile(join(runDir, 'envelope.json'), JSON.stringify(JSON.parse(await readFile(result, 'utf8'))));
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer); process.removeListener('SIGTERM', cancel); process.removeListener('SIGINT', cancel);
|
|
84
|
+
try { docker(['rm', '-f', name]); } catch {}
|
|
85
|
+
try { docker(['run', '--rm', '--network', 'none', '--user', `${config.uid}:${config.uid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, config.image, 'cleanup-session', `ses_${request.runId}`]); } catch { console.error('Session cleanup failed; operator inspection required'); }
|
|
86
|
+
if (slot !== undefined) closeSync(slot);
|
|
87
|
+
await rm(dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => { console.error(redactLog(error.message)); process.exitCode = 1; });
|