software-defence-factory 0.3.2 → 0.3.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.
@@ -46,13 +46,25 @@ function init(repo, agent='codex', check='', port=7331) {
46
46
  console.log(`Configured ${state}\nApp files were not changed. Only committed code is cloned into jobs.`);
47
47
  }
48
48
 
49
+ function retainImage(reference) {
50
+ let image;
51
+ try { image=run('docker',['image','inspect','--format','{{.Id}}',reference]); }
52
+ catch(error) { if(/No such image|No such object/.test(error.message))return null;throw error; }
53
+ if(!/^sha256:[a-f0-9]{64}$/.test(image))throw new Error('Docker returned an unexpected image identity');
54
+ // Containerd can drop an untagged manifest when the shared build tag moves.
55
+ // Keep the exact object reachable; existing installations retain their pins.
56
+ run('docker',['tag',image,`software-defence-factory-retained:${image.slice(7)}`]);
57
+ return image;
58
+ }
49
59
  async function install() {
50
60
  const config=configAt(state);
51
61
  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');
52
62
  if (!['darwin','linux'].includes(process.platform)||!['arm64','x64'].includes(process.arch)) throw new Error('Use macOS/Linux arm64/amd64, or WSL2');
53
63
  run('docker',['info','--format','{{.ServerVersion}}']);
64
+ retainImage(config.image);retainImage(PINS.jobImage);
54
65
  await stream('docker',['build','-t',PINS.jobImage,join(ROOT,'factory/image')]);
55
- config.image=run('docker',['image','inspect','--format','{{.Id}}',PINS.jobImage]);
66
+ config.image=retainImage(PINS.jobImage);
67
+ if(!config.image)throw new Error('Built job image is unavailable');
56
68
  save(join(state,'factory.json'),config);save(join(state,'engine.json'),{runtime:'native-node',version:VERSION,image:config.image});
57
69
  console.log('Installed. Job image is pinned to its local image ID. No model call made.');
58
70
  }
package/docs/npm.md CHANGED
@@ -60,7 +60,11 @@ Source checkouts remain managed by Git and do not update themselves.
60
60
 
61
61
  CLI updates do not rebuild or update Docker images automatically. Stop the
62
62
  installation and run `install --state PATH` when intentionally adopting a new
63
- runtime image; repeat the relevant qualification before resuming jobs.
63
+ runtime image; repeat the relevant qualification before resuming jobs. Install retains the old and new
64
+ image IDs under `software-defence-factory-retained` tags, so another installation
65
+ rebuilding the shared tag cannot remove an existing installation's pinned image.
66
+ These retained images are recovery data; remove them only after confirming no
67
+ installation or retained attempt needs them.
64
68
 
65
69
  ## Release flow
66
70
 
@@ -26,6 +26,8 @@ software-defence-factory install --state /private/state/my-app
26
26
  software-defence-factory doctor --state /private/state/my-app
27
27
  ```
28
28
 
29
+ Verification commands receive `FACTORY_BASE_REVISION`, the resolved commit recorded as the candidate base. Diff-based checks should compare against this revision; the isolated checkout has no origin remote. The value comes from protected controller metadata, not the task text.
30
+
29
31
  Replace the check with the application's actual verification command. `init` does not edit the app, copy global skills or start work. It creates factory.json, worker.token and model.env with private permissions. Each installation has one repository and a distinct state path/port. `--agent pi` selects Pi; `--agent custom --command-json '["executable","argument"]'` selects an available command in the job image. The bundled image provides Node, Git, Codex and Pi. Other toolchains require an intentionally built compatible image; do not claim Rust/mobile/browser capabilities from this image alone.
30
32
 
31
33
  Configure inference credentials in the private model.env file. Do not copy the operator's entire account environment or authentication folders. Codex uses its supported API credential environment; Pi uses the selected provider's configuration. Use `--model` with init for a specific model. Task-level model overrides are supported only for Codex/Pi and do not prove that the provider serves that model.
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, lstatSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, lstatSync, chmodSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { ROOT, configAt, run, save, json, digest, instanceLabel, stopContainers } from './lib.mjs';
@@ -44,6 +44,15 @@ function safeRead(path) {
44
44
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error('Expected a regular report under 1 MiB');
45
45
  return readFileSync(path, 'utf8');
46
46
  }
47
+ // Called only after the container has stopped. Builds may leave read-only cache
48
+ // directories; make owned directories traversable without following symlinks.
49
+ function removeScratch(path) {
50
+ if (lstatSync(path).isDirectory()) {
51
+ chmodSync(path, 0o700);
52
+ for (const name of readdirSync(path)) removeScratch(join(path, name));
53
+ }
54
+ rmSync(path, { recursive: true, force: true });
55
+ }
47
56
  async function container(mode, input, command, writable = false, credentials = false) {
48
57
  const name = `sdf-${instanceLabel(state)}-${attempt}-${mode}`;
49
58
  const reportDir = join(folder, attempt, mode);
@@ -64,6 +73,7 @@ async function container(mode, input, command, writable = false, credentials = f
64
73
  '--mount',`type=bind,source=${join(ROOT,'kit')},target=/factory-policy,readonly`,
65
74
  '--mount',`type=bind,source=${join(ROOT,'.agents/skills')},target=/factory-skills,readonly`];
66
75
  if (scratch) args.push('--mount',`type=bind,source=${scratch},target=/scratch`);
76
+ if (mode === 'verify') args.push('--env',`FACTORY_BASE_REVISION=${git('rev-parse',`${metadata().base}^{commit}`)}`);
67
77
  if (credentials) args.push('--env-file', join(state,'model.env'));
68
78
  args.push('-i',config.image,'timeout','--signal=KILL',`${config.timeoutSeconds}s`,'sh','-c','mkdir -p "$HOME" && exec "$@"','factory',...command);
69
79
  console.log(JSON.stringify({ phase: mode, event: 'started', synthetic: config.agent === 'mock' }));
@@ -84,8 +94,10 @@ async function container(mode, input, command, writable = false, credentials = f
84
94
  const probe = run('docker',['ps','-aq','--filter',`name=^/${name}$`]);
85
95
  if (probe) throw error;
86
96
  }
87
- if (scratch) rmSync(scratch, { recursive: true, force: true });
88
- if (code !== 0) throw new Error(`${mode} exited ${code}; private log: ${logPath}`);
97
+ let cleanupError;
98
+ try { if (scratch) removeScratch(scratch); } catch (error) { cleanupError = error; }
99
+ if (code !== 0) throw new Error(`${mode} exited ${code}; private log: ${logPath}${cleanupError ? `; scratch cleanup requires recovery: ${cleanupError.message}` : ''}`);
100
+ if (cleanupError) throw cleanupError;
89
101
  return reportDir;
90
102
  }
91
103
  function brief(instruction) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "software-defence-factory",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Scoped software delivery and defence investigations with isolated jobs, evidence and review",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@
2
2
  import assert from 'node:assert/strict';
3
3
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
4
  import { resolve, join } from 'node:path';
5
- import { api, configAt, save, json, containers, sleep, stream, ROOT } from '../factory/lib.mjs';
5
+ import { api, configAt, save, json, containers, sleep, stream, run, ROOT } from '../factory/lib.mjs';
6
6
  import { admitIncident } from '../factory/incident.mjs';
7
7
 
8
8
  const state=resolve(process.argv[2] || '.factory/demo-platform'),original=configAt(state);
@@ -36,6 +36,9 @@ try {
36
36
  const native=await work('Synthetic disk-backed build verification');
37
37
  const checking=await until(()=>containers(state).find(c=>c.Config.Labels['sdf.job']===native.id&&c.State.Running&&c.Config.Env.includes('FACTORY_PHASE=verify')));
38
38
  assert.equal(checking.HostConfig.NanoCpus,4000000000);assert.equal(checking.HostConfig.PidsLimit,1024);
39
+ const nativeCandidate=json(join(state,'jobs',native.id,'candidate.json'));
40
+ assert.notEqual(nativeCandidate.base,nativeCandidate.head);
41
+ assert(checking.Config.Env.includes(`FACTORY_BASE_REVISION=${nativeCandidate.base}`));
39
42
  assert(checking.Mounts.some(m=>m.Destination==='/scratch'&&m.RW));
40
43
  assert(checking.Mounts.some(m=>m.Destination==='/workspace'&&!m.RW));
41
44
  await waitState(native.id,'awaiting_approval');
@@ -43,7 +46,21 @@ try {
43
46
  assert(!existsSync(join(state,'jobs',native.id,verification.id,'check-workspace')));
44
47
  assert(!existsSync(join(state,'jobs',native.id,'checkout/large-build-fixture')));
45
48
  await cli('approve',native.id);await waitState(native.id,'succeeded');setConfig({});
46
- record('native build scratch exceeds 1 GiB without changing the candidate; limits and cleanup verified');
49
+ record('native build scratch exceeds 1 GiB; immutable base, read-only candidate, limits and cleanup verified');
50
+
51
+ for (const exitCode of [0, 17]) {
52
+ setConfig({check:`mkdir -p cache/nested && echo cache > cache/nested/value && ln -s /workspace cache/candidate && chmod 000 cache/nested cache && exit ${exitCode}`});
53
+ const readonly=await work(`Synthetic read-only cache cleanup, exit ${exitCode}`);
54
+ await waitState(readonly.id,exitCode===0?'awaiting_approval':'failed');
55
+ const run=(await snapshot(readonly.id)).runs.find(r=>r.command==='verify');
56
+ assert(!existsSync(join(state,'jobs',readonly.id,run.id,'check-workspace')));
57
+ assert.equal(readFileSync(join(state,'jobs',readonly.id,'checkout/value.txt'),'utf8'),'fixed\n');
58
+ assert.equal(readFileSync(join(original.repo,'value.txt'),'utf8'),'broken\n');
59
+ if(exitCode===0){await cli('approve',readonly.id);await waitState(readonly.id,'succeeded');}
60
+ else assert.match(run.error,/verify exited 17/);
61
+ record(`read-only cache cleanup after exit ${exitCode}; candidate symlink not followed and exit preserved`);
62
+ }
63
+ setConfig({});
47
64
 
48
65
  const changed=await work('Synthetic changed revision guard');await waitState(changed.id,'awaiting_approval');
49
66
  writeFileSync(join(state,'jobs',changed.id,'checkout/value.txt'),'changed after review\n');
@@ -84,6 +101,13 @@ try {
84
101
  await cli('stop');assert.equal(containers(state).length,0);await cli('up');await waitState(interrupted.id,'interrupted');
85
102
  setConfig({timeoutSeconds:2});await cli('retry',interrupted.id);await waitState(interrupted.id,'failed');
86
103
  assert.equal((await snapshot(interrupted.id)).runs.length,2);assert.equal(containers(state).length,0);record('stop/restart retains interrupted state; retry proves previous writer stopped');
104
+ const other=join(state,'second-installation');
105
+ await stream(process.execPath,[join(ROOT,'bin/software-defence-factory.mjs'),'init','--state',other,'--repo',original.repo,'--agent','mock','--check',original.check,'--port','7350']);
106
+ await stream(process.execPath,[join(ROOT,'bin/software-defence-factory.mjs'),'install','--state',other]);
107
+ assert.equal(run('docker',['image','inspect','--format','{{.Id}}',original.image]),original.image);
108
+ run('docker',['run','--rm','--init','--network','none',original.image,'node','-e','process.exit(0)']);
109
+ await cli('stop');setConfig({});await cli('up');
110
+ record('a second installation rebuild preserves the first pinned image and controller restart');
87
111
  save(join(state,'qualification.json'),{synthetic:true,platform:process.platform,arch:process.arch,engine:json(join(state,'engine.json')).version,results});
88
112
  console.log(`Qualified ${results.length} paths. Model quality, cost and production connectors were not measured.`);
89
113
  } finally {save(join(state,'factory.json'),original);}