@ours.network/install 1.1.1 → 1.2.0-nightly.2
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 +196 -6
- package/assets/Dockerfile +41 -0
- package/assets/docker-compose.yaml +222 -0
- package/assets/scripts/README.md +20 -0
- package/assets/scripts/build/README.md +39 -0
- package/assets/scripts/build/build-common.mjs +37 -0
- package/assets/scripts/build/build-cowork.mjs +2 -0
- package/assets/scripts/build/build-fleet.mjs +2 -0
- package/assets/scripts/build/build-mcp.mjs +9 -0
- package/assets/scripts/build/build-messenger.mjs +2 -0
- package/assets/scripts/build/build-sdk.mjs +9 -0
- package/assets/scripts/build/build-telegram.mjs +2 -0
- package/assets/scripts/build/build.mjs +49 -0
- package/assets/scripts/build/record-build.mjs +31 -0
- package/assets/scripts/maintenance/README.md +35 -0
- package/assets/scripts/maintenance/build-context.mjs +162 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +238 -0
- package/assets/scripts/maintenance/provenance-compare.mjs +160 -0
- package/assets/scripts/maintenance/state-archive.mjs +238 -0
- package/assets/scripts/maintenance/state-native.mjs +56 -0
- package/assets/scripts/maintenance/state-operation.mjs +249 -0
- package/assets/scripts/runtime/README.md +24 -0
- package/assets/scripts/runtime/check-client.mjs +21 -0
- package/assets/scripts/runtime/check-start.mjs +15 -0
- package/assets/scripts/runtime/client-setup.mjs +197 -0
- package/assets/scripts/runtime/entrypoint.sh +13 -0
- package/assets/scripts/runtime/health-cowork.sh +11 -0
- package/assets/scripts/runtime/health-messenger.mjs +6 -0
- package/assets/scripts/runtime/health-telegram.sh +8 -0
- package/assets/scripts/runtime/healthcheck.mjs +17 -0
- package/assets/scripts/runtime/runtime-common.mjs +47 -0
- package/assets/scripts/runtime/start-cowork.sh +6 -0
- package/assets/scripts/runtime/start-messenger.sh +6 -0
- package/assets/scripts/runtime/start-telegram.sh +6 -0
- package/assets/sources.json +21 -0
- package/install.sh +2 -1
- package/lib/build-transition.mjs +56 -0
- package/lib/docker-conversion-runtime.mjs +96 -0
- package/lib/docker-layout-installation.mjs +62 -0
- package/lib/effects.mjs +945 -11
- package/lib/extras.mjs +23 -68
- package/lib/layout-conversion.mjs +297 -0
- package/lib/orchestrate-uninstall.mjs +30 -1
- package/lib/orchestrate.mjs +265 -18
- package/lib/plan.mjs +194 -1
- package/lib/target.mjs +100 -0
- package/lib/usage.mjs +27 -2
- package/package.json +9 -2
- package/uninstall.sh +2 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Acquire selected packages, build Git sources once, write the final manifest. */
|
|
2
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { SOURCE_ROOT, CONFIG, OUT, ROOT, SELECTED, archive, manifest, run, capture, inheritedLock } from './build-common.mjs';
|
|
7
|
+
const recipes = ['sdk', 'telegram', 'cowork', 'messenger', 'fleet', 'mcp'];
|
|
8
|
+
const packages = Object.fromEntries([...SELECTED].map(name => [name, CONFIG.packages[name]]));
|
|
9
|
+
for (const [name, selection] of Object.entries(packages)) {
|
|
10
|
+
const keys = Object.keys(selection || {}).sort().join(',');
|
|
11
|
+
if (keys === 'source') {
|
|
12
|
+
const source = selection.source;
|
|
13
|
+
if (!recipes.includes(source)) throw Error(`Unknown source recipe: ${source}`);
|
|
14
|
+
const spec = CONFIG.sources[source];
|
|
15
|
+
if (spec?.type !== 'git' || !/^[0-9a-f]{40}$/.test(spec.commit)) throw Error(`${source}: expected Git URL and full commit SHA`);
|
|
16
|
+
} else if (keys === 'type,version' && selection.type === 'npm') {
|
|
17
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(selection.version)) throw Error(`${name}: expected exact npm version`);
|
|
18
|
+
} else throw Error(`${name}: select a Git source or exact npm version`);
|
|
19
|
+
}
|
|
20
|
+
mkdirSync(OUT, { recursive: true });
|
|
21
|
+
for (const [name, selection] of Object.entries(packages)) {
|
|
22
|
+
if (selection.type !== 'npm') continue;
|
|
23
|
+
const result = JSON.parse(capture(['npm', 'pack', name + '@' + selection.version, '--ignore-scripts', '--json', '--pack-destination', OUT], ROOT))[0];
|
|
24
|
+
renameSync(join(OUT, result.filename), archive(name));
|
|
25
|
+
}
|
|
26
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
27
|
+
if (existsSync('/run/secrets/github_token')) Object.assign(env, {
|
|
28
|
+
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'credential.helper',
|
|
29
|
+
GIT_CONFIG_VALUE_0: '!f() { printf "username=x-access-token\\npassword="; cat /run/secrets/github_token; printf "\\n"; }; f',
|
|
30
|
+
});
|
|
31
|
+
for (const source of recipes) {
|
|
32
|
+
if (!Object.values(packages).some(item => item.source === source)) continue;
|
|
33
|
+
const spec = CONFIG.sources[source], directory = join(SOURCE_ROOT, source);
|
|
34
|
+
mkdirSync(directory, { recursive: true });
|
|
35
|
+
const git = (...args) => execFileSync('git', args, { cwd: directory, env, stdio: ['inherit', 'inherit', 'inherit', ...inheritedLock] });
|
|
36
|
+
git('init', '-q'); git('remote', 'add', 'origin', spec.url);
|
|
37
|
+
git('fetch', 'origin', spec.commit); git('checkout', '--detach', 'FETCH_HEAD');
|
|
38
|
+
if (capture(['git', 'rev-parse', 'HEAD'], directory).trim() !== spec.commit) throw Error(`${source}: checkout does not match selected commit`);
|
|
39
|
+
git('submodule', 'update', '--init', '--recursive', '--depth=1');
|
|
40
|
+
run([process.execPath, fileURLToPath(new URL(`build-${source}.mjs`, import.meta.url))], ROOT);
|
|
41
|
+
}
|
|
42
|
+
for (const [name, selection] of Object.entries(packages)) {
|
|
43
|
+
const packed = manifest(archive(name));
|
|
44
|
+
if (packed.name !== name || (selection.type === 'npm' && packed.version !== selection.version)) throw Error(`Packed output does not match selection: ${name}`);
|
|
45
|
+
}
|
|
46
|
+
writeFileSync(join(ROOT, 'package.json'), JSON.stringify({
|
|
47
|
+
name: 'ours-container-runtime', version: '0.1.0', private: true,
|
|
48
|
+
dependencies: Object.fromEntries(Object.keys(packages).map(name => [name, 'file:' + relative(ROOT, archive(name))])),
|
|
49
|
+
}, null, 2) + '\n');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Finalize freshly installed runtime records; never migrate historic builds here. */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { createBuildContext, CONTEXT } from '../maintenance/build-context.mjs';
|
|
7
|
+
export function finalizeBuild(root) {
|
|
8
|
+
if (fs.realpathSync(root) !== root) throw new Error('Build root must be canonical');
|
|
9
|
+
try { fs.lstatSync(join(root, CONTEXT)); throw new Error('Existing context cannot be regenerated'); }
|
|
10
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
11
|
+
const protect = (path, directory = false) => {
|
|
12
|
+
const st = fs.lstatSync(path);
|
|
13
|
+
if (!(directory ? st.isDirectory() : st.isFile()) || st.uid !== process.getuid() || (!directory && st.nlink !== 1) || fs.realpathSync(path) !== path) throw new Error('Unsafe owned build input: ' + path);
|
|
14
|
+
fs.chmodSync(path, directory ? 0o700 : 0o600);
|
|
15
|
+
};
|
|
16
|
+
for (const path of [root, join(root, 'docker'), join(root, 'docker/vendor')]) protect(path, true);
|
|
17
|
+
for (const name of ['package.json', 'package-lock.json']) protect(join(root, name));
|
|
18
|
+
const manifest = JSON.parse(fs.readFileSync(join(root, 'package.json')));
|
|
19
|
+
for (const spec of Object.values(manifest.dependencies ?? {})) {
|
|
20
|
+
if (typeof spec !== 'string' || !/^file:docker\/vendor\/ours\.network-[a-z-]+\.tgz$/.test(spec)) throw new Error('Unexpected installer vendor reference');
|
|
21
|
+
protect(join(root, spec.slice(5)));
|
|
22
|
+
}
|
|
23
|
+
const tree = execFileSync('npm', ['ls', '--omit=dev', '--all', '--json'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
24
|
+
JSON.parse(tree);
|
|
25
|
+
const path = join(root, 'dependency-tree.json');
|
|
26
|
+
// Fresh finalization may not overwrite a retained tree or follow a symlink.
|
|
27
|
+
fs.writeFileSync(path, tree, { flag: 'wx', mode: 0o600 });
|
|
28
|
+
fs.chmodSync(path, 0o600);
|
|
29
|
+
return createBuildContext(root);
|
|
30
|
+
}
|
|
31
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) finalizeBuild(resolve(process.env.OURS_BUILD_ROOT || '/opt/ours'));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# State maintenance
|
|
2
|
+
|
|
3
|
+
The installer coordinates stopped server components and invokes the same Node.js
|
|
4
|
+
maintenance implementation in package and Docker installations. Package mode uses
|
|
5
|
+
the installed `ours-install` assets and dependencies. Docker uses a separate
|
|
6
|
+
maintenance image with dependencies taken from the installer's `package.json`.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
ours-install server backup server before-change --state-dir /path/to/installation
|
|
10
|
+
ours-install server restore server before-change --state-dir /path/to/installation
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Backup and restore support the full server or an addressed daemon, Telegram,
|
|
14
|
+
Cowork or Messenger domain. Daemon archives include MCP preferences. Daemon
|
|
15
|
+
restore/reset replaces both trees together and preserves other component data
|
|
16
|
+
and current credentials. Addressed reset requires `--confirm`; full-server reset
|
|
17
|
+
is not supported. Managed legacy installations convert to the shared layout
|
|
18
|
+
through the installer before ordinary startup; plain script calls are not a
|
|
19
|
+
substitute for that coordinated conversion.
|
|
20
|
+
|
|
21
|
+
| File | Responsibility |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `state-operation.mjs` | Validates the selected state, stages replacements and invokes owning package commands to retain current authority |
|
|
24
|
+
| `state-archive.mjs` | Creates, validates and extracts format-1 archives, preserving private permissions and timestamps |
|
|
25
|
+
| `state-native.mjs` | Provides the existing OS file-lock and atomic directory operations through Koffi |
|
|
26
|
+
| `docker-layout-conversion.mjs` | Stages, validates and publishes the managed legacy Docker state layout under installer coordination |
|
|
27
|
+
|
|
28
|
+
Archives are stored in `storage/backups`, outside `storage/state` but on the same
|
|
29
|
+
storage volume. Removing that volume removes its backups too. Restore creates a
|
|
30
|
+
backup before replacing state. Compatibility across arbitrary builds or
|
|
31
|
+
instances is not established; `--compatible` records an operator's explicit
|
|
32
|
+
compatibility decision. A failed operation leaves affected services stopped.
|
|
33
|
+
|
|
34
|
+
Maintenance uses the installer's Node.js runtime and npm dependencies. There is
|
|
35
|
+
no Python fallback or separate operator shell script.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Verified installer vendor bindings. Context hashes are consistency, not signatures. */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { join, resolve, relative, isAbsolute } from 'node:path';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { semanticRecordEqual } from './provenance-compare.mjs';
|
|
7
|
+
|
|
8
|
+
export const BASE_RECORDS = ['package-lock.json', 'dependency-tree.json'];
|
|
9
|
+
export const CONTEXT = 'build-context.json';
|
|
10
|
+
const admitted = new WeakMap();
|
|
11
|
+
const packages = new Set(['sdk', 'cli', 'mcp', 'tg-connector', 'cowork', 'messenger-server', 'fleet', 'codex', 'claude-code', 'install'].map(n => '@ours.network/' + n));
|
|
12
|
+
const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding);
|
|
13
|
+
const fail = text => { throw new Error(`Build context: ${text}`); };
|
|
14
|
+
const keys = (v, expected) => v && !Array.isArray(v) && typeof v === 'object' && Object.keys(v).sort().join('\0') === [...expected].sort().join('\0');
|
|
15
|
+
const vendorPath = name => `docker/vendor/ours.network-${name.slice('@ours.network/'.length)}.tgz`;
|
|
16
|
+
const exists = path => { try { fs.lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
|
|
17
|
+
export const recordNames = records => Object.hasOwn(records, CONTEXT) ? [...BASE_RECORDS, CONTEXT] : [...BASE_RECORDS];
|
|
18
|
+
|
|
19
|
+
function view(records) {
|
|
20
|
+
if (!keys(records, recordNames(records)) || !Object.values(records).every(Buffer.isBuffer)) fail('invalid record set');
|
|
21
|
+
if (!Object.hasOwn(records, CONTEXT)) return null;
|
|
22
|
+
const bytes = records[CONTEXT];
|
|
23
|
+
let c, lock, tree;
|
|
24
|
+
try { c = JSON.parse(bytes); lock = JSON.parse(records[BASE_RECORDS[0]]); tree = JSON.parse(records[BASE_RECORDS[1]]); }
|
|
25
|
+
catch { fail('invalid JSON'); }
|
|
26
|
+
// The creator owns this encoding: reject duplicate keys and alternate ambiguous JSON.
|
|
27
|
+
if (!bytes.equals(Buffer.from(JSON.stringify(c, null, 2) + '\n'))) fail('noncanonical context JSON');
|
|
28
|
+
for (const name of BASE_RECORDS) {
|
|
29
|
+
if (!semanticRecordEqual(name, records[name], Buffer.concat([records[name], Buffer.from('\n')]))) fail('unsupported build record shape');
|
|
30
|
+
}
|
|
31
|
+
if (!keys(c, ['schema', 'buildRoot', 'records', 'vendors']) || c.schema !== 1) fail('unsupported schema');
|
|
32
|
+
if (typeof c.buildRoot !== 'string' || !isAbsolute(c.buildRoot) || resolve(c.buildRoot) !== c.buildRoot || c.buildRoot.includes('\0')) fail('invalid build root');
|
|
33
|
+
if (!keys(c.records, BASE_RECORDS)) fail('invalid record digests');
|
|
34
|
+
for (const name of BASE_RECORDS) if (c.records[name] !== digest(records[name])) fail('record digest mismatch');
|
|
35
|
+
if (!Array.isArray(c.vendors) || !c.vendors.length) fail('missing vendor bindings');
|
|
36
|
+
const names = c.vendors.map(v => v?.name);
|
|
37
|
+
if (new Set(names).size !== names.length || names.join('\0') !== [...names].sort().join('\0')) fail('noncanonical vendor set');
|
|
38
|
+
const deps = lock?.packages?.['']?.dependencies;
|
|
39
|
+
if (lock.lockfileVersion !== 3 || !keys(deps, names) || !keys(tree.dependencies, names)) fail('vendor selection differs from records');
|
|
40
|
+
for (const v of c.vendors) {
|
|
41
|
+
if (!keys(v, ['name', 'version', 'relativePath', 'integrity']) || !packages.has(v.name) || v.relativePath !== vendorPath(v.name)) fail('invalid vendor binding');
|
|
42
|
+
if (typeof v.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v.version) || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(v.integrity)) fail('invalid vendor version/integrity');
|
|
43
|
+
const entry = lock.packages['node_modules/' + v.name], node = tree.dependencies[v.name];
|
|
44
|
+
if (!entry || entry.version !== v.version || entry.integrity !== v.integrity || entry.resolved !== 'file:' + v.relativePath || deps[v.name] !== 'file:' + v.relativePath || entry.link) fail('vendor lock binding mismatch');
|
|
45
|
+
if (!node || node.version !== v.version || node.resolved !== 'file:' + join(c.buildRoot, v.relativePath)) fail('vendor tree binding mismatch');
|
|
46
|
+
node.resolved = 'file:' + v.relativePath;
|
|
47
|
+
}
|
|
48
|
+
return { vendors: c.vendors, tree: Buffer.from(JSON.stringify(tree)) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateBuildRecordSet(records) { view(records); }
|
|
52
|
+
|
|
53
|
+
function safeFile(path, privateFile = false) {
|
|
54
|
+
const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
55
|
+
try {
|
|
56
|
+
const st = fs.fstatSync(fd);
|
|
57
|
+
if (!st.isFile() || st.nlink !== 1 || ![process.getuid(), 0].includes(st.uid) || (st.mode & 0o7022) || (privateFile && (st.uid !== process.getuid() || st.gid !== process.getgid() || (st.mode & 0o777) !== 0o600))) fail('unsafe regular file ownership/permissions');
|
|
58
|
+
return fs.readFileSync(fd);
|
|
59
|
+
} finally { fs.closeSync(fd); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Only protected filesystem records enter the comparison API. Missing context is legacy. */
|
|
63
|
+
export function readBuildRecords(directory, { privateFiles = false, marker = false } = {}) {
|
|
64
|
+
const st = fs.lstatSync(directory);
|
|
65
|
+
if (!st.isDirectory() || fs.realpathSync(directory) !== directory || ![process.getuid(), 0].includes(st.uid) || (st.mode & 0o7022)) fail('unsafe record directory');
|
|
66
|
+
const names = [...BASE_RECORDS, ...(exists(join(directory, CONTEXT)) ? [CONTEXT] : [])];
|
|
67
|
+
if (marker && fs.readdirSync(directory).sort().join('\0') !== [...names].sort().join('\0')) fail('mixed or incomplete marker records');
|
|
68
|
+
const records = Object.fromEntries(names.map(name => [name, safeFile(join(directory, name), privateFiles)]));
|
|
69
|
+
const normalized = view(records);
|
|
70
|
+
// Snapshot privately as well as exposing buffers: caller mutation never alters admitted evidence.
|
|
71
|
+
const handle = {};
|
|
72
|
+
for (const [name, bytes] of Object.entries(records)) Object.defineProperty(handle, name, { enumerable: true, get: () => Buffer.from(bytes) });
|
|
73
|
+
admitted.set(handle, { records, normalized });
|
|
74
|
+
return Object.freeze(handle);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function equalBuildRecords(a, b) {
|
|
78
|
+
const x = admitted.get(a), y = admitted.get(b);
|
|
79
|
+
if (!x || !y) fail('comparison requires verified record handles');
|
|
80
|
+
if (!semanticRecordEqual(BASE_RECORDS[0], x.records[BASE_RECORDS[0]], y.records[BASE_RECORDS[0]])) return false;
|
|
81
|
+
if (x.normalized && y.normalized) {
|
|
82
|
+
if (JSON.stringify(x.normalized.vendors) !== JSON.stringify(y.normalized.vendors)) return false;
|
|
83
|
+
return semanticRecordEqual(BASE_RECORDS[1], x.normalized.tree, y.normalized.tree);
|
|
84
|
+
}
|
|
85
|
+
// No one-sided normalization/adoption of historic records.
|
|
86
|
+
return semanticRecordEqual(BASE_RECORDS[1], x.records[BASE_RECORDS[1]], y.records[BASE_RECORDS[1]]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createBuildContext(root) {
|
|
90
|
+
if (fs.realpathSync(root) !== root || resolve(root) !== root) fail('build root must be canonical');
|
|
91
|
+
const destination = join(root, CONTEXT);
|
|
92
|
+
if (exists(destination)) fail('context already exists; never regenerate historic evidence');
|
|
93
|
+
const records = readBuildRecords(root);
|
|
94
|
+
const lock = JSON.parse(records['package-lock.json']);
|
|
95
|
+
const deps = lock?.packages?.['']?.dependencies;
|
|
96
|
+
if (!deps || Array.isArray(deps) || typeof deps !== 'object') fail('missing vendor dependencies');
|
|
97
|
+
const manifest = JSON.parse(safeFile(join(root, 'package.json')));
|
|
98
|
+
if (manifest.name !== lock.name || manifest.version !== lock.version || !keys(manifest.dependencies, Object.keys(deps)) || Object.keys(deps).some(n => manifest.dependencies[n] !== deps[n])) fail('runtime manifest differs from lock selection');
|
|
99
|
+
const vendors = [];
|
|
100
|
+
for (const name of Object.keys(deps).sort()) {
|
|
101
|
+
if (!packages.has(name)) fail('unknown vendor package');
|
|
102
|
+
const relativePath = vendorPath(name), path = join(root, relativePath);
|
|
103
|
+
let part = root;
|
|
104
|
+
for (const component of relativePath.split('/')) {
|
|
105
|
+
part = join(part, component); const st = fs.lstatSync(part);
|
|
106
|
+
if (st.isSymbolicLink() || ![process.getuid(), 0].includes(st.uid) || fs.realpathSync(part) !== part || (st.mode & 0o7022)) fail('vendor path is not canonical or is writable by others');
|
|
107
|
+
}
|
|
108
|
+
if (relative(root, path).startsWith('..')) fail('vendor path escaped root');
|
|
109
|
+
const bytes = safeFile(path), integrity = 'sha512-' + digest(bytes, 'sha512', 'base64');
|
|
110
|
+
const entry = lock.packages['node_modules/' + name];
|
|
111
|
+
if (!entry || entry.integrity !== integrity) fail('tar integrity mismatch');
|
|
112
|
+
const before = fs.statSync(path);
|
|
113
|
+
const metadata = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--ignore-scripts', '--json', path], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
114
|
+
const after = fs.statSync(path);
|
|
115
|
+
if (before.dev !== after.dev || before.ino !== after.ino || !safeFile(path).equals(bytes)) fail('tar changed during verification');
|
|
116
|
+
if (metadata.length !== 1 || metadata[0].name !== name || metadata[0].version !== entry.version) fail('tar package identity mismatch');
|
|
117
|
+
vendors.push({ name, version: entry.version, relativePath, integrity });
|
|
118
|
+
}
|
|
119
|
+
const context = { schema: 1, buildRoot: root, records: Object.fromEntries(BASE_RECORDS.map(n => [n, digest(records[n])])), vendors };
|
|
120
|
+
const bytes = Buffer.from(JSON.stringify(context, null, 2) + '\n');
|
|
121
|
+
view({ ...records, [CONTEXT]: bytes });
|
|
122
|
+
for (const name of BASE_RECORDS) if (!safeFile(join(root, name)).equals(records[name])) fail('records changed during verification');
|
|
123
|
+
const sync = path => { const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
|
|
124
|
+
for (const name of BASE_RECORDS) sync(join(root, name));
|
|
125
|
+
const temp = join(root, '.' + CONTEXT + '-' + randomUUID());
|
|
126
|
+
try {
|
|
127
|
+
fs.writeFileSync(temp, bytes, { flag: 'wx', mode: 0o600 });
|
|
128
|
+
fs.chmodSync(temp, 0o600);
|
|
129
|
+
sync(temp);
|
|
130
|
+
// link publishes atomically without replacement; unlink the private temp immediately.
|
|
131
|
+
fs.linkSync(temp, destination);
|
|
132
|
+
} finally { if (exists(temp)) fs.unlinkSync(temp); }
|
|
133
|
+
sync(root);
|
|
134
|
+
return readBuildRecords(root);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Fresh marker publication only; an existing generation requires explicit maintenance. */
|
|
138
|
+
export function initializeBuildMarker(marker, records) {
|
|
139
|
+
const selected = admitted.get(records);
|
|
140
|
+
if (!selected) fail('marker initialization requires verified record handles');
|
|
141
|
+
if (exists(marker) && fs.readdirSync(marker).length) {
|
|
142
|
+
const current = readBuildRecords(marker, { privateFiles: true, marker: true });
|
|
143
|
+
if (recordNames(current).length !== recordNames(records).length || recordNames(records).some(n => !current[n].equals(records[n]))) fail('existing state provenance differs; use reviewed update --compatible');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const parent = resolve(marker, '..');
|
|
147
|
+
const st = fs.lstatSync(parent);
|
|
148
|
+
if (!st.isDirectory() || fs.realpathSync(parent) !== parent || st.uid !== process.getuid() || (st.mode & 0o7077)) fail('unsafe marker parent');
|
|
149
|
+
if (exists(marker)) {
|
|
150
|
+
const st = fs.lstatSync(marker);
|
|
151
|
+
if (!st.isDirectory() || fs.realpathSync(marker) !== marker || st.uid !== process.getuid() || (st.mode & 0o7077)) fail('unsafe marker directory');
|
|
152
|
+
}
|
|
153
|
+
const stage = fs.mkdtempSync(join(parent, '.provenance-'));
|
|
154
|
+
fs.chmodSync(stage, 0o700);
|
|
155
|
+
const sync = path => { const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
|
|
156
|
+
try {
|
|
157
|
+
for (const [name, bytes] of Object.entries(selected.records)) {
|
|
158
|
+
const path = join(stage, name); fs.writeFileSync(path, bytes, { flag: 'wx', mode: 0o600 }); fs.chmodSync(path, 0o600); sync(path);
|
|
159
|
+
}
|
|
160
|
+
sync(stage); fs.renameSync(stage, marker); sync(parent);
|
|
161
|
+
} finally { if (exists(stage)) fs.rmSync(stage, { recursive: true }); }
|
|
162
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/** Stopped legacy Docker volumes mapped into the shared server-state archive layout. */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { copyPrivateTree, scanSource, createArchive, validateArchive } from './state-archive.mjs';
|
|
7
|
+
import { publishNoReplace } from './state-native.mjs';
|
|
8
|
+
|
|
9
|
+
const COMPONENTS = ['daemon', 'telegram', 'cowork', 'messenger'];
|
|
10
|
+
import { recordNames, readBuildRecords, validateBuildRecordSet } from './build-context.mjs';
|
|
11
|
+
const DAEMON_STATE = '/var/lib/ours';
|
|
12
|
+
const MCP_STATE = '/var/lib/ours-mcp';
|
|
13
|
+
const LEGACY_MCP_CHILD = '.mcp';
|
|
14
|
+
|
|
15
|
+
function privateBytes(path, ownership) {
|
|
16
|
+
const stat = fs.lstatSync(path);
|
|
17
|
+
if (!stat.isFile() || stat.uid !== ownership.uid || stat.gid !== ownership.gid || (stat.mode & 0o7777) !== 0o600) {
|
|
18
|
+
throw new Error(`Unsafe Docker conversion source file: ${path}`);
|
|
19
|
+
}
|
|
20
|
+
return fs.readFileSync(path);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function objectAt(path, ownership) {
|
|
24
|
+
const value = JSON.parse(privateBytes(path, ownership));
|
|
25
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') {
|
|
26
|
+
throw new Error('Docker conversion configuration must be an object');
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateProfile(profile, instanceId) {
|
|
32
|
+
if (profile?.endpoint !== 'http://127.0.0.1:3050'
|
|
33
|
+
|| profile.expectedInstanceId !== instanceId
|
|
34
|
+
|| profile.credentialPath !== join(DAEMON_STATE, 'daemon-token')) {
|
|
35
|
+
throw new Error('MCP profile does not select this Docker source');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Source mounts and writer exclusion are selected and held by the installer. */
|
|
40
|
+
export async function stageDockerLayout(source, staging, options) {
|
|
41
|
+
if (resolve(source) !== source || fs.realpathSync(source) !== source
|
|
42
|
+
|| source === dirname(source) || resolve(staging) !== staging
|
|
43
|
+
|| fs.realpathSync(dirname(staging)) !== dirname(staging)
|
|
44
|
+
|| staging === source || staging.startsWith(source + '/') || source.startsWith(staging + '/')) {
|
|
45
|
+
throw new Error('Docker staging must be canonical and outside the source tree');
|
|
46
|
+
}
|
|
47
|
+
const daemon = join(source, 'daemon', 'data');
|
|
48
|
+
const config = objectAt(join(daemon, 'config.json'), options);
|
|
49
|
+
if (config.stateDir !== DAEMON_STATE || config.port !== 3050) {
|
|
50
|
+
throw new Error('Daemon authority source differs from the selected Docker layout');
|
|
51
|
+
}
|
|
52
|
+
if (config.networkMcp?.applicationConfigPath !== join(DAEMON_STATE, LEGACY_MCP_CHILD, 'config.json')) {
|
|
53
|
+
throw new Error('MCP configuration selects an external or conflicting source');
|
|
54
|
+
}
|
|
55
|
+
validateProfile(config.networkMcp.profile, options.instanceId);
|
|
56
|
+
validateProfile(objectAt(join(daemon, LEGACY_MCP_CHILD, 'profile.json'), options), options.instanceId);
|
|
57
|
+
validateBuildRecordSet(options.provenance);
|
|
58
|
+
for (const name of recordNames(options.provenance)) {
|
|
59
|
+
if (!Buffer.isBuffer(options.provenance?.[name])) throw new Error('Selected build provenance is required');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fs.mkdirSync(staging, { mode: 0o700 });
|
|
63
|
+
try {
|
|
64
|
+
for (const component of COMPONENTS) {
|
|
65
|
+
const data = join(source, component, 'data');
|
|
66
|
+
const marker = join(data, '.ours-provenance');
|
|
67
|
+
if (fs.readdirSync(marker).sort().join() !== recordNames(options.provenance).sort().join()) {
|
|
68
|
+
throw new Error(`Incomplete source ${component} provenance`);
|
|
69
|
+
}
|
|
70
|
+
for (const name of recordNames(options.provenance)) {
|
|
71
|
+
if (!privateBytes(join(marker, name), options).equals(options.provenance[name])) {
|
|
72
|
+
throw new Error(`Source ${component} differs from the selected build`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
copyPrivateTree(data, join(staging, component), options);
|
|
76
|
+
}
|
|
77
|
+
fs.renameSync(join(staging, 'daemon', LEGACY_MCP_CHILD), join(staging, 'mcp'));
|
|
78
|
+
fs.mkdirSync(join(staging, 'credentials'), { mode: 0o700 });
|
|
79
|
+
for (const component of COMPONENTS.filter(name => name !== 'daemon')) {
|
|
80
|
+
const bytes = privateBytes(join(source, `${component}-credential`, 'daemon-token'), options);
|
|
81
|
+
if (!bytes.length) throw new Error('Current managed credential is empty');
|
|
82
|
+
const destination = join(staging, 'credentials', component);
|
|
83
|
+
fs.mkdirSync(destination, { mode: 0o700 });
|
|
84
|
+
fs.writeFileSync(join(destination, 'daemon-token'), bytes, { mode: 0o600, flag: 'wx' });
|
|
85
|
+
}
|
|
86
|
+
scanSource(staging, options);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Bind only after the original payload has been archived and validated. */
|
|
94
|
+
export function bindDockerLayout(staging, options) {
|
|
95
|
+
const configPath = join(staging, 'daemon', 'config.json');
|
|
96
|
+
const config = objectAt(configPath, options);
|
|
97
|
+
validateProfile(config.networkMcp?.profile, options.instanceId);
|
|
98
|
+
config.networkMcp.applicationConfigPath = join(MCP_STATE, 'config.json');
|
|
99
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Offline validation also covers components intentionally left stopped. */
|
|
103
|
+
export function validateDockerLayout(tree, options) {
|
|
104
|
+
validateBuildRecordSet(options.provenance);
|
|
105
|
+
scanSource(tree, options);
|
|
106
|
+
for (const component of COMPONENTS) {
|
|
107
|
+
if (fs.readdirSync(join(tree, component, '.ours-provenance')).sort().join() !== recordNames(options.provenance).sort().join()) throw new Error('Mixed component provenance');
|
|
108
|
+
for (const name of recordNames(options.provenance)) {
|
|
109
|
+
if (!privateBytes(join(tree, component, '.ours-provenance', name), options).equals(options.provenance[name])) {
|
|
110
|
+
throw new Error(`Converted ${component} differs from the selected build`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const config = join(tree, component, 'config.json');
|
|
114
|
+
if (fs.existsSync(config)) objectAt(config, options);
|
|
115
|
+
}
|
|
116
|
+
const daemon = objectAt(join(tree, 'daemon/config.json'), options);
|
|
117
|
+
const profile = objectAt(join(tree, 'mcp/profile.json'), options);
|
|
118
|
+
validateProfile(profile, options.instanceId);
|
|
119
|
+
if (daemon.stateDir !== DAEMON_STATE || daemon.port !== 3050
|
|
120
|
+
|| daemon.networkMcp?.applicationConfigPath !== join(MCP_STATE, 'config.json')
|
|
121
|
+
|| JSON.stringify(daemon.networkMcp.profile) !== JSON.stringify(profile)
|
|
122
|
+
|| objectAt(join(tree, 'cowork/config.json'), options).stateDir !== '/var/lib/ours-cowork') {
|
|
123
|
+
throw new Error('Converted Docker deployment configuration is inconsistent');
|
|
124
|
+
}
|
|
125
|
+
if (fs.existsSync(join(tree, 'mcp/config.json'))) objectAt(join(tree, 'mcp/config.json'), options);
|
|
126
|
+
for (const path of ['daemon/daemon-token', ...COMPONENTS.filter(name => name !== 'daemon').map(name => `credentials/${name}/daemon-token`)]) {
|
|
127
|
+
if (!privateBytes(join(tree, path), options).length) throw new Error('Converted managed credential is empty');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Requires a schema-1 pending record reserving this volume and excluded writers. */
|
|
132
|
+
export async function prepareDockerLayout(source, storage, label, options) {
|
|
133
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(label)) throw new Error('Invalid conversion backup label');
|
|
134
|
+
if (resolve(storage) !== storage || fs.realpathSync(storage) !== storage
|
|
135
|
+
|| resolve(source) !== source || fs.realpathSync(source) !== source
|
|
136
|
+
|| storage === source || storage.startsWith(source + '/') || source.startsWith(storage + '/')) {
|
|
137
|
+
throw new Error('Conversion storage must be canonical and outside the source');
|
|
138
|
+
}
|
|
139
|
+
const privateDirectory = path => {
|
|
140
|
+
const stat = fs.lstatSync(path);
|
|
141
|
+
if (!stat.isDirectory() || stat.uid !== options.uid || stat.gid !== options.gid
|
|
142
|
+
|| (stat.mode & 0o7777) !== 0o700) throw new Error(`Unsafe conversion directory: ${path}`);
|
|
143
|
+
};
|
|
144
|
+
privateDirectory(storage);
|
|
145
|
+
const maintenance = join(storage, '.maintenance');
|
|
146
|
+
const backups = join(storage, 'backups');
|
|
147
|
+
for (const path of [maintenance, backups]) {
|
|
148
|
+
if (!fs.existsSync(path)) fs.mkdirSync(path, { mode: 0o700 });
|
|
149
|
+
privateDirectory(path);
|
|
150
|
+
}
|
|
151
|
+
const staging = join(maintenance, 'layout-conversion');
|
|
152
|
+
const target = join(storage, 'state');
|
|
153
|
+
const removeOwnedTree = path => {
|
|
154
|
+
if (!fs.existsSync(path)) return;
|
|
155
|
+
scanSource(path, options);
|
|
156
|
+
fs.rmSync(path, { recursive: true });
|
|
157
|
+
};
|
|
158
|
+
removeOwnedTree(staging);
|
|
159
|
+
try {
|
|
160
|
+
await stageDockerLayout(source, staging, options);
|
|
161
|
+
const archiveOptions = { ...options, domain: 'server' };
|
|
162
|
+
const backup = join(backups, label);
|
|
163
|
+
if (fs.existsSync(backup)) await validateArchive(backup, archiveOptions);
|
|
164
|
+
else await createArchive(staging, backup, archiveOptions);
|
|
165
|
+
// The source remains mounted at its original runtime path for SDK selection.
|
|
166
|
+
execFileSync(options.cli, ['config', 'access-retain', '--config', options.configPath,
|
|
167
|
+
'--target-state-dir', join(staging, 'daemon'), '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
168
|
+
bindDockerLayout(staging, options);
|
|
169
|
+
validateDockerLayout(staging, options);
|
|
170
|
+
removeOwnedTree(target);
|
|
171
|
+
publishNoReplace(staging, target);
|
|
172
|
+
} finally {
|
|
173
|
+
removeOwnedTree(staging);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Remove only retired working data; volume-root archives remain untouched. */
|
|
178
|
+
export function cleanupDockerSource(source, options) {
|
|
179
|
+
const empty = [];
|
|
180
|
+
const aliases = [...COMPONENTS, ...COMPONENTS.filter(name => name !== 'daemon').map(name => `${name}-credential`)];
|
|
181
|
+
for (const alias of aliases) {
|
|
182
|
+
const root = join(source, alias);
|
|
183
|
+
if (!fs.existsSync(root)) continue;
|
|
184
|
+
const stat = fs.lstatSync(root);
|
|
185
|
+
if (!stat.isDirectory() || stat.uid !== options.uid || stat.gid !== options.gid
|
|
186
|
+
|| (stat.mode & 0o7777) !== 0o700) throw new Error(`Unsafe retired volume root: ${alias}`);
|
|
187
|
+
const credential = alias.endsWith('-credential');
|
|
188
|
+
const path = join(root, credential ? 'daemon-token' : 'data');
|
|
189
|
+
if (fs.existsSync(path)) {
|
|
190
|
+
if (credential) { privateBytes(path, options); fs.unlinkSync(path); }
|
|
191
|
+
else { scanSource(path, options); fs.rmSync(path, { recursive: true }); }
|
|
192
|
+
}
|
|
193
|
+
if (fs.readdirSync(root).length === 0) empty.push(alias);
|
|
194
|
+
}
|
|
195
|
+
return empty;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Internal one-shot container entrypoint; the installer owns writer exclusion. */
|
|
199
|
+
export async function runDockerLayoutCommand(argv, env = process.env) {
|
|
200
|
+
const [operation, label] = argv;
|
|
201
|
+
if (!((operation === 'prepare' && argv.length === 2)
|
|
202
|
+
|| (['validate', 'cleanup'].includes(operation) && argv.length === 1))) {
|
|
203
|
+
throw new Error('usage: prepare BACKUP_LABEL | validate | cleanup');
|
|
204
|
+
}
|
|
205
|
+
const storage = env.OURS_STATE_ROOT || '/storage';
|
|
206
|
+
const source = env.OURS_CONVERSION_SOURCE || '/source';
|
|
207
|
+
const build = env.OURS_BUILD_ROOT || '/opt/ours';
|
|
208
|
+
const options = {
|
|
209
|
+
uid: process.getuid(), gid: process.getgid(), instanceId: env.OURS_DAEMON_ID,
|
|
210
|
+
cli: env.OURS_CLI_PATH || '/opt/ours/node_modules/.bin/ours',
|
|
211
|
+
configPath: env.OURS_DAEMON_CONFIG || '/var/lib/ours/config.json',
|
|
212
|
+
provenance: readBuildRecords(build),
|
|
213
|
+
};
|
|
214
|
+
if (!options.uid || !options.gid) throw new Error('Conversion requires a non-root owner');
|
|
215
|
+
if (operation === 'cleanup') {
|
|
216
|
+
console.log(JSON.stringify({ emptyVolumes: cleanupDockerSource(source, options) }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
execFileSync(env.OURS_COWORK_CLI_PATH || '/opt/ours/node_modules/.bin/ours-cowork', ['--json', 'prepare-backup'], {
|
|
221
|
+
env: { ...env, OURS_COWORK_CONFIG: env.OURS_COWORK_CONFIG || '/var/lib/ours-cowork/config.json',
|
|
222
|
+
OURS_COWORK_STATE_DIR: env.OURS_COWORK_STATE_DIR || '/var/lib/ours-cowork' },
|
|
223
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
224
|
+
});
|
|
225
|
+
} catch {
|
|
226
|
+
throw new Error('Cowork preparation failed; Docker conversion was not continued');
|
|
227
|
+
}
|
|
228
|
+
if (operation === 'prepare') await prepareDockerLayout(source, storage, label, options);
|
|
229
|
+
else validateDockerLayout(join(storage, 'state'), options);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
233
|
+
runDockerLayoutCommand(process.argv.slice(2)).catch(error => {
|
|
234
|
+
// Owner process errors may contain private output; keep it out of installer logs.
|
|
235
|
+
console.error(error?.status !== undefined ? 'Owner command failed during Docker conversion' : error.message);
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
});
|
|
238
|
+
}
|