@ours.network/install 1.2.1-nightly.4 → 1.2.1-nightly.5

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 CHANGED
@@ -281,3 +281,17 @@ maintenance reads both formats. Format2 is not readable by older maintenance too
281
281
  so this change does not promise executable downgrade support. Restore validates the
282
282
  original archive records unchanged, then writes markers for the active target runtime.
283
283
  Mixed/incomplete context and component record sets are refused before activation.
284
+
285
+ ### Retrying a Docker installation after a source-policy permission failure
286
+
287
+ Setup verifies the selected Docker image before reusing it, including when the image
288
+ already exists. If an older installer left a root-owned, mode-0600
289
+ `/opt/ours/sources.json`, setup repairs only that image file's permissions and
290
+ continues. The selected package bytes, build provenance, image execution settings,
291
+ credentials and stored data are retained. The old materialized Dockerfile's known
292
+ COPY instruction is also corrected for future builds. No manual image or volume
293
+ deletion is required; repeat the original setup with the updated installer.
294
+
295
+ The repair runs isolated verification containers without state mounts or network
296
+ access. Other image verification failures stop setup without replacing the image.
297
+ An interrupted repair can be retried, including after the image tag was replaced.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "channel": "nightly",
4
- "installerVersion": "1.2.1-nightly.4",
4
+ "installerVersion": "1.2.1-nightly.5",
5
5
  "packages": {
6
6
  "@ours.network/sdk": {
7
7
  "version": "3.8.1-nightly.3",
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.4",
5
+ "installerVersion": "1.2.1-nightly.5",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
8
  "version": "3.8.1-nightly.3",
@@ -0,0 +1,105 @@
1
+ /** Qualify cached images before reuse; repair only the known root-owned 0600 policy. */
2
+ import { lstatSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import { atomicWriteConfig } from './config.mjs';
7
+
8
+ const policyPath = '/opt/ours/sources.json';
9
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
10
+ const fail = message => { throw new Error(`Docker runtime verification refused: ${message}`); };
11
+ const imageId = value => /^sha256:[0-9a-f]{64}$/.test(value ?? '');
12
+
13
+ export function refreshDockerPolicyCopy(record) {
14
+ const path = join(record.workDir, 'Dockerfile'), stat = lstatSync(path);
15
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || (stat.mode & 0o7022)) fail('Unsafe retained Dockerfile');
16
+ const text = readFileSync(path, 'utf8');
17
+ // Upgrade precisely the formerly shipped instruction; preserve all other assets.
18
+ const oldLine = /^COPY sources\.json \/opt\/ours\/sources\.json$/m;
19
+ if (oldLine.test(text)) atomicWriteConfig(path, text.replace(oldLine, 'COPY --chmod=644 sources.json /opt/ours/sources.json'));
20
+ }
21
+
22
+ const probeScript = `
23
+ const fs = require('node:fs'), crypto = require('node:crypto');
24
+ (async () => {
25
+ const path = '${policyPath}', s = fs.lstatSync(path);
26
+ if (!s.isFile() || s.nlink !== 1 || s.uid !== 0 || s.gid !== 0 || ![0o600, 0o644].includes(s.mode & 0o7777)) throw new Error('Unsupported policy ownership/type/mode');
27
+ let bytes;
28
+ try { bytes = fs.readFileSync(path); }
29
+ catch (error) { if (process.getuid() !== 0 && error.code === 'EACCES') { process.exitCode = 74; return; } throw error; }
30
+ const graph = await import('/opt/ours/maintenance/release-graph.mjs');
31
+ graph.verifyRuntimeRelease('/opt/ours');
32
+ const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
33
+ const records = {};
34
+ for (const name of ['package.json', 'package-lock.json', 'dependency-tree.json', 'build-context.json']) {
35
+ const file = '/opt/ours/' + name, stat = fs.lstatSync(file);
36
+ if (!stat.isFile() || stat.nlink !== 1) throw new Error('Unsupported build record');
37
+ records[name] = hash(fs.readFileSync(file));
38
+ }
39
+ console.log(JSON.stringify({ uid: s.uid, gid: s.gid, regular: true, links: s.nlink, mode: s.mode & 0o7777, policyHash: hash(bytes), records }));
40
+ })().catch(error => { console.error(error.message); process.exitCode = 1; });
41
+ `;
42
+
43
+ export async function qualifyDockerRuntime(record, effects) {
44
+ if (!/^ours-[a-z0-9-]+$/.test(record.project ?? '')) fail('unexpected installation project');
45
+ const uid = record.uid ?? 1000, gid = record.gid ?? 1000;
46
+ if (![uid, gid].every(n => Number.isInteger(n) && n > 0)) fail('non-root runtime UID/GID required');
47
+ const tag = `${record.project}:runtime`;
48
+ const inspect = async target => {
49
+ const { stdout } = await effects.run('docker', ['image', 'inspect', target], { timeout: 60000 });
50
+ const [value] = JSON.parse(stdout);
51
+ if (!imageId(value?.Id) || !Array.isArray(value.RootFS?.Layers) || value.Config?.Labels?.['network.ours.build-context'] !== '1') fail('image lacks recognized build metadata');
52
+ return value;
53
+ };
54
+ const probe = async (id, user) => {
55
+ const name = `ours-runtime-probe-${randomUUID()}`;
56
+ try {
57
+ const result = await effects.run('docker', ['run', '--rm', '--name', name, '--read-only', '--network', 'none', '--cap-drop', 'ALL',
58
+ '--security-opt', 'no-new-privileges:true', '--pids-limit', '64', '--memory', '256m', '--user', user, '--entrypoint', 'node', id, '-e', probeScript],
59
+ { allowCodes: [1, 74], timeout: 60000 });
60
+ return { code: result.code, report: result.code === 0 ? JSON.parse(result.stdout) : null };
61
+ } catch (error) {
62
+ // A killed Docker client can leave its own bounded probe container behind.
63
+ try { await effects.run('docker', ['rm', '-f', name], { allowCodes: [1], timeout: 10000 }); } catch {}
64
+ throw error;
65
+ }
66
+ };
67
+ const policy = readFileSync(record.sourcesPath), expectedHash = digest(policy);
68
+ const check = (report, mode) => {
69
+ if (!report || report.mode !== mode || report.uid !== 0 || report.gid !== 0 || !report.regular || report.links !== 1 || report.policyHash !== expectedHash) fail('image policy differs from retained selection or expected permissions');
70
+ };
71
+ const original = await inspect(tag);
72
+ const ordinary = await probe(original.Id, `${uid}:${gid}`);
73
+ if (ordinary.code === 0) { check(ordinary.report, 0o644); return; }
74
+ if (ordinary.code !== 74) fail('cached image is unusable; automatic repair only supports the known source-policy permission defect');
75
+ const privileged = await probe(original.Id, '0:0');
76
+ if (privileged.code !== 0) fail('original release graph could not be verified');
77
+ check(privileged.report, 0o600);
78
+ effects.out?.('Repairing cached Docker image permissions; keeping the selected packages and stored data.');
79
+ const directory = mkdtempSync(join(record.root, '.image-permissions-'));
80
+ const name = `ours-permission-repair-${randomUUID()}`, baseTag = `${name}:base`, candidateTag = `${name}:candidate`;
81
+ try {
82
+ // Unique local alias transports the immutable ID into BuildKit. The resulting
83
+ // layer ancestry and complete execution config are checked before publication.
84
+ await effects.run('docker', ['image', 'tag', original.Id, baseTag]);
85
+ if ((await inspect(baseTag)).Id !== original.Id) fail('repair base image changed');
86
+ writeFileSync(join(directory, 'sources.json'), policy, { mode: 0o600, flag: 'wx' });
87
+ writeFileSync(join(directory, 'Dockerfile'), `FROM ${baseTag}\nCOPY --chmod=644 sources.json ${policyPath}\n`, { mode: 0o600, flag: 'wx' });
88
+ await effects.run('docker', ['build', '--network=none', '--pull=false', '--tag', candidateTag, directory], { stream: true, timeout: 120000 });
89
+ const candidate = await inspect(candidateTag);
90
+ if (!isDeepStrictEqual(candidate.Config, original.Config) || !isDeepStrictEqual(candidate.RootFS.Layers.slice(0, original.RootFS.Layers.length), original.RootFS.Layers)
91
+ || candidate.RootFS.Layers.length !== original.RootFS.Layers.length + 1) fail('repair changed image execution settings or ancestry');
92
+ const verified = await probe(candidate.Id, `${uid}:${gid}`);
93
+ if (verified.code !== 0) fail('repaired image did not pass runtime verification');
94
+ check(verified.report, 0o644);
95
+ if (!isDeepStrictEqual(verified.report.records, privileged.report.records)) fail('repair changed build provenance');
96
+ if ((await inspect(tag)).Id !== original.Id) fail('selected image changed during repair; retry setup');
97
+ await effects.run('docker', ['image', 'tag', candidate.Id, tag]);
98
+ effects.out?.('Cached Docker image repaired and verified; continuing setup.');
99
+ } finally {
100
+ for (const temporary of [candidateTag, baseTag]) {
101
+ try { await effects.run('docker', ['image', 'rm', temporary], { allowCodes: [1], timeout: 10000 }); } catch {}
102
+ }
103
+ rmSync(directory, { recursive: true, force: true });
104
+ }
105
+ }
package/lib/effects.mjs CHANGED
@@ -23,6 +23,7 @@ import { createServerOnboarding } from './server-onboarding.mjs';
23
23
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
24
24
  import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
25
25
  import { classifyHarnessProbe } from './logic.mjs';
26
+ import { qualifyDockerRuntime, refreshDockerPolicyCopy } from './docker-runtime-repair.mjs';
26
27
  import { classifyStateDir } from './detect.mjs';
27
28
  import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
28
29
  import { releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
@@ -399,7 +400,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
399
400
  // invocation only and never to the installer's own process: a state
400
401
  // directory selected by one run must not leak into anything the operator
401
402
  // starts afterwards.
402
- run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [] } = {}) => {
403
+ run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [], timeout } = {}) => {
403
404
  // Always built from this layer's OWN env rather than left to spawnSync's
404
405
  // implicit inheritance, so what a child receives is a property of the
405
406
  // effects object a caller constructed and not of whatever ambient shell
@@ -410,6 +411,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
410
411
  const executable = cmd === 'npm' ? npmBin : cmd;
411
412
  const r = spawnSync(executable, args, {
412
413
  cwd,
414
+ timeout,
413
415
  encoding: 'utf8',
414
416
  stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
415
417
  env: childEnv,
@@ -704,6 +706,7 @@ export function networkEffects(effects) {
704
706
  else if (!readFileSync(record.sourcesPath).equals(bytes)) throw new Error('Migration source selection changed');
705
707
  if (!retainConfig) writePrivateNew(record.configPath, JSON.stringify({ stateDir: record.mode === 'docker' ? '/var/lib/ours' : installationPaths(record).daemon, port: record.port, apiVisibility: 'owner' }, null, 2) + '\n');
706
708
  },
709
+ async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
707
710
  async prepareInstallation(record, { runtimeOnly = false } = {}) {
708
711
  let copied = false;
709
712
  if (!existsSync(record.workDir)) {
@@ -717,11 +720,13 @@ export function networkEffects(effects) {
717
720
  if (!existsSync(materialized)) writePrivateNew(materialized, retained);
718
721
  else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
719
722
  if (record.mode === 'docker') {
723
+ refreshDockerPolicyCopy(record);
720
724
  // The installer owns these dependencies in both installation modes.
721
725
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
722
726
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
723
727
  const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
724
728
  if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
729
+ await effects.qualifyDockerRuntime(record);
725
730
  if (runtimeOnly) return;
726
731
  await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
727
732
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "1.2.1-nightly.4",
3
+ "version": "1.2.1-nightly.5",
4
4
  "private": false,
5
5
  "description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
6
6
  "type": "module",