@ours.network/install 1.2.1-nightly.3 → 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 +14 -0
- package/assets/Dockerfile +3 -1
- package/assets/release.json +1 -1
- package/assets/sources.json +1 -1
- package/lib/docker-runtime-repair.mjs +105 -0
- package/lib/effects.mjs +6 -1
- package/package.json +1 -1
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.
|
package/assets/Dockerfile
CHANGED
|
@@ -3,7 +3,9 @@ FROM node:24 AS toolchain
|
|
|
3
3
|
RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 ca-certificates git && rm -rf /var/lib/apt/lists/*
|
|
4
4
|
FROM toolchain AS build
|
|
5
5
|
WORKDIR /opt/ours
|
|
6
|
-
|
|
6
|
+
# The host selection is private (0600); this non-secret image policy must be
|
|
7
|
+
# readable by arbitrary runtime UIDs while remaining root-owned and non-writable.
|
|
8
|
+
COPY --chmod=644 sources.json /opt/ours/sources.json
|
|
7
9
|
COPY scripts/build/*.mjs /build-scripts/
|
|
8
10
|
COPY scripts/maintenance/build-context.mjs scripts/maintenance/provenance-compare.mjs scripts/maintenance/release-graph.mjs /maintenance/
|
|
9
11
|
RUN --mount=type=secret,id=github_token --mount=type=cache,id=ours-dist-2-npm,target=/root/.npm,sharing=locked node /build-scripts/build.mjs
|
package/assets/release.json
CHANGED
package/assets/sources.json
CHANGED
|
@@ -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.
|
|
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",
|