software-defence-factory 0.4.3 → 0.4.4
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/.github/ISSUE_TEMPLATE/factory-task.yml +5 -2
- package/bin/software-defence-factory.mjs +15 -24
- package/docs/quickstart.md +17 -2
- package/docs/recovery.md +9 -0
- package/docs/setup.md +17 -5
- package/factory/image-install.mjs +189 -0
- package/factory/installation-lock.mjs +15 -0
- package/factory/services.mjs +2 -2
- package/factory/supervisor.mjs +10 -2
- package/kit/README.md +4 -0
- package/kit/repository.md +86 -0
- package/package.json +1 -1
- package/scripts/export-kit.mjs +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
name: Factory task
|
|
2
2
|
description: Describe a business outcome and observable acceptance criteria.
|
|
3
3
|
title: "[Factory] "
|
|
4
|
+
labels: ["factory:triage"]
|
|
4
5
|
body:
|
|
5
6
|
- type: textarea
|
|
6
7
|
id: need
|
|
@@ -20,7 +21,9 @@ body:
|
|
|
20
21
|
id: scope
|
|
21
22
|
attributes:
|
|
22
23
|
label: Scope, constraints and recovery
|
|
23
|
-
description: Affected repository,
|
|
24
|
+
description: Affected repository, allowed changes, non-goals, dependencies and rollback. Link blocking issues.
|
|
25
|
+
validations:
|
|
26
|
+
required: true
|
|
24
27
|
- type: dropdown
|
|
25
28
|
id: track
|
|
26
29
|
attributes:
|
|
@@ -32,4 +35,4 @@ body:
|
|
|
32
35
|
id: capabilities
|
|
33
36
|
attributes:
|
|
34
37
|
label: Required capabilities
|
|
35
|
-
description: For example browser,
|
|
38
|
+
description: For example browser, tests, platform tools or security review. Name any external operator proof needed. Never paste credentials or sensitive findings into a public issue.
|
|
@@ -5,6 +5,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { createServer } from 'node:net';
|
|
7
7
|
import { ROOT, PINS, DEFAULT_STATE, configAt, save, json, run, stream, digest, api, sleep, stopContainers } from '../factory/lib.mjs';
|
|
8
|
+
import { assertInstalledJobImage, installCustomJobImage, installStandardJobImage, inspectImageInstallation } from '../factory/image-install.mjs';
|
|
8
9
|
import { admitIncident } from '../factory/incident.mjs';
|
|
9
10
|
import { DEFAULT_DEMO_STATE } from '../factory/paths.mjs';
|
|
10
11
|
import { bootstrap, registerInstallation, VERSION } from '../factory/updates.mjs';
|
|
@@ -47,27 +48,16 @@ function init(repo, agent='codex', check='', port=7331) {
|
|
|
47
48
|
console.log(`Configured ${state}\nApp files were not changed. Only committed code is cloned into jobs.`);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function retainImage(reference) {
|
|
51
|
-
let image;
|
|
52
|
-
try { image=run('docker',['image','inspect','--format','{{.Id}}',reference]); }
|
|
53
|
-
catch(error) { if(/No such image|No such object/.test(error.message))return null;throw error; }
|
|
54
|
-
if(!/^sha256:[a-f0-9]{64}$/.test(image))throw new Error('Docker returned an unexpected image identity');
|
|
55
|
-
// Containerd can drop an untagged manifest when the shared build tag moves.
|
|
56
|
-
// Keep the exact object reachable; existing installations retain their pins.
|
|
57
|
-
run('docker',['tag',image,`software-defence-factory-retained:${image.slice(7)}`]);
|
|
58
|
-
return image;
|
|
59
|
-
}
|
|
60
51
|
async function install() {
|
|
61
52
|
const config=configAt(state);
|
|
62
|
-
if(existsSync(join(state,'supervisor.json'))&&alive(json(join(state,'supervisor.json')).pid))throw new Error('Stop the factory before installing or updating its runtime');
|
|
63
53
|
if (!['darwin','linux'].includes(process.platform)||!['arm64','x64'].includes(process.arch)) throw new Error('Use macOS/Linux arm64/amd64, or WSL2');
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
console.log(
|
|
54
|
+
if(flags.image) {
|
|
55
|
+
const selected=installCustomJobImage(state,flags.image,{version:VERSION});
|
|
56
|
+
console.log(`Installed custom job image ${selected.image} from local reference ${selected.reference}. No image was downloaded or built; no model call made.`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const installed=await installStandardJobImage(state,{version:VERSION});
|
|
60
|
+
console.log(`Installed standard job image ${installed.image}. No model call made.`);
|
|
71
61
|
}
|
|
72
62
|
async function portFree(port) {
|
|
73
63
|
await new Promise((ok,fail)=>{const server=createServer();server.once('error',fail);server.listen(port,'127.0.0.1',()=>server.close(ok));});
|
|
@@ -76,9 +66,9 @@ async function up() {
|
|
|
76
66
|
const config=configAt(state),lock=join(state,'supervisor.json');
|
|
77
67
|
registerInstallation(state);
|
|
78
68
|
if(existsSync(lock)) { if(alive(json(lock).pid))throw new Error('Supervisor already running; use status');rmSync(lock); }
|
|
79
|
-
|
|
69
|
+
assertInstalledJobImage(state,config);
|
|
80
70
|
if(process.getuid()===0)throw new Error('Run the controller as a dedicated unprivileged user with Docker access');
|
|
81
|
-
|
|
71
|
+
await portFree(config.port);
|
|
82
72
|
const fd=openSync(join(state,'supervisor.log'),'a',0o600);
|
|
83
73
|
const child=spawn(process.execPath,[join(ROOT,'factory/supervisor.mjs'),state],{detached:true,stdio:['ignore',fd,fd]});
|
|
84
74
|
closeSync(fd);child.unref();
|
|
@@ -139,8 +129,7 @@ try {
|
|
|
139
129
|
const launch=async()=>{
|
|
140
130
|
if(process.getuid()===0)throw new Error('Use a dedicated unprivileged operator account');
|
|
141
131
|
const lock=join(state,'supervisor.json');if(existsSync(lock)){if(alive(json(lock).pid))throw new Error('Supervisor already running');rmSync(lock);}
|
|
142
|
-
|
|
143
|
-
run('docker',['image','inspect',configAt(state).image]);
|
|
132
|
+
assertInstalledJobImage(state,configAt(state));
|
|
144
133
|
registerInstallation(state);await portFree(configAt(state).port);
|
|
145
134
|
const { supervise } = await import('../factory/supervisor.mjs');await supervise(state);
|
|
146
135
|
};
|
|
@@ -153,7 +142,9 @@ try {
|
|
|
153
142
|
else if(command==='tunnel')await manageService('tunnel',positional[0],state,flags);
|
|
154
143
|
else if(command==='status') { const snapshot=await api(state,'/api/v1/status');delete snapshot.csrf_token;console.log(JSON.stringify(snapshot,null,2)); }
|
|
155
144
|
else if(command==='doctor') {
|
|
156
|
-
const config=configAt(state)
|
|
145
|
+
const config=configAt(state),dockerVersion=run('docker',['info','--format','{{.ServerVersion}}']),imageStatus=inspectImageInstallation(state,config);
|
|
146
|
+
console.log(JSON.stringify({node:process.version,docker:dockerVersion,engineInstalled:imageStatus.installed,image:imageStatus.image,repo:config.repo,agent:config.agent,checksConfigured:!!config.check?.trim(),inference:'Not called or verified',qualification:{model:'not assessed',toolchain:'not assessed'},dashboard:`http://127.0.0.1:${config.port}`},null,2));
|
|
147
|
+
if(!imageStatus.installed)process.exitCode=1;
|
|
157
148
|
} else if(command==='run') {
|
|
158
149
|
let spec;
|
|
159
150
|
if(flags.issue) {
|
|
@@ -195,7 +186,7 @@ try {
|
|
|
195
186
|
demo Install and run a synthetic sample (no model key)
|
|
196
187
|
qualify --state PATH Exercise recovery and isolation with a stopped demo job
|
|
197
188
|
init --repo PATH --agent codex|pi|custom --check "npm ci && npm test"
|
|
198
|
-
install
|
|
189
|
+
install [--image LOCAL_REF] Build the standard image, or select an existing local image
|
|
199
190
|
doctor | up | status | stop Inspect / operate your private installation
|
|
200
191
|
serve Foreground supervisor
|
|
201
192
|
service [print] Print a systemd user-service definition
|
package/docs/quickstart.md
CHANGED
|
@@ -61,8 +61,23 @@ Use `status`, `cancel JOB_ID`, `retry JOB_ID` and `stop`, always with the select
|
|
|
61
61
|
|
|
62
62
|
## Native application builds
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
Build a compatible application image on the worker, then select its existing
|
|
65
|
+
local tag through the CLI:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
software-defence-factory install --image LOCAL_IMAGE_REF --state /private/state/my-app
|
|
69
|
+
software-defence-factory doctor --state /private/state/my-app
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Selection resolves and retains the immutable image ID. It does not pull or build
|
|
73
|
+
the reference. An unavailable image, a running controller, an unreconciled job
|
|
74
|
+
or a remaining job container makes selection fail without changing the last
|
|
75
|
+
working configuration. Plain `install` remains the standard-image build path
|
|
76
|
+
and selects the standard image again. `doctor` verifies that the recorded image
|
|
77
|
+
metadata matches the image selected in private state; it reports model and
|
|
78
|
+
toolchain qualification separately, and does not perform either qualification.
|
|
79
|
+
|
|
80
|
+
In `factory.json`, `cpus` (1–32, default 2), `pidsLimit` (64–16384, default 256), `memoryMiB` and
|
|
66
81
|
`timeoutSeconds` bound the job's resources. Verification uses a separate
|
|
67
82
|
disk-backed checkout, keeping the candidate read-only. Its private scratch
|
|
68
83
|
directory is removed after container termination is confirmed. Interrupted
|
package/docs/recovery.md
CHANGED
|
@@ -90,3 +90,12 @@ and any contemporaneous private configuration backup. Record only corroborated
|
|
|
90
90
|
facts in a separate private incident note with evidence paths and unknowns; leave
|
|
91
91
|
the original queue/evidence unchanged. Without that evidence, model/image facts
|
|
92
92
|
cannot be reconstructed reliably.
|
|
93
|
+
|
|
94
|
+
### Interrupted image selection or controller startup
|
|
95
|
+
|
|
96
|
+
`installation.lock` serializes image changes with controller startup, including
|
|
97
|
+
managed boot/restarts. A live supervisor then prevents image changes while work
|
|
98
|
+
can run. If an operation is interrupted, preserve the lock and inspect its PID
|
|
99
|
+
and action. Remove it only after confirming that process and its image build or
|
|
100
|
+
startup have stopped, then run `doctor` and reconcile image metadata before
|
|
101
|
+
starting again. The CLI does not automatically clear an unknown lock.
|
package/docs/setup.md
CHANGED
|
@@ -104,6 +104,9 @@ anything. Verify the canonical GitHub origin and main branch's tracking target;
|
|
|
104
104
|
a fork may still track its upstream product. Move application sources into the
|
|
105
105
|
chosen workspace, not into the npm package or private runtime state.
|
|
106
106
|
|
|
107
|
+
Use [repository readiness](../kit/repository.md) for issue forms, labels, CI
|
|
108
|
+
policy and the explicit issue-to-job handoff. A ready label does not start a job.
|
|
109
|
+
|
|
107
110
|
Before admitting development work, establish:
|
|
108
111
|
|
|
109
112
|
- Reproducible toolchain/dependency pins and an actual build/check command.
|
|
@@ -123,11 +126,20 @@ software-defence-factory install --state /private/state/my-app
|
|
|
123
126
|
software-defence-factory doctor --state /private/state/my-app
|
|
124
127
|
```
|
|
125
128
|
|
|
126
|
-
Replace the agent/check/paths with the accepted application profile.
|
|
127
|
-
builds the standard image
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
Replace the agent/check/paths with the accepted application profile. Plain
|
|
130
|
+
`install` builds the standard image. To use an application-specific image, build
|
|
131
|
+
it on the worker first and select its existing local tag instead:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
software-defence-factory install --image LOCAL_IMAGE_REF --state /private/state/my-app
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
This command checks the local Docker daemon, records and retains the exact image
|
|
138
|
+
ID, and does not download or build the selected image. Stop the installation and
|
|
139
|
+
reconcile every job before changing its image. Running plain `install` later
|
|
140
|
+
still rebuilds and selects the standard image. `init` does not edit the app,
|
|
141
|
+
install personal skills or submit a task. Runtime jobs receive the bundled
|
|
142
|
+
method and six skills automatically.
|
|
131
143
|
|
|
132
144
|
For a local model, verify the existing model service, intended model name and
|
|
133
145
|
its startup. Test the model API from a disposable container using the **selected
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync, readFileSync, readdirSync, writeFileSync, renameSync, rmSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { configAt, instanceLabel, json, PINS, ROOT, run, stream } from './lib.mjs';
|
|
7
|
+
import { acquireInstallationLock } from './installation-lock.mjs';
|
|
8
|
+
import { VERSION } from './updates.mjs';
|
|
9
|
+
|
|
10
|
+
const imageIdPattern = /^sha256:[a-f0-9]{64}$/;
|
|
11
|
+
const imageReferencePattern = /^[a-zA-Z0-9][a-zA-Z0-9_./:@-]*$/;
|
|
12
|
+
const missingImage = error => /no such (image|object)/i.test(error.message);
|
|
13
|
+
const alive = pid => {
|
|
14
|
+
try { process.kill(pid, 0); return true; }
|
|
15
|
+
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function docker(args, runner = run) { return runner('docker', args); }
|
|
19
|
+
|
|
20
|
+
export function inspectLocalImage(reference, runner = run) {
|
|
21
|
+
if (typeof reference !== 'string' || !imageReferencePattern.test(reference))
|
|
22
|
+
throw new Error('Invalid Docker image reference');
|
|
23
|
+
let id;
|
|
24
|
+
try { id = docker(['image', 'inspect', '--format', '{{.Id}}', reference], runner); }
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (missingImage(error)) return null;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
if (!imageIdPattern.test(id)) throw new Error('Docker returned an unexpected image identity');
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function retainImage(image, runner = run) {
|
|
34
|
+
if (!imageIdPattern.test(image || '')) throw new Error('Expected an immutable Docker image ID');
|
|
35
|
+
// The shared build tag can move. Keep every selected image reachable by ID.
|
|
36
|
+
docker(['image', 'tag', image, `software-defence-factory-retained:${image.slice(7)}`], runner);
|
|
37
|
+
return image;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function retainIfPresent(reference, runner) {
|
|
41
|
+
const image = inspectLocalImage(reference, runner);
|
|
42
|
+
if (image) retainImage(image, runner);
|
|
43
|
+
return image;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertImageChangeSafe(state, { runner = run, isAlive = alive } = {}) {
|
|
47
|
+
const supervisor = join(state, 'supervisor.json');
|
|
48
|
+
if (existsSync(supervisor)) {
|
|
49
|
+
let record;
|
|
50
|
+
try { record = json(supervisor); }
|
|
51
|
+
catch { throw new Error('Supervisor state is unreadable; reconcile the installation before changing its image'); }
|
|
52
|
+
if (!Number.isSafeInteger(record.pid) || record.pid < 1)
|
|
53
|
+
throw new Error('Supervisor state is unverified; reconcile the installation before changing its image');
|
|
54
|
+
if (isAlive(record.pid)) throw new Error('Stop the Factory before changing its job image');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const jobs = join(state, 'jobs');
|
|
58
|
+
if (existsSync(jobs)) {
|
|
59
|
+
for (const job of readdirSync(jobs)) {
|
|
60
|
+
if (existsSync(join(jobs, job, 'active.json')))
|
|
61
|
+
throw new Error(`Job ${job} has active or unreconciled execution; reconcile it before changing the image`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const containers = docker(['ps', '-aq', '--filter', `label=sdf.factory=${instanceLabel(state)}`], runner)
|
|
66
|
+
.split('\n').filter(Boolean);
|
|
67
|
+
if (containers.length) throw new Error('Factory job containers remain; stop and reconcile them before changing the image');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function persistInstallation(state, config, metadata, filesystem = undefined) {
|
|
71
|
+
const fs = filesystem || { readFileSync, writeFileSync, renameSync, rmSync };
|
|
72
|
+
const configPath = join(state, 'factory.json'), enginePath = join(state, 'engine.json');
|
|
73
|
+
const previousConfig = fs.readFileSync(configPath);
|
|
74
|
+
const token = `${process.pid}.${randomBytes(8).toString('hex')}`;
|
|
75
|
+
const configTemp = `${configPath}.${token}.next`, engineTemp = `${enginePath}.${token}.next`;
|
|
76
|
+
let configReplaced = false;
|
|
77
|
+
try {
|
|
78
|
+
fs.writeFileSync(configTemp, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
79
|
+
fs.writeFileSync(engineTemp, `${JSON.stringify(metadata, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
80
|
+
fs.renameSync(configTemp, configPath);
|
|
81
|
+
configReplaced = true;
|
|
82
|
+
fs.renameSync(engineTemp, enginePath);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (configReplaced) {
|
|
85
|
+
const rollback = `${configPath}.${token}.rollback`;
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(rollback, previousConfig, { flag: 'wx', mode: 0o600 });
|
|
88
|
+
fs.renameSync(rollback, configPath);
|
|
89
|
+
} catch (rollbackError) {
|
|
90
|
+
throw new Error(`Image installation metadata could not be completed (${error.message}); restoring factory.json also failed (${rollbackError.message})`);
|
|
91
|
+
} finally { fs.rmSync(rollback, { force: true }); }
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
} finally {
|
|
95
|
+
fs.rmSync(configTemp, { force: true });
|
|
96
|
+
fs.rmSync(engineTemp, { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function metadataFor(image, source, reference, version) {
|
|
101
|
+
return {
|
|
102
|
+
runtime: 'native-node',
|
|
103
|
+
version,
|
|
104
|
+
image,
|
|
105
|
+
imageSource: source,
|
|
106
|
+
imageReference: reference,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function installCustomJobImage(state, reference, { runner = run, version = VERSION, isAlive = alive, filesystem } = {}) {
|
|
111
|
+
const release = acquireInstallationLock(state, 'select image');
|
|
112
|
+
try {
|
|
113
|
+
const config = configAt(state);
|
|
114
|
+
assertImageChangeSafe(state, { runner, isAlive });
|
|
115
|
+
const image = inspectLocalImage(reference, runner);
|
|
116
|
+
if (!image) throw new Error(`Docker image ${reference} is not present locally; build or pull it explicitly before selecting it`);
|
|
117
|
+
retainImage(image, runner);
|
|
118
|
+
const nextConfig = { ...config, image };
|
|
119
|
+
const metadata = metadataFor(image, 'custom', reference, version);
|
|
120
|
+
persistInstallation(state, nextConfig, metadata, filesystem);
|
|
121
|
+
return { image, reference, config: nextConfig, metadata };
|
|
122
|
+
} finally { release(); }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function installStandardJobImage(state, { runner = run, build = stream, version = VERSION, isAlive = alive, filesystem } = {}) {
|
|
126
|
+
const release = acquireInstallationLock(state, 'build image');
|
|
127
|
+
try {
|
|
128
|
+
const config = configAt(state);
|
|
129
|
+
assertImageChangeSafe(state, { runner, isAlive });
|
|
130
|
+
docker(['info', '--format', '{{.ServerVersion}}'], runner);
|
|
131
|
+
retainIfPresent(config.image, runner);
|
|
132
|
+
retainIfPresent(PINS.jobImage, runner);
|
|
133
|
+
await build('docker', ['build', '-t', PINS.jobImage, join(ROOT, 'factory/image')]);
|
|
134
|
+
const image = inspectLocalImage(PINS.jobImage, runner);
|
|
135
|
+
if (!image) throw new Error('Built job image is unavailable');
|
|
136
|
+
retainImage(image, runner);
|
|
137
|
+
const nextConfig = { ...config, image };
|
|
138
|
+
const metadata = metadataFor(image, 'standard', PINS.jobImage, version);
|
|
139
|
+
persistInstallation(state, nextConfig, metadata, filesystem);
|
|
140
|
+
return { image, config: nextConfig, metadata };
|
|
141
|
+
} finally { release(); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readMetadata(state) {
|
|
145
|
+
const path = join(state, 'engine.json');
|
|
146
|
+
if (!existsSync(path)) return null;
|
|
147
|
+
try { return json(path); } catch { return null; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function inspectImageInstallation(state, config = configAt(state), runner = run) {
|
|
151
|
+
const metadata = readMetadata(state);
|
|
152
|
+
let image = null;
|
|
153
|
+
image = inspectLocalImage(config.image, runner);
|
|
154
|
+
|
|
155
|
+
const metadataValid = Boolean(metadata
|
|
156
|
+
&& metadata.runtime === 'native-node'
|
|
157
|
+
&& typeof metadata.version === 'string'
|
|
158
|
+
&& imageIdPattern.test(metadata.image || '')
|
|
159
|
+
&& (!metadata.imageSource || ['standard', 'custom'].includes(metadata.imageSource))
|
|
160
|
+
&& (!metadata.imageReference || imageReferencePattern.test(metadata.imageReference)));
|
|
161
|
+
const imageAvailable = image !== null;
|
|
162
|
+
const markerMatches = metadataValid && imageAvailable && metadata.image === image;
|
|
163
|
+
const installed = Boolean(markerMatches);
|
|
164
|
+
return {
|
|
165
|
+
installed,
|
|
166
|
+
image: {
|
|
167
|
+
reference: config.image,
|
|
168
|
+
id: image,
|
|
169
|
+
available: imageAvailable,
|
|
170
|
+
pinned: imageAvailable && config.image === image,
|
|
171
|
+
markerImage: metadataValid ? metadata.image : null,
|
|
172
|
+
source: metadataValid ? (metadata.imageSource || 'legacy') : null,
|
|
173
|
+
referenceAtInstall: metadataValid ? (metadata.imageReference || null) : null,
|
|
174
|
+
metadataValid,
|
|
175
|
+
markerMatches: Boolean(markerMatches),
|
|
176
|
+
error: imageAvailable ? null : `Selected image ${config.image} is not present locally`,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function assertInstalledJobImage(state, config = configAt(state), runner = run) {
|
|
182
|
+
const status = inspectImageInstallation(state, config, runner);
|
|
183
|
+
if (!status.installed) {
|
|
184
|
+
if (!status.image.available) throw new Error(`Selected job image ${config.image} is not present locally; run install with an available image`);
|
|
185
|
+
if (!status.image.metadataValid) throw new Error('Runtime image metadata is missing or invalid; run install');
|
|
186
|
+
throw new Error('Selected job image does not match the installed image metadata; run install');
|
|
187
|
+
}
|
|
188
|
+
return status;
|
|
189
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Per installation: managed boot bypasses the CLI's global service-operation
|
|
5
|
+
// lock. Hold this fence until image metadata is complete or the supervisor PID
|
|
6
|
+
// is visible, before any queued work can snapshot an execution profile.
|
|
7
|
+
export function acquireInstallationLock(state, action) {
|
|
8
|
+
const path = join(state, 'installation.lock');
|
|
9
|
+
try { writeFileSync(path, JSON.stringify({ pid: process.pid, action }), { flag: 'wx', mode: 0o600 }); }
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === 'EEXIST') throw new Error(`Another installation operation may own ${path}; reconcile its PID before removing the lock`);
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
return () => rmSync(path);
|
|
15
|
+
}
|
package/factory/services.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { createServer } from 'node:net';
|
|
6
6
|
import { ROOT, STATE_HOME, DATA_HOME, SOURCE_CHECKOUT } from './paths.mjs';
|
|
7
7
|
import { configAt, json, save, run, api, sleep, digest } from './lib.mjs';
|
|
8
|
+
import { assertInstalledJobImage } from './image-install.mjs';
|
|
8
9
|
import { VERSION, newer, latestVersion, installRelease, busyInstallations } from './updates.mjs';
|
|
9
10
|
import { serviceId, systemdUnit, launchAgent, tunnelArguments, groupArguments, runtimeLauncher } from './service-files.mjs';
|
|
10
11
|
|
|
@@ -127,9 +128,8 @@ async function install(kind, state, flags) {
|
|
|
127
128
|
console.log(JSON.stringify(await serviceStatus(previous), null, 2)); return;
|
|
128
129
|
}
|
|
129
130
|
if (kind === 'controller') {
|
|
130
|
-
|
|
131
|
+
assertInstalledJobImage(state, configAt(state));
|
|
131
132
|
if (existsSync(join(state, 'supervisor.json')) && alive(json(join(state, 'supervisor.json')).pid)) throw new Error('Stop the manually started controller before adopting it as a service');
|
|
132
|
-
run('docker', ['image', 'inspect', configAt(state).image]);
|
|
133
133
|
}
|
|
134
134
|
let argv = kind === 'controller' ? [process.execPath, launcher(), 'serve', '--state', state] : tunnelArguments(flags.host, port);
|
|
135
135
|
if (flags.group) {
|
package/factory/supervisor.mjs
CHANGED
|
@@ -2,10 +2,18 @@ import { writeFileSync, rmSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { configAt, stopContainers } from './lib.mjs';
|
|
5
|
+
import { acquireInstallationLock } from './installation-lock.mjs';
|
|
6
|
+
import { assertInstalledJobImage } from './image-install.mjs';
|
|
5
7
|
import { createController } from './server.mjs';
|
|
6
8
|
export async function supervise(state) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
+
const lock = join(state, 'supervisor.json');
|
|
10
|
+
const release = acquireInstallationLock(state, 'start controller');
|
|
11
|
+
let config;
|
|
12
|
+
try {
|
|
13
|
+
config = configAt(state);
|
|
14
|
+
assertInstalledJobImage(state, config);
|
|
15
|
+
writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
|
|
16
|
+
} finally { release(); }
|
|
9
17
|
let controller, stopping = false;
|
|
10
18
|
async function shutdown(code = 0) {
|
|
11
19
|
if (stopping) return; stopping = true;
|
package/kit/README.md
CHANGED
|
@@ -18,6 +18,10 @@ The six skills cover triage, specification, implementation, review, security and
|
|
|
18
18
|
|
|
19
19
|
## First real task
|
|
20
20
|
|
|
21
|
+
Follow [repository readiness](repository.md) to adopt issue forms and labels,
|
|
22
|
+
verify CI/protection and explicitly admit a scoped task. Exporting this kit does
|
|
23
|
+
not install GitHub labels, poll issues or start agents.
|
|
24
|
+
|
|
21
25
|
Choose a small existing defect or improvement. Describe the intended user behavior, allowed scope and observable acceptance check. Implement a working vertical slice, exercise the app's relevant checks and obtain a separate review of the delivered revision. A demo or copied skill files alone do not qualify an application.
|
|
22
26
|
|
|
23
27
|
A PR requires the configured GitHub authority. Otherwise hand back the branch/diff and evidence with an honest status. Unknown cost/time remain unknown. Automated starts require separate qualification of triggers, deduplication, stop/restart and actual resource limits; a skill does not create a scheduler.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Prepare a repository for Factory work
|
|
2
|
+
|
|
3
|
+
The method works with an existing agent and CI. The optional runtime adds a
|
|
4
|
+
queue, isolated execution and dashboard. GitHub preparation provides a shared
|
|
5
|
+
work queue; it does not turn labels into an execution trigger.
|
|
6
|
+
|
|
7
|
+
## Establish a reproducible baseline
|
|
8
|
+
|
|
9
|
+
Record the canonical Git origin, target branch, committed source revision,
|
|
10
|
+
toolchain, check command and existing owner instructions. Preserve WIP separately.
|
|
11
|
+
Verify the real checks in the intended job image and CI. Inspect the actual
|
|
12
|
+
branch protection/ruleset: required checks, current-base behavior, PR policy and
|
|
13
|
+
merge authority. If an account plan prevents enforcement, record the limitation;
|
|
14
|
+
do not silently change billing or repository visibility.
|
|
15
|
+
|
|
16
|
+
## Adopt the issue form and labels
|
|
17
|
+
|
|
18
|
+
The exported kit includes `.github/ISSUE_TEMPLATE/factory-task.yml` and
|
|
19
|
+
`.factory-kit/labels.json`. Review the form and merge it with existing repository
|
|
20
|
+
templates on a branch. The source repository keeps these label definitions in
|
|
21
|
+
`config/labels.json`. Install labels before enabling the form's default label.
|
|
22
|
+
|
|
23
|
+
For an authorized GitHub repository, `gh label create` creates one label; use
|
|
24
|
+
`gh label edit` only after comparing an existing label with the proposed meaning.
|
|
25
|
+
Do not delete or overwrite an adopter's unrelated labels. For example:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
gh label list --repo OWNER/REPO
|
|
29
|
+
gh label create 'factory:triage' --repo OWNER/REPO --color D9E2D3 --description 'Needs bounded scope and capability routing'
|
|
30
|
+
# Repeat for the selected definitions in labels.json, then read them back.
|
|
31
|
+
gh label list --repo OWNER/REPO
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Label | Meaning |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `factory:triage` | An idea or report needing bounded scope |
|
|
37
|
+
| `factory:spec` | Outcome, constraints or acceptance still need definition |
|
|
38
|
+
| `factory:ready` | Accepted scope is ready for explicit admission |
|
|
39
|
+
| `factory:review` | A candidate/evidence set needs operator review |
|
|
40
|
+
| `factory:blocked` | A concrete dependency or recovery prevents progress |
|
|
41
|
+
| `track:software` | Software delivery |
|
|
42
|
+
| `track:security` | Appropriately scoped security work; sensitive evidence stays private |
|
|
43
|
+
|
|
44
|
+
Use at most one Factory stage label at a time; track labels are separate.
|
|
45
|
+
GitHub's closed state records completed or declined issues. Runtime job state is
|
|
46
|
+
more precise than these planning labels and remains authoritative for execution.
|
|
47
|
+
Update labels deliberately; the current runtime does not synchronize them.
|
|
48
|
+
|
|
49
|
+
## Admit one ready issue
|
|
50
|
+
|
|
51
|
+
A ready task identifies the desired behavior, allowed scope, non-goals,
|
|
52
|
+
dependencies, meaningful acceptance, required capabilities and delivery target.
|
|
53
|
+
Split a large roadmap into independently reviewable changes. Assign external
|
|
54
|
+
browser/platform proof explicitly when the selected worker image lacks it.
|
|
55
|
+
Never treat a label, issue author or text as permission to obtain credentials,
|
|
56
|
+
change policy, deploy or merge.
|
|
57
|
+
|
|
58
|
+
For the runtime, inspect the selected installation and queue before starting it;
|
|
59
|
+
existing queued jobs may execute on startup. Hold a dedicated committed source
|
|
60
|
+
at the intended SHA until the current runtime has cloned it, and compare the
|
|
61
|
+
candidate's recorded base before acceptance. Admission-time source snapshots are
|
|
62
|
+
tracked separately in Factory #28; naming a SHA in task text does not pin it.
|
|
63
|
+
|
|
64
|
+
Submit the selected issue with `software-defence-factory run --issue URL --state
|
|
65
|
+
PATH`, or submit a reviewed task file using `run --file`. Issue submission uses
|
|
66
|
+
the operator's `gh` authentication and checks the issue against the configured
|
|
67
|
+
origin. The dashboard's task form submits text to the same runtime; it does not
|
|
68
|
+
currently browse or import a live GitHub backlog. Keep external link/issue/job
|
|
69
|
+
mapping in the trusted handoff record. No automatic GitHub polling is enabled.
|
|
70
|
+
|
|
71
|
+
## Review and deliver
|
|
72
|
+
|
|
73
|
+
The implementation, configured checks and independent review must cover the
|
|
74
|
+
same candidate and acceptance policy. The operator completes external proof and
|
|
75
|
+
approves the specific reviewed result. Retain failed attempts and use explicit
|
|
76
|
+
revision feedback when a candidate needs changes.
|
|
77
|
+
|
|
78
|
+
The current runtime returns a patch after acceptance. The authorized operator
|
|
79
|
+
applies it at its recorded base on a unique branch, verifies the resulting tree,
|
|
80
|
+
and opens a normal PR subject to current checks/protection. GitHub credentials
|
|
81
|
+
stay outside jobs. Accepted is not pushed, merged or deployed. Optional trusted
|
|
82
|
+
PR publication is Factory #29, not an installed feature of this guide.
|
|
83
|
+
|
|
84
|
+
Link issue, job, base, candidate, checks, review, external proof and PR. Keep
|
|
85
|
+
private paths, credentials and raw incident/model logs outside public records.
|
|
86
|
+
One successful scoped task qualifies that path, not arbitrary unattended work.
|
package/package.json
CHANGED
package/scripts/export-kit.mjs
CHANGED
|
@@ -17,7 +17,7 @@ if (args.length === 1 && args[0] === "--help") {
|
|
|
17
17
|
const destination = resolve(args[0]);
|
|
18
18
|
try {
|
|
19
19
|
const sources = new Map();
|
|
20
|
-
for (const name of ["README.md", "policy.md", "installation.md", "delivery.md", "examples/github-checks.yml.example"])
|
|
20
|
+
for (const name of ["README.md", "policy.md", "installation.md", "delivery.md", "repository.md", "examples/github-checks.yml.example"])
|
|
21
21
|
sources.set(`.factory-kit/${name}`, `kit/${name}`);
|
|
22
22
|
for (const role of ["triage", "spec", "implement", "review", "security", "evaluate"]) {
|
|
23
23
|
const path = `.agents/skills/factory-${role}/SKILL.md`;
|