software-defence-factory 0.3.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.
Files changed (48) hide show
  1. package/.agents/skills/README.md +12 -0
  2. package/.agents/skills/factory-evaluate/SKILL.md +16 -0
  3. package/.agents/skills/factory-implement/SKILL.md +20 -0
  4. package/.agents/skills/factory-review/SKILL.md +16 -0
  5. package/.agents/skills/factory-security/SKILL.md +14 -0
  6. package/.agents/skills/factory-spec/SKILL.md +14 -0
  7. package/.agents/skills/factory-triage/SKILL.md +12 -0
  8. package/.github/ISSUE_TEMPLATE/factory-task.yml +35 -0
  9. package/LICENSE +21 -0
  10. package/README.md +72 -0
  11. package/THIRD_PARTY_NOTICES.md +547 -0
  12. package/bin/software-defence-factory.mjs +198 -0
  13. package/config/labels.json +37 -0
  14. package/docs/defence-integration.md +15 -0
  15. package/docs/npm.md +93 -0
  16. package/docs/ownership.md +9 -0
  17. package/docs/quickstart.md +56 -0
  18. package/factory/examples/incident.json +15 -0
  19. package/factory/examples/task.md +8 -0
  20. package/factory/executor.mjs +152 -0
  21. package/factory/image/Dockerfile +8 -0
  22. package/factory/image/mock.mjs +16 -0
  23. package/factory/incident.mjs +64 -0
  24. package/factory/lib.mjs +79 -0
  25. package/factory/paths.mjs +13 -0
  26. package/factory/pins.json +3 -0
  27. package/factory/processes.mjs +60 -0
  28. package/factory/queue.mjs +132 -0
  29. package/factory/server.mjs +101 -0
  30. package/factory/supervisor.mjs +20 -0
  31. package/factory/ui/assets/index-BWlVQO4e.js +10 -0
  32. package/factory/ui/assets/index-CpsRE53G.css +1 -0
  33. package/factory/ui/assets/manrope-cyrillic-ext-wght-normal-C8S-KRRz.woff2 +0 -0
  34. package/factory/ui/assets/manrope-cyrillic-wght-normal-Dvxsihut.woff2 +0 -0
  35. package/factory/ui/assets/manrope-greek-wght-normal-DL7QRZyv.woff2 +0 -0
  36. package/factory/ui/assets/manrope-latin-ext-wght-normal-Ch3YOpNY.woff2 +0 -0
  37. package/factory/ui/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
  38. package/factory/ui/assets/manrope-vietnamese-wght-normal-usUDDRr7.woff2 +0 -0
  39. package/factory/ui/index.html +14 -0
  40. package/factory/updates.mjs +137 -0
  41. package/kit/README.md +25 -0
  42. package/kit/delivery.md +28 -0
  43. package/kit/examples/github-checks.yml.example +32 -0
  44. package/kit/installation.md +40 -0
  45. package/kit/policy.md +41 -0
  46. package/package.json +45 -0
  47. package/scripts/export-kit.mjs +65 -0
  48. package/scripts/probe-platform.mjs +75 -0
@@ -0,0 +1,64 @@
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { configAt, digest, json, save, sleep } from './lib.mjs';
4
+
5
+ const secret = /(?:sk-(?:proj-)?[a-zA-Z0-9_-]{16,}|gh[pousr]_[a-zA-Z0-9]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:authorization|password|api[_-]?key|secret)\s*[=:]\s*["']?(?!\[REDACTED\])[a-zA-Z0-9_+\/.=-]{8,})/i;
6
+ export function validateIncident(input,scope,now=Date.now()) {
7
+ if(!scope||!['project','service','environment','owner'].every(k=>typeof scope[k]==='string'&&scope[k].trim()))throw new Error('A complete trusted installation scope is required');
8
+ if(input.contract_version!==1||input.sanitized!==true)throw new Error('Expected contract_version:1 and operator-sanitized:true evidence');
9
+ for(const k of ['project','service','environment'])if(input[k]!==scope[k])throw new Error(`Incident ${k} differs from the trusted installation scope`);
10
+ for(const k of ['source','event_id','summary'])if(typeof input[k]!=='string'||!input[k].trim()||input[k].length>1000)throw new Error(`Invalid incident ${k}`);
11
+ if(!Array.isArray(input.evidence)||input.evidence.length>20)throw new Error('Expected up to 20 bounded evidence entries');
12
+ const clean={contract_version:1,project:scope.project,service:scope.service,environment:scope.environment,owner:scope.owner,source:input.source,event_id:input.event_id,summary:input.summary,evidence:[],gaps:[]};
13
+ for(const e of input.evidence) {
14
+ if(!['logs','metrics','revision'].includes(e.kind)||typeof e.text!=='string'||e.text.length>16000||!Number.isFinite(Date.parse(e.observed_at)))throw new Error('Invalid evidence kind, text or observed_at');
15
+ const stale=now-Date.parse(e.observed_at)>3600000||Date.parse(e.observed_at)>now+60000;
16
+ clean.evidence.push({kind:e.kind,text:e.text,observed_at:e.observed_at,stale,hash:digest(e.text)});
17
+ if(stale)clean.gaps.push(`${e.kind}: outside the one-hour freshness window`);
18
+ }
19
+ for(const kind of ['logs','metrics','revision'])if(!clean.evidence.some(e=>e.kind===kind&&e.text.trim()))clean.gaps.push(`Missing ${kind}`);
20
+ const serialized=JSON.stringify(clean);
21
+ if(Buffer.byteLength(serialized)>64000)throw new Error('Evidence exceeds 64 KB');
22
+ if(secret.test(serialized))throw new Error('Possible credential detected; remove it before submitting evidence');
23
+ return clean;
24
+ }
25
+ export async function admitIncident(state,input,submit) {
26
+ const clean=validateIncident(input,configAt(state).scope);
27
+ // Stable input hash excludes time-dependent coverage judgments.
28
+ const hash=digest(JSON.stringify({...clean,gaps:[],evidence:clean.evidence.map(({stale,...e})=>e)}));
29
+ const key=digest(JSON.stringify([clean.project,clean.environment,clean.service,clean.source,clean.event_id]));
30
+ const directory=join(state,'incidents');mkdirSync(directory,{recursive:true,mode:0o700});
31
+ const path=join(directory,`${key}.json`);
32
+ if(existsSync(path)) {
33
+ const prior=json(path);
34
+ if(prior.inputHash!==hash)throw new Error('Same incident identity with conflicting evidence; review it before creating a new episode');
35
+ if(!prior.job)throw new Error('Admission outcome unknown; reconcile the existing case in the dashboard before retrying');
36
+ return {id:prior.job,case_id:prior.case_id,deduplicated:true};
37
+ }
38
+ const entry={case_id:`case_${key.slice(0,16)}`,inputHash:hash,received_at:new Date().toISOString(),case_state:'open',input:clean};
39
+ writeFileSync(path,JSON.stringify(entry,null,2),{mode:0o600,flag:'wx'});
40
+ // Keeping a pending record on a network failure prevents a duplicate writer.
41
+ const job=await submit('defence',`ARCITAI_INCIDENT_REF:${key}`);
42
+ save(path,{...entry,job:job.id});return {...job,case_id:entry.case_id,deduplicated:false};
43
+ }
44
+ export async function incidentFor(state,prompt,job) {
45
+ const match=prompt.trim().match(/^ARCITAI_INCIDENT_REF:([a-f0-9]{64})$/);
46
+ if(!match)throw new Error('Submit incident evidence through the CLI incident command');
47
+ const path=join(state,'incidents',`${match[1]}.json`);
48
+ let entry=json(path);
49
+ // Submission may return just after the worker claims its first step. Never let
50
+ // the executor claim a pending admission itself: only the submitting controller binds it.
51
+ for(let i=0;!entry.job&&i<30;i++){await sleep(100);entry=json(path);}
52
+ if(!entry.job||entry.job!==job)throw new Error('Incident admission is not bound to this job; reconcile it instead of duplicating the case');
53
+ // Recheck the trusted scope and freshness at execution time.
54
+ const input=validateIncident({...entry.input,sanitized:true},configAt(state).scope);
55
+ return {path,entry,input};
56
+ }
57
+ export function validateReport(report,incident) {
58
+ const list=k=>Array.isArray(report[k])&&report[k].length<=50&&report[k].every(v=>typeof v==='string'&&v.length<=4000);
59
+ if(!['needs_review','insufficient_evidence'].includes(report.status)||typeof report.summary!=='string'||!report.summary.trim()||report.summary.length>8000||report.production_action_taken!==false||!['hypotheses','recommended_actions','unknowns'].every(list))throw new Error('Invalid incident draft');
60
+ const incomplete=report.status==='insufficient_evidence'||incident.input.gaps.length>0;
61
+ // Model output cannot introduce authoritative recovery or verification fields.
62
+ return {case_id:incident.entry.case_id,case_state:'open',draft:true,verification:'not_performed',status:incomplete?'insufficient_evidence':'needs_review',investigation:incomplete?'inconclusive':'triaged',
63
+ summary:report.summary,hypotheses:report.hypotheses,recommended_actions:report.recommended_actions,unknowns:report.unknowns,evidence_gaps:incident.input.gaps,production_action_taken:false};
64
+ }
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { resolve, dirname, join, isAbsolute } from 'node:path';
3
+ import { spawnSync, spawn } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+
6
+ import { ROOT, DEFAULT_STATE } from './paths.mjs';
7
+ export { ROOT, DEFAULT_STATE };
8
+ export const PINS = JSON.parse(readFileSync(join(ROOT, 'factory/pins.json')));
9
+ export const json = path => JSON.parse(readFileSync(path, 'utf8'));
10
+ export function save(path, value) {
11
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
12
+ const temp = `${path}.${process.pid}.tmp`;
13
+ writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); renameSync(temp, path);
14
+ }
15
+ export function run(command, args, options = {}) {
16
+ const result = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...options });
17
+ if (result.error) throw result.error;
18
+ if (result.status !== 0) throw new Error(`${command} failed (${result.status}): ${result.stderr || result.stdout || ''}`);
19
+ return result.stdout?.trim() ?? '';
20
+ }
21
+ export function stream(command, args, options = {}) {
22
+ return new Promise((ok, fail) => {
23
+ const child = spawn(command, args, { stdio: 'inherit', ...options });
24
+ child.on('error', fail); child.on('exit', code => code === 0 ? ok() : fail(new Error(`${command} exited ${code}`)));
25
+ });
26
+ }
27
+ export const digest = value => createHash('sha256').update(value).digest('hex');
28
+ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
29
+ export function configAt(state) {
30
+ const config = json(join(state, 'factory.json'));
31
+ if (config.version !== 1 || !['mock','codex','pi','custom'].includes(config.agent)) throw new Error('Unsupported configuration');
32
+ if (!Array.isArray(config.command) || !config.command.length || !config.command.every(v => typeof v === 'string' && v && !v.includes('\0'))) throw new Error('command must be an argument array');
33
+ if (!Number.isInteger(config.port) || config.port < 1024 || config.port > 65535) throw new Error('Invalid port');
34
+ if (!Number.isInteger(config.timeoutSeconds) || config.timeoutSeconds < 1 || config.timeoutSeconds > 7200) throw new Error('timeoutSeconds must be 1–7200');
35
+ if (!Number.isInteger(config.memoryMiB) || config.memoryMiB < 256) throw new Error('Invalid memory limit');
36
+ if (!['none','bridge'].includes(config.network)) throw new Error('network must be none or bridge; host networking is not supported');
37
+ if (typeof config.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_./:@-]*$/.test(config.image)) throw new Error('Invalid container image');
38
+ if (typeof config.repo !== 'string' || !isAbsolute(config.repo) || [config.repo,state,ROOT].some(p=>/[,\n\r]/.test(p))) throw new Error('Expected absolute paths without commas or line breaks');
39
+ if (typeof config.check !== 'string' || !config.scope || !['project','service','environment','owner'].every(k=>typeof config.scope[k]==='string'&&config.scope[k].trim())) throw new Error('Missing check or installation scope');
40
+ if (!existsSync(config.repo)) throw new Error('Configured repository is missing');
41
+ return config;
42
+ }
43
+ export async function api(state, path, body, method) {
44
+ const config = configAt(state);
45
+ const response = await fetch(`http://127.0.0.1:${config.port}${path}`, {
46
+ method: method || (body === undefined ? 'GET' : 'POST'),
47
+ headers: { Authorization: `Bearer ${readFileSync(join(state, 'worker.token'), 'utf8').trim()}`, 'Content-Type': 'application/json' },
48
+ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000),
49
+ });
50
+ const text = await response.text();
51
+ if (!response.ok) throw new Error(`Controller ${response.status}: ${text}`);
52
+ return text ? JSON.parse(text) : null;
53
+ }
54
+ export function instanceLabel(state) { return digest(resolve(state)).slice(0, 16); }
55
+ export function containers(state) {
56
+ const ids = run('docker', ['ps', '-aq', '--filter', `label=sdf.factory=${instanceLabel(state)}`]).split('\n').filter(Boolean);
57
+ return ids.flatMap(id=>{
58
+ const result=spawnSync('docker',['inspect',id],{encoding:'utf8'});
59
+ if(result.error)throw result.error;
60
+ // Normal completion can remove a container between list and inspect.
61
+ if(result.status!==0) {
62
+ if(/no such (object|container)/i.test(result.stderr))return [];
63
+ throw new Error(`Cannot inspect job container: ${result.stderr}`);
64
+ }
65
+ return JSON.parse(result.stdout);
66
+ });
67
+ }
68
+ export function stopContainers(state, jobId) {
69
+ for (const item of containers(state)) {
70
+ if (jobId && item.Config.Labels['sdf.job'] !== jobId) continue;
71
+ if (item.State.Running) {
72
+ try {run('docker', ['stop', '--time', '2', item.Id]);}
73
+ catch(error) {if(containers(state).some(c=>c.Id===item.Id&&c.State.Running))throw error;}
74
+ }
75
+ try { run('docker', ['rm', item.Id]); } catch (error) {
76
+ if (containers(state).some(c => c.Id === item.Id)) throw error;
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,13 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
7
+ export const SOURCE_CHECKOUT = existsSync(join(ROOT, '.git'));
8
+ export const STATE_HOME = join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'software-defence-factory');
9
+ export const DATA_HOME = join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'software-defence-factory');
10
+ // Keep existing source-checkout installations compatible. npm installations never
11
+ // put private state in node_modules, an npm cache, or the caller's application.
12
+ export const DEFAULT_STATE = SOURCE_CHECKOUT ? join(ROOT, '.factory/platform') : join(STATE_HOME, 'platform');
13
+ export const DEFAULT_DEMO_STATE = SOURCE_CHECKOUT ? join(ROOT, '.factory/demo-platform') : join(STATE_HOME, 'demo-platform');
@@ -0,0 +1,3 @@
1
+ {
2
+ "jobImage": "software-defence-factory-job:0.3.0"
3
+ }
@@ -0,0 +1,60 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ROOT, configAt, run, json, stopContainers, sleep } from './lib.mjs';
5
+
6
+ const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { if (error.code === 'ESRCH') return false; throw error; } };
7
+ export function executors(state) {
8
+ const children = new Map();
9
+ async function stop(jobId) {
10
+ const running = children.get(jobId);
11
+ if (running) {
12
+ try { process.kill(-running.child.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
13
+ await Promise.race([running.done, sleep(2000)]);
14
+ if (alive(-running.child.pid)) {
15
+ try { process.kill(-running.child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
16
+ await running.done;
17
+ }
18
+ }
19
+ stopContainers(state, jobId);
20
+ if (running && !alive(-running.child.pid)) rmSync(join(state, 'jobs', jobId, 'active.json'), { force: true });
21
+ }
22
+ async function reconcile(jobId, phase) {
23
+ if (children.has(jobId)) throw new Error('Previous executor is still finishing');
24
+ stopContainers(state, jobId);
25
+ const folder = join(state, 'jobs', jobId), lock = join(folder, 'active.json');
26
+ if (existsSync(lock)) {
27
+ const previous = json(lock);
28
+ if (!Number.isSafeInteger(previous.pid) || alive(previous.pid)) throw new Error('Previous executor is still present or unknown');
29
+ if (!Number.isSafeInteger(previous.pgid) || run('ps', ['-axo', 'pgid=']).split('\n').map(Number).includes(previous.pgid))
30
+ throw new Error('Previous process group is still present or unknown');
31
+ rmSync(lock);
32
+ }
33
+ if (['build', 'defence'].includes(phase) && existsSync(join(folder, 'checkout')))
34
+ renameSync(join(folder, 'checkout'), join(folder, `previous-checkout-${Date.now()}`));
35
+ }
36
+ async function execute(job, attempt) {
37
+ const config = configAt(state), output = join(state, 'jobs', job.id, 'artifacts', attempt.id);
38
+ mkdirSync(output, { recursive: true, mode: 0o700 });
39
+ const resultPath = join(output, 'result.json'), fd = openSync(join(output, 'executor.log'), 'a', 0o600);
40
+ const child = spawn(process.execPath, [join(ROOT, 'factory/executor.mjs'), state, attempt.command], {
41
+ detached: true, stdio: ['pipe', fd, fd], env: { ...process.env, SDF_JOB_ID: job.id, SDF_RUN_ID: attempt.id, SDF_MODEL: job.model || '', SDF_OUTPUT_DIR: output, SDF_STEP_RESULT_PATH: resultPath },
42
+ }); closeSync(fd);
43
+ let finish;
44
+ const done = new Promise(resolve => { finish = resolve; });
45
+ children.set(job.id, { child, done });
46
+ child.on('error', error => finish({ error }));
47
+ child.on('close', code => finish({ code }));
48
+ child.stdin.on('error', error => { if (error.code !== 'EPIPE') finish({ error }); }); child.stdin.end(job.prompt);
49
+ const deadline = setTimeout(() => { stop(job.id).catch(error => console.error(error.message)); }, (config.timeoutSeconds + 30) * 1000);
50
+ let exit;
51
+ try { exit = await done; }
52
+ finally { clearTimeout(deadline); children.delete(job.id); }
53
+ if (exit.error) throw exit.error;
54
+ if (!existsSync(resultPath)) throw new Error(`Executor exited ${exit.code} without a result`);
55
+ const outcome = JSON.parse(readFileSync(resultPath, 'utf8'));
56
+ if (exit.code !== 0 || outcome.outcome !== 'complete') return { outcome: 'blocked', summary: outcome.summary || `Executor exited ${exit.code}` };
57
+ return outcome;
58
+ }
59
+ return { execute, stop, reconcile };
60
+ }
@@ -0,0 +1,132 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+
5
+ const workflows = { software: ['build', 'verify', 'review', 'handoff'], defence: ['defence'] };
6
+ const id = prefix => prefix + '_' + randomBytes(12).toString('hex');
7
+ const now = () => new Date().toISOString();
8
+ export class QueueError extends Error { constructor(message, status = 409) { super(message); this.status = status; } }
9
+
10
+ // One controller owns this database and one executor at a time. Each transition
11
+ // is committed before execution starts; a restart never assumes a result.
12
+ export class JobQueue {
13
+ constructor(state, { execute, stop, reconcile }) {
14
+ this.db = new DatabaseSync(join(state, 'jobs.sqlite'));
15
+ this.db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY, data TEXT NOT NULL);');
16
+ this.execute = execute; this.stop = stop; this.reconcile = reconcile;
17
+ this.active = null; this.closing = false; this.pumping = false; this.actions = new Set();
18
+ for (const job of this.all()) if (['running', 'cancelling'].includes(job.state)) {
19
+ job.state = 'interrupted';
20
+ Object.assign(job.runs.at(-1), { state: 'interrupted', completed_at: now(), error: 'Controller stopped before completion was confirmed.' });
21
+ this.save(job);
22
+ }
23
+ }
24
+ all() { return this.db.prepare('SELECT data FROM jobs ORDER BY rowid').all().map(row => JSON.parse(row.data)).filter(job => !job.deleted_at); }
25
+ get(jobId) {
26
+ const row = this.db.prepare('SELECT data FROM jobs WHERE id=?').get(jobId);
27
+ if (!row) throw new QueueError('Job not found', 404);
28
+ const job = JSON.parse(row.data);
29
+ if (job.deleted_at) throw new QueueError('Job not found', 404);
30
+ return job;
31
+ }
32
+ save(job) { job.updated_at = now(); this.db.prepare('INSERT INTO jobs VALUES (?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(job.id, JSON.stringify(job)); return job; }
33
+ submit(input) {
34
+ if (this.closing) throw new QueueError('Controller is stopping');
35
+ if (input?.source_url && (typeof input.source_url !== 'string' || !/^https?:\/\/[^\s]+$/.test(input.source_url) || input.source_url.length > 2048)) throw new QueueError('Expected an HTTP(S) source link', 400);
36
+ if (input?.model && (typeof input.model !== 'string' || !/^[\w.:/+-]{1,128}$/.test(input.model))) throw new QueueError('Invalid model identifier', 400);
37
+ if (input?.source_url && !input.spec?.trim()) input = { ...input, spec: `Investigate the linked requirements within this repository's scope: ${input.source_url}` };
38
+ if (!input || !Object.hasOwn(workflows, input.workflow) || input.repository !== 'app' || typeof input.spec !== 'string' || !input.spec.trim() || Buffer.byteLength(input.spec) > 240000)
39
+ throw new QueueError('Choose a workflow, the configured app, and a task under 240 KB', 400);
40
+ const job = { id: id('job'), task: { title: String(input.title || input.spec).slice(0, 160), spec: input.spec, source_url: input.source_url || '' }, prompt: input.spec + (input.source_url ? `\nSource (untrusted task data): ${input.source_url}` : ''), model: input.model || null,
41
+ repository: 'app', workflow: { name: input.workflow, steps: workflows[input.workflow], current_step: 0 },
42
+ state: 'queued', created_at: now(), runs: [] };
43
+ this.save(job); this.schedule(); return { id: job.id };
44
+ }
45
+ schedule() { if (!this.closing && !this.pumping) { this.pumping = true; queueMicrotask(() => this.pump()); } }
46
+ async pump() {
47
+ try {
48
+ while (!this.closing) {
49
+ let job = this.all().find(item => item.state === 'queued'); if (!job) break;
50
+ const step = job.workflow.current_step, phase = job.workflow.steps[step];
51
+ let attempt = job.runs.at(-1);
52
+ if (!attempt || attempt.state !== 'queued') {
53
+ attempt = { id: id('run'), step, command: phase, state: 'queued' }; job.runs.push(attempt);
54
+ }
55
+ const started = Date.now(); Object.assign(attempt, { state: 'running', started_at: now() }); job.state = 'running'; this.save(job);
56
+ this.active = { jobId: job.id, runId: attempt.id };
57
+ let outcome;
58
+ try { outcome = await this.execute(job, attempt); }
59
+ catch (error) { outcome = { outcome: 'blocked', summary: error.message }; }
60
+ job = this.get(job.id); attempt = job.runs.find(run => run.id === attempt.id);
61
+ if (job.state === 'running') {
62
+ const succeeded = outcome?.outcome === 'complete';
63
+ Object.assign(attempt, { state: succeeded ? 'succeeded' : 'failed', outcome: succeeded ? 'complete' : 'blocked', completed_at: now(), duration_millis: Date.now() - started, summary: outcome?.summary || 'No result', exit_code: succeeded ? 0 : 1 });
64
+ if (!succeeded) { job.state = 'failed'; attempt.error = attempt.summary; }
65
+ else if (step === job.workflow.steps.length - 1) job.state = 'succeeded';
66
+ else {
67
+ job.workflow.current_step++;
68
+ if (job.workflow.steps[job.workflow.current_step] === 'handoff') {
69
+ job.state = 'awaiting_approval';
70
+ job.runs.push({ id: id('run'), step: job.workflow.current_step, command: 'handoff', state: 'awaiting_approval', reviewed_run_id: attempt.id });
71
+ } else job.state = 'queued';
72
+ }
73
+ this.save(job);
74
+ }
75
+ this.active = null;
76
+ }
77
+ } finally { this.pumping = false; }
78
+ }
79
+ async exclusive(jobId, perform) {
80
+ if (this.closing || this.actions.has(jobId)) throw new QueueError('Job is already changing; reload before acting');
81
+ this.actions.add(jobId);
82
+ try { return await perform(); } finally { this.actions.delete(jobId); }
83
+ }
84
+ action(jobId, action, input) { return this.exclusive(jobId, () => this.applyAction(jobId, action, input)); }
85
+ async applyAction(jobId, action, input) {
86
+ if (this.closing) throw new QueueError('Controller is stopping');
87
+ const job = this.get(jobId), attempt = job.runs.at(-1);
88
+ if (input?.run_id !== attempt?.id) throw new QueueError('Job changed; reload before acting');
89
+ if (action === 'approve') {
90
+ if (job.state !== 'awaiting_approval') throw new QueueError('Job is not awaiting approval');
91
+ job.state = 'queued'; attempt.state = 'queued'; this.save(job); this.schedule();
92
+ } else if (action === 'request_changes') {
93
+ if (job.state !== 'awaiting_approval' || job.workflow.name !== 'software') throw new QueueError('Only a reviewed software task can be revised');
94
+ if (typeof input.feedback !== 'string' || !input.feedback.trim() || input.feedback.length > 4000) throw new QueueError('Provide revision feedback under 4000 characters', 400);
95
+ await this.reconcile(jobId, 'build');
96
+ Object.assign(attempt, { state: 'succeeded', outcome: 'changes_requested', summary: input.feedback, completed_at: now(), duration_millis: 0 });
97
+ job.prompt += `\n\nRequested revision: ${input.feedback}`;
98
+ job.workflow.current_step = 0; job.state = 'queued'; this.save(job); this.schedule();
99
+ } else if (action === 'cancel') {
100
+ if (!['queued', 'running', 'awaiting_approval', 'blocked', 'interrupted'].includes(job.state)) throw new QueueError('Job is already stopped');
101
+ job.state = 'cancelling'; this.save(job);
102
+ try { await this.stop(jobId); }
103
+ catch (error) { job.state = 'interrupted'; this.save(job); throw error; }
104
+ job.state = 'cancelled';
105
+ if (attempt) Object.assign(attempt, { state: 'cancelled', completed_at: now() });
106
+ this.save(job);
107
+ } else if (action === 'retry') {
108
+ if (!['failed', 'interrupted', 'cancelled'].includes(job.state)) throw new QueueError('Only a stopped attempt can be retried');
109
+ await this.reconcile(jobId, attempt?.command);
110
+ job.state = 'queued'; this.save(job); this.schedule();
111
+ } else throw new QueueError('Unknown action', 404);
112
+ return { id: jobId, state: job.state };
113
+ }
114
+ remove(jobId) { return this.exclusive(jobId, () => this.removeStopped(jobId)); }
115
+ async removeStopped(jobId) {
116
+ const job = this.get(jobId);
117
+ if (!['succeeded', 'failed', 'cancelled'].includes(job.state) || this.active?.jobId === jobId) throw new QueueError('Stop the task before removing it');
118
+ await this.reconcile(jobId);
119
+ job.deleted_at = now(); this.save(job);
120
+ return { id: jobId, deleted: true };
121
+ }
122
+ async close() {
123
+ this.closing = true;
124
+ if (this.active) {
125
+ const job = this.get(this.active.jobId);
126
+ job.state = 'interrupted'; Object.assign(job.runs.at(-1), { state: 'interrupted', completed_at: now() }); this.save(job);
127
+ await this.stop(job.id);
128
+ }
129
+ while (this.pumping || this.actions.size) await new Promise(resolve => setTimeout(resolve, 20));
130
+ this.db.close();
131
+ }
132
+ }
@@ -0,0 +1,101 @@
1
+ import http from 'node:http';
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
4
+ import { join, resolve, sep } from 'node:path';
5
+ import { hostname } from 'node:os';
6
+ import { JobQueue, QueueError } from './queue.mjs';
7
+ import { executors } from './processes.mjs';
8
+ import { configAt, ROOT } from './lib.mjs';
9
+
10
+ function equal(a, b) { return typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b)); }
11
+ async function body(request) {
12
+ let text = '';
13
+ for await (const part of request) { text += part; if (Buffer.byteLength(text) > 256000) throw new QueueError('Request too large', 413); }
14
+ try { const value = JSON.parse(text); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(); return value; }
15
+ catch { throw new QueueError('Expected a JSON object', 400); }
16
+ }
17
+ function artifacts(state, jobId) {
18
+ const root = join(state, 'jobs', jobId, 'artifacts'); if (!existsSync(root)) return [];
19
+ return readdirSync(root).filter(name => /^run_[a-f0-9]+$/.test(name)).flatMap(runId =>
20
+ readdirSync(join(root, runId)).filter(name => /^[\w.-]+\.(?:md|json|patch|log)$/.test(name)).flatMap(name => {
21
+ const stat = lstatSync(join(root, runId, name));
22
+ return stat.isFile() && !stat.isSymbolicLink() ? [{ id: `${jobId}~${runId}~${name}`, run_id: runId, path: name, storagePath: `${runId}/${name}`, size: stat.size, bytes: stat.size, content_type: 'text/plain' }] : [];
23
+ }));
24
+ }
25
+ export function createController(state, adapter = executors(state)) {
26
+ const config = configAt(state), csrf = randomBytes(32).toString('hex');
27
+ const token = readFileSync(join(state, 'worker.token'), 'utf8').trim();
28
+ const queue = new JobQueue(state, adapter);
29
+ const server = http.createServer(async (request, response) => {
30
+ const send = (status, value, type = 'application/json; charset=utf-8') => { response.writeHead(status, { 'Content-Type': type }); response.end(type.startsWith('application/json') ? JSON.stringify(value) : value); };
31
+ response.setHeader('Cache-Control', 'no-store'); response.setHeader('X-Content-Type-Options', 'nosniff'); response.setHeader('Referrer-Policy', 'no-referrer');
32
+ response.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
33
+ try {
34
+ const port = server.address().port, hosts = [`localhost:${port}`, `127.0.0.1:${port}`];
35
+ if (!hosts.includes(request.headers.host)) throw new QueueError('Host is not allowed', 403);
36
+ if (request.headers.origin && !hosts.map(host => `http://${host}`).includes(request.headers.origin)) throw new QueueError('Origin is not allowed', 403);
37
+ if (request.headers['sec-fetch-site'] === 'cross-site') throw new QueueError('Cross-site access is not allowed', 403);
38
+ const url = new URL(request.url, `http://${request.headers.host}`);
39
+ const authenticated = equal(request.headers.authorization, `Bearer ${token}`) || equal(request.headers['x-factory-session'], csrf);
40
+ if (request.method === 'GET' && url.pathname === '/api/v1/status') {
41
+ const jobs = queue.all().map(job => ({ ...job, runs: job.runs.map(attempt => ({ ...attempt,
42
+ outcome: attempt.outcome || (attempt.state === 'succeeded' ? 'complete' : undefined),
43
+ executor: ['verify','handoff'].includes(attempt.command) ? 'deterministic' : config.agent,
44
+ worker_name: hostname(), model: job.model || config.model })) }));
45
+ return send(200, { version: 1, workflows: ['software', 'defence'], commands: [], triggers: [], jobs, csrf_token: csrf,
46
+ workers: [{ name: hostname(), instance_id: 'local-executor', repositories: ['app'], connected: !queue.closing, last_seen_at: new Date().toISOString() }],
47
+ repositories: ['app'], repo: config.repo, agent: config.agent });
48
+ }
49
+ if (request.method === 'GET' && url.pathname === '/api/v1/definitions') {
50
+ const descriptions = {
51
+ build: 'Implement the accepted task in an isolated checkout, using the bundled policy and skills. Produce a candidate commit and implementation evidence.',
52
+ verify: `Run the configured application check on the candidate commit: ${config.check}`,
53
+ review: 'Independently review the candidate diff and checks. Record pass, changes or blocked with concrete findings.',
54
+ handoff: 'After operator approval, verify the candidate and policy still match the checks and review. Record acceptance without pushing, merging or deploying.',
55
+ defence: 'Read-only triage of an admitted, scoped incident. Produce a private draft, preserve unknowns and make no production change or recovery claim.',
56
+ };
57
+ return send(200, { workflows: { software: ['build','verify','review','handoff'].map(name => ({ name, approval: name === 'handoff' })), defence: [{ name: 'defence' }] },
58
+ commands: Object.entries(descriptions).map(([name,prompt]) => ({ name, prompt, executor: ['verify','handoff'].includes(name) ? 'deterministic' : config.agent, timeout: `${config.timeoutSeconds}s` })) });
59
+ }
60
+ const content = url.pathname.match(/^\/api\/v1\/artifacts\/(job_[a-f0-9]+)~(run_[a-f0-9]+)~([\w.-]+)\/content$/);
61
+ const artifactList = url.pathname.match(/^\/api\/v1\/jobs\/(job_[a-f0-9]+)\/artifacts$/);
62
+ if (request.method === 'GET' && (artifactList || content)) {
63
+ if (!authenticated) throw new QueueError('Session required', 403);
64
+ const jobId = (artifactList || content)[1]; queue.get(jobId);
65
+ const entries = artifacts(state, jobId), file = content ? `${content[2]}/${content[3]}` : url.searchParams.get('file');
66
+ if (!file) return send(200, entries);
67
+ if (!entries.some(item => item.storagePath === file && item.bytes <= 1024 * 1024)) throw new QueueError('Artifact not available', 404);
68
+ const root = realpathSync(join(state, 'jobs', jobId, 'artifacts')), path = realpathSync(resolve(root, file));
69
+ if (!path.startsWith(root + sep)) throw new QueueError('Artifact path rejected', 403);
70
+ return send(200, readFileSync(path), 'text/plain; charset=utf-8');
71
+ }
72
+ if (request.method === 'POST') {
73
+ if (!authenticated) throw new QueueError('Session required', 403);
74
+ if (!(request.headers['content-type'] || '').startsWith('application/json')) throw new QueueError('Use application/json', 415);
75
+ const input = await body(request);
76
+ if (url.pathname === '/api/v1/jobs') {
77
+ if (input.model && input.model !== config.model && !['codex','pi'].includes(config.agent)) throw new QueueError('Model overrides require a codex or pi executor', 400);
78
+ return send(201, queue.submit(input));
79
+ }
80
+ const action = url.pathname.match(/^\/api\/v1\/jobs\/(job_[a-f0-9]+)\/(approve|cancel|retry|request_changes)$/);
81
+ if (action) return send(200, await queue.action(action[1], action[2], input));
82
+ }
83
+ const removal = url.pathname.match(/^\/api\/v1\/jobs\/(job_[a-f0-9]+)$/);
84
+ if (request.method === 'DELETE' && removal) {
85
+ if (!authenticated) throw new QueueError('Session required', 403);
86
+ return send(200, await queue.remove(removal[1]));
87
+ }
88
+ const asset = url.pathname.match(/^\/assets\/([\w.-]+\.(js|css|woff2))$/);
89
+ if (request.method === 'GET' && (url.pathname === '/' || asset)) {
90
+ const name = asset ? `assets/${asset[1]}` : 'index.html';
91
+ const types = { js: 'text/javascript', css: 'text/css', woff2: 'font/woff2' };
92
+ const path = join(ROOT, 'factory/ui', name);
93
+ if (!existsSync(path)) throw new QueueError('Asset not found', 404);
94
+ return send(200, readFileSync(path), asset ? types[asset[2]] : 'text/html; charset=utf-8');
95
+ }
96
+ throw new QueueError('Not found', 404);
97
+ } catch (error) { if (!error.status) console.error(error); send(error.status || 500, { error: error.status ? error.message : 'Controller error; see private logs' }); }
98
+ });
99
+ server.requestTimeout = 20000; server.headersTimeout = 15000; server.on('listening', () => queue.schedule());
100
+ return { server, queue, async close() { await queue.close(); await new Promise(resolve => server.close(resolve)); } };
101
+ }
@@ -0,0 +1,20 @@
1
+ import { writeFileSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { configAt, stopContainers } from './lib.mjs';
4
+ import { createController } from './server.mjs';
5
+ const state = process.argv[2], config = configAt(state), lock = join(state, 'supervisor.json');
6
+ writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
7
+ let controller, stopping = false;
8
+ async function shutdown(code = 0) {
9
+ if (stopping) return; stopping = true;
10
+ try { if (controller) await controller.close(); stopContainers(state); }
11
+ catch (error) { console.error(`Stop unconfirmed: ${error.message}`); process.exit(1); }
12
+ rmSync(lock, { force: true }); process.exit(code);
13
+ }
14
+ process.on('SIGTERM', () => shutdown()); process.on('SIGINT', () => shutdown());
15
+ try {
16
+ stopContainers(state);
17
+ controller = createController(state);
18
+ controller.server.on('error', error => { console.error(error.message); shutdown(1); });
19
+ controller.server.listen(config.port, '127.0.0.1', () => console.log(`Factory controller ready on 127.0.0.1:${config.port}`));
20
+ } catch (error) { console.error(error.message); await shutdown(1); }