@ours.network/install 1.1.1 → 1.2.0-nightly.1
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,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** One-shot maintenance for a selected domain; the installer excludes administrative writers. */
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import { resolve, dirname, basename, join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { createArchive, extractArchive, validateArchive, scanSource, copyPrivateTree } from './state-archive.mjs';
|
|
9
|
+
import { exchange, tryLock, setMtimeNs } from './state-native.mjs';
|
|
10
|
+
import { recordNames, readBuildRecords, equalBuildRecords, initializeBuildMarker } from './build-context.mjs';
|
|
11
|
+
|
|
12
|
+
const PROVENANCE = '.ours-provenance';
|
|
13
|
+
const APPLICATIONS = ['daemon', 'telegram', 'cowork', 'messenger'];
|
|
14
|
+
const exists = path => { try { fs.lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
|
|
15
|
+
const canonical = path => exists(path) ? fs.realpathSync(path) : join(canonical(dirname(path)), basename(path));
|
|
16
|
+
const sameRecords = (a, b) => recordNames(a).length === recordNames(b).length && recordNames(a).every(name => a[name].equals(b[name]));
|
|
17
|
+
const semanticSameRecords = equalBuildRecords;
|
|
18
|
+
const sameInode = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
19
|
+
const fail = message => { throw new Error(message); };
|
|
20
|
+
|
|
21
|
+
export async function runStateOperation(argv, env = process.env, checkpoints = {}) {
|
|
22
|
+
const selected = name => {
|
|
23
|
+
const value = env[name];
|
|
24
|
+
if (!value || resolve(value) !== value || canonical(value) !== value) fail(`${name} must select an absolute canonical path`);
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
const state = selected('OURS_STATE_ROOT'), live = selected('OURS_LIVE_ROOT'), build = selected('OURS_BUILD_ROOT');
|
|
28
|
+
if ([state, build].some(path => path === live || path.startsWith(live + '/'))) fail('maintenance and build records must be outside live application state');
|
|
29
|
+
const backups = join(state, 'backups'), maintenance = join(state, '.maintenance');
|
|
30
|
+
const domain = argv[1];
|
|
31
|
+
if (![...APPLICATIONS, 'server'].includes(domain)) fail('select server, daemon, telegram, cowork or messenger');
|
|
32
|
+
if (Object.hasOwn(env, 'OURS_STATE_DOMAIN') && env.OURS_STATE_DOMAIN !== domain) fail('selected volume domain does not match operation');
|
|
33
|
+
const compatible = argv.at(-1) === '--compatible';
|
|
34
|
+
if (compatible) argv = argv.slice(0, -1);
|
|
35
|
+
const operation = argv[0];
|
|
36
|
+
const paired = domain === 'daemon' && ['backup', 'restore', 'reset'].includes(operation);
|
|
37
|
+
const commonTree = domain === 'server' || paired;
|
|
38
|
+
if (compatible && !['restore', 'update'].includes(operation)) fail('compatibility attestation applies only to update or restore');
|
|
39
|
+
if (domain === 'server' && !['backup', 'restore', 'update', 'rebuild'].includes(operation)) fail('full-server scope supports only backup, restore, update and rebuild');
|
|
40
|
+
if (operation === 'rebuild' && domain !== 'server') fail('rebuild selects the complete server');
|
|
41
|
+
const adopt = operation === 'init' && argv[2] === '--adopt-existing';
|
|
42
|
+
const valid = (['init', 'update', 'rebuild'].includes(operation) && argv.length === 2) || (adopt && argv.length === 3) || (['backup', 'restore'].includes(operation) && argv.length === 3) || (operation === 'reset' && argv.length === 3 && argv[2] === '--confirm');
|
|
43
|
+
if (!valid) fail('usage: init DOMAIN | backup DOMAIN LABEL | restore DOMAIN LABEL | reset DOMAIN --confirm | update DOMAIN');
|
|
44
|
+
const uid = process.getuid(), gid = process.getgid();
|
|
45
|
+
if (!uid || !gid) fail('state maintenance requires a non-root UID and GID');
|
|
46
|
+
const options = records => ({ domain, uid, gid, provenance: records });
|
|
47
|
+
const labelPath = label => {
|
|
48
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}(?![\s\S])/.test(label)) fail('backup label must be a plain basename');
|
|
49
|
+
return join(backups, label);
|
|
50
|
+
};
|
|
51
|
+
if (['backup', 'restore'].includes(operation)) labelPath(argv[2]);
|
|
52
|
+
function privateStat(path, directory, exact) {
|
|
53
|
+
const st = fs.lstatSync(path), mode = st.mode & 0o7777;
|
|
54
|
+
if (!(directory ? st.isDirectory() : st.isFile()) || st.uid !== uid || st.gid !== gid || (mode & ~0o700) || (exact !== undefined && mode !== exact)) fail(`unsafe ownership, type or permissions: ${path}`);
|
|
55
|
+
return st;
|
|
56
|
+
}
|
|
57
|
+
function mkdir(path) {
|
|
58
|
+
if (exists(path)) privateStat(path, true, 0o700);
|
|
59
|
+
else { fs.mkdirSync(path, { mode: 0o700 }); fs.chmodSync(path, 0o700); }
|
|
60
|
+
}
|
|
61
|
+
function layout() { privateStat(state, true, 0o700); mkdir(backups); mkdir(maintenance); }
|
|
62
|
+
function readRecords(path, marker = true) {
|
|
63
|
+
privateStat(path, true, 0o700);
|
|
64
|
+
return readBuildRecords(path, { privateFiles: true, marker });
|
|
65
|
+
}
|
|
66
|
+
function writeRecord(path, bytes) {
|
|
67
|
+
fs.writeFileSync(path, bytes, { flag: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, mode: 0o600 });
|
|
68
|
+
fs.chmodSync(path, 0o600);
|
|
69
|
+
}
|
|
70
|
+
function owner(command, extraEnv = {}) {
|
|
71
|
+
try { execFileSync(command[0], command.slice(1), { env: { ...env, ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe', ...(env.OURS_INSTALLER_LOCK_FD === '3' ? [3] : [])] }); }
|
|
72
|
+
catch { fail(`owning package ${command[1]} operation failed`); }
|
|
73
|
+
}
|
|
74
|
+
function prepare() {
|
|
75
|
+
if (!['server', 'cowork'].includes(domain) && !paired) return;
|
|
76
|
+
if (!env.OURS_COWORK_CLI_PATH || !env.OURS_COWORK_CONFIG) fail('Cowork maintenance requires its selected executable and configuration');
|
|
77
|
+
owner([env.OURS_COWORK_CLI_PATH, '--json', 'prepare-backup'], { OURS_COWORK_CONFIG: env.OURS_COWORK_CONFIG });
|
|
78
|
+
}
|
|
79
|
+
function liveDescriptor() {
|
|
80
|
+
privateStat(live, true, 0o700);
|
|
81
|
+
const fd = fs.openSync(live, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
82
|
+
try {
|
|
83
|
+
if (fs.realpathSync(live) !== live || !sameInode(fs.fstatSync(fd), fs.statSync(live))) fail('domain state path changed or is not canonical');
|
|
84
|
+
return fd;
|
|
85
|
+
} catch (e) { fs.closeSync(fd); throw e; }
|
|
86
|
+
}
|
|
87
|
+
function validateTree(tree, records) {
|
|
88
|
+
const roots = domain === 'server' ? APPLICATIONS.map(name => join(tree, name)) : [tree];
|
|
89
|
+
for (const root of roots) if (!sameRecords(readRecords(join(root, PROVENANCE)), records)) fail('restored component provenance does not match the archive build');
|
|
90
|
+
if (domain === 'server') for (const name of ['mcp', 'credentials']) privateStat(join(tree, name), true, 0o700);
|
|
91
|
+
if (paired) privateStat(join(tree, '.mcp'), true, 0o700);
|
|
92
|
+
return roots;
|
|
93
|
+
}
|
|
94
|
+
function retarget(tree, source, target) {
|
|
95
|
+
// Call only on private staging. Publish the entire generation at exchange.
|
|
96
|
+
for (const root of validateTree(tree, source)) {
|
|
97
|
+
const marker = join(root, PROVENANCE);
|
|
98
|
+
fs.rmSync(marker, { recursive: true }); mkdir(marker);
|
|
99
|
+
for (const name of recordNames(target)) writeRecord(join(marker, name), target[name]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function copyTree(source, destination) {
|
|
103
|
+
copyPrivateTree(source, destination, { uid, gid });
|
|
104
|
+
}
|
|
105
|
+
async function backupTo(path, records) {
|
|
106
|
+
if (!paired) return createArchive(live, path, options(records));
|
|
107
|
+
const payload = join(maintenance, `daemon-payload-${randomBytes(12).toString('hex')}`);
|
|
108
|
+
try {
|
|
109
|
+
if (exists(join(live, 'daemon/.mcp'))) fail('daemon state collides with the separate MCP source');
|
|
110
|
+
privateStat(join(live, 'mcp'), true, 0o700);
|
|
111
|
+
copyTree(join(live, 'daemon'), payload);
|
|
112
|
+
copyTree(join(live, 'mcp'), join(payload, '.mcp'));
|
|
113
|
+
await createArchive(payload, path, options(records));
|
|
114
|
+
} finally { if (exists(payload)) fs.rmSync(payload, { recursive: true }); }
|
|
115
|
+
}
|
|
116
|
+
async function automaticBackup(prefix, records) {
|
|
117
|
+
const path = labelPath(`${prefix}-${new Date().toISOString().replace(/[-:.]/g, '')}-${randomBytes(4).toString('hex')}`);
|
|
118
|
+
checkpoints.beforeBackup?.();
|
|
119
|
+
await backupTo(path, records);
|
|
120
|
+
checkpoints.afterBackup?.();
|
|
121
|
+
return path;
|
|
122
|
+
}
|
|
123
|
+
function bindConfig(path, source, keys) {
|
|
124
|
+
privateStat(source, false, 0o600);
|
|
125
|
+
const current = JSON.parse(fs.readFileSync(source));
|
|
126
|
+
const restored = exists(path) ? JSON.parse(fs.readFileSync(path)) : {};
|
|
127
|
+
for (const value of [current, restored]) if (!value || Array.isArray(value) || typeof value !== 'object') fail('component configuration must be an object');
|
|
128
|
+
for (const key of keys) {
|
|
129
|
+
if (Object.hasOwn(current, key)) restored[key] = current[key]; else delete restored[key];
|
|
130
|
+
}
|
|
131
|
+
fs.writeFileSync(path, JSON.stringify(restored, null, 2) + '\n', { mode: 0o600 });
|
|
132
|
+
fs.chmodSync(path, 0o600);
|
|
133
|
+
return current;
|
|
134
|
+
}
|
|
135
|
+
function bindDeployment(tree) {
|
|
136
|
+
if (commonTree) {
|
|
137
|
+
const current = bindConfig(join(tree, 'daemon/config.json'), env.OURS_DAEMON_CONFIG, ['stateDir', 'port', 'apiVisibility', 'networkMcp']);
|
|
138
|
+
if (current.networkMcp?.profile) {
|
|
139
|
+
const path = join(tree, 'mcp/profile.json');
|
|
140
|
+
fs.writeFileSync(path, JSON.stringify(current.networkMcp.profile, null, 2) + '\n', { mode: 0o600 });
|
|
141
|
+
fs.chmodSync(path, 0o600);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (['server', 'cowork'].includes(domain)) bindConfig(join(tree, domain === 'server' ? 'cowork/config.json' : 'config.json'), env.OURS_COWORK_CONFIG, ['version', 'stateDir', 'rest']);
|
|
145
|
+
}
|
|
146
|
+
function retainAuthority(staging) {
|
|
147
|
+
if (!['daemon', 'server'].includes(domain)) return;
|
|
148
|
+
if (!env.OURS_CLI_PATH || !env.OURS_DAEMON_CONFIG) fail('daemon maintenance requires its selected executable and configuration');
|
|
149
|
+
const daemon = commonTree ? join(staging, 'daemon') : staging;
|
|
150
|
+
owner([env.OURS_CLI_PATH, 'config', 'access-retain', '--config', env.OURS_DAEMON_CONFIG, '--target-state-dir', daemon, '--json']);
|
|
151
|
+
if (!commonTree) return;
|
|
152
|
+
const credentials = join(live, 'credentials'), destination = join(staging, 'credentials');
|
|
153
|
+
for (const name of ['telegram', 'cowork', 'messenger']) if (!privateStat(join(credentials, name, 'daemon-token'), false, 0o600).size) fail('current managed credential is empty');
|
|
154
|
+
fs.rmSync(destination, { recursive: true }); copyTree(credentials, destination);
|
|
155
|
+
}
|
|
156
|
+
function replace(staging) {
|
|
157
|
+
const fd = fs.openSync(staging, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
158
|
+
try {
|
|
159
|
+
if (!tryLock(fd)) fail('replacement staging is in use');
|
|
160
|
+
if (!sameInode(fs.fstatSync(fd), fs.statSync(staging))) fail('replacement staging changed before exchange');
|
|
161
|
+
checkpoints.beforeExchange?.();
|
|
162
|
+
exchange(live, staging);
|
|
163
|
+
checkpoints.afterExchange?.();
|
|
164
|
+
fs.rmSync(staging, { recursive: true });
|
|
165
|
+
} finally { fs.closeSync(fd); }
|
|
166
|
+
}
|
|
167
|
+
privateStat(state, true, 0o700);
|
|
168
|
+
if (!exists(live)) { if (operation !== 'init') fail('domain state does not exist; run init for this domain first'); layout(); mkdir(live); }
|
|
169
|
+
const target = readBuildRecords(build);
|
|
170
|
+
const fd = liveDescriptor();
|
|
171
|
+
try {
|
|
172
|
+
if (!tryLock(fd)) fail('domain state is already in use');
|
|
173
|
+
const check = liveDescriptor();
|
|
174
|
+
try { if (!sameInode(fs.fstatSync(fd), fs.fstatSync(check))) fail('domain state changed while acquiring its lock'); }
|
|
175
|
+
finally { fs.closeSync(check); }
|
|
176
|
+
let records;
|
|
177
|
+
if (domain === 'server') { records = readRecords(join(live, 'daemon', PROVENANCE)); validateTree(live, records); }
|
|
178
|
+
else if (paired) { records = readRecords(join(live, 'daemon', PROVENANCE)); privateStat(join(live, 'mcp'), true, 0o700); }
|
|
179
|
+
else if (operation !== 'init') records = readRecords(join(live, PROVENANCE));
|
|
180
|
+
else {
|
|
181
|
+
const marker = join(live, PROVENANCE), application = fs.readdirSync(live).filter(name => name !== PROVENANCE);
|
|
182
|
+
records = target;
|
|
183
|
+
if (exists(marker)) {
|
|
184
|
+
privateStat(marker, true, 0o700);
|
|
185
|
+
if (fs.readdirSync(marker).length) {
|
|
186
|
+
records = readRecords(marker); // Partial generations are never repaired implicitly.
|
|
187
|
+
if (!sameRecords(records, target)) fail('existing provenance differs; use reviewed update --compatible to establish the new build context');
|
|
188
|
+
} else if (application.length && !adopt) fail('existing unmarked domain state requires evidence-backed adoption');
|
|
189
|
+
} else if (application.length && !adopt) fail('existing unmarked domain state requires evidence-backed adoption');
|
|
190
|
+
prepare(); if (application.length && adopt) scanSource(live, { uid, gid });
|
|
191
|
+
checkpoints.beforeInitialMarker?.();
|
|
192
|
+
initializeBuildMarker(marker, records);
|
|
193
|
+
checkpoints.afterInitialMarker?.();
|
|
194
|
+
layout(); return;
|
|
195
|
+
}
|
|
196
|
+
if (operation === 'update' || operation === 'rebuild') {
|
|
197
|
+
// The installer admits rebuild only after verifying unchanged sources.
|
|
198
|
+
// This is not a recorded user compatibility attestation.
|
|
199
|
+
if (!semanticSameRecords(records, target) && !(compatible && operation !== 'rebuild')) fail('different-build restore/update requires reviewed storage compatibility (--compatible)');
|
|
200
|
+
prepare(); layout(); const archive = await automaticBackup('pre-update', records);
|
|
201
|
+
console.log(`Validated pre-update backup: ${basename(archive)}`);
|
|
202
|
+
// Stage the complete set for every domain; no in-place partial retarget.
|
|
203
|
+
const staging = join(maintenance, `update-${randomBytes(12).toString('hex')}`);
|
|
204
|
+
try {
|
|
205
|
+
copyTree(live, staging);
|
|
206
|
+
retarget(staging, records, target);
|
|
207
|
+
validateTree(staging, target);
|
|
208
|
+
replace(staging);
|
|
209
|
+
} finally { if (exists(staging)) fs.rmSync(staging, { recursive: true }); }
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (operation === 'backup') { prepare(); layout(); await backupTo(labelPath(argv[2]), records); return; }
|
|
213
|
+
const staging = join(maintenance, `${operation}-${randomBytes(12).toString('hex')}`);
|
|
214
|
+
try {
|
|
215
|
+
if (operation === 'restore') {
|
|
216
|
+
const archive = labelPath(argv[2]); if (fs.realpathSync(archive) !== archive) fail('backup path is not canonical');
|
|
217
|
+
const archived = readRecords(archive, false);
|
|
218
|
+
if (!semanticSameRecords(archived, target) && !compatible) fail('different-build restore/update requires reviewed storage compatibility (--compatible)');
|
|
219
|
+
await validateArchive(archive, options(archived)); prepare(); layout(); await automaticBackup('pre-restore', records);
|
|
220
|
+
if (paired) {
|
|
221
|
+
const payload = `${staging}-payload`;
|
|
222
|
+
try {
|
|
223
|
+
await extractArchive(archive, payload, options(archived)); retarget(payload, archived, target);
|
|
224
|
+
copyTree(live, staging);
|
|
225
|
+
fs.rmSync(join(staging, 'daemon'), { recursive: true });
|
|
226
|
+
fs.rmSync(join(staging, 'mcp'), { recursive: true });
|
|
227
|
+
fs.renameSync(join(payload, '.mcp'), join(staging, 'mcp'));
|
|
228
|
+
fs.renameSync(payload, join(staging, 'daemon'));
|
|
229
|
+
} finally { if (exists(payload)) fs.rmSync(payload, { recursive: true }); }
|
|
230
|
+
} else { await extractArchive(archive, staging, options(archived)); retarget(staging, archived, target); }
|
|
231
|
+
} else {
|
|
232
|
+
prepare(); layout(); await automaticBackup('pre-reset', records);
|
|
233
|
+
if (paired) {
|
|
234
|
+
copyTree(live, staging);
|
|
235
|
+
for (const name of ['daemon', 'mcp']) { fs.rmSync(join(staging, name), { recursive: true }); mkdir(join(staging, name)); }
|
|
236
|
+
} else mkdir(staging);
|
|
237
|
+
const marker = join(paired ? join(staging, 'daemon') : staging, PROVENANCE);
|
|
238
|
+
mkdir(marker);
|
|
239
|
+
for (const name of recordNames(records)) writeRecord(join(marker, name), records[name]);
|
|
240
|
+
bindDeployment(staging);
|
|
241
|
+
}
|
|
242
|
+
retainAuthority(staging); bindDeployment(staging); replace(staging);
|
|
243
|
+
} finally { if (exists(staging)) fs.rmSync(staging, { recursive: true }); }
|
|
244
|
+
} finally { fs.closeSync(fd); }
|
|
245
|
+
}
|
|
246
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
247
|
+
try { await runStateOperation(process.argv.slice(2)); }
|
|
248
|
+
catch (error) { console.error(`OURS state operation refused: ${error.message}`); process.exitCode = 1; }
|
|
249
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Startup and readiness
|
|
2
|
+
|
|
3
|
+
Inputs: Compose variables and named volumes, protected credentials and existing
|
|
4
|
+
settings. Outputs: prepared paths/profiles, running processes and readiness exit
|
|
5
|
+
codes. Compose makes normal calls; no separate manual setup scripts are required.
|
|
6
|
+
|
|
7
|
+
| Files | Caller and purpose |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `client-setup.mjs` | installer preparation: directories, configuration, issued credentials, MCP profile and protected Telegram input; does not create identities |
|
|
10
|
+
| `entrypoint.sh`, `start-*.sh` | Compose services: lock the data directory and start the application; the Telegram package applies optional provisioning before readiness |
|
|
11
|
+
| `check-start.mjs`, `check-client.mjs` | Launchers: check access/credentials and essential settings before startup |
|
|
12
|
+
| `runtime-common.mjs` | Shared check helpers and build-record writing under the state lock |
|
|
13
|
+
| `healthcheck.mjs`, `health-*` | Docker healthchecks for daemon, Telegram, Cowork and Messenger readiness; failure returns a nonzero exit code |
|
|
14
|
+
|
|
15
|
+
The main MCP runs inside Docker. Fleet and ours-codex/ours-claude remain on the
|
|
16
|
+
host. Files use the existing transport without host bind mounts.
|
|
17
|
+
|
|
18
|
+
Main MCP attaches to the daemon on the server and exposes its network transport.
|
|
19
|
+
The installer owns preparation and selects the same sibling layout in package
|
|
20
|
+
mode. Docker uses one named `server-storage` volume: `state/daemon`, `state/mcp`,
|
|
21
|
+
`state/telegram`, `state/cowork`, `state/messenger` and `state/credentials`.
|
|
22
|
+
Preparation mounts the storage root; running applications mount only their own
|
|
23
|
+
state children and selected credentials. Backups and maintenance staging are
|
|
24
|
+
outside `state/`. The owner-lock volume contains transient runtime locks.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { checkCredential, checkRuntime, jsonConfig, privatePath, recordBuild } from './runtime-common.mjs';
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
const service = process.argv[2];
|
|
5
|
+
if (!['telegram', 'cowork', 'messenger'].includes(service)) throw new Error('Unknown client service');
|
|
6
|
+
const state = `/var/lib/ours-${service}`;
|
|
7
|
+
checkRuntime(state, process.env[service === 'telegram' ? 'OURS_TG_DAEMON_ID' : 'OURS_DAEMON_ID']);
|
|
8
|
+
privatePath(`/credentials/${service}`, true);
|
|
9
|
+
checkCredential(`/credentials/${service}/daemon-token`);
|
|
10
|
+
const config = jsonConfig(`${state}/config.json`);
|
|
11
|
+
if (service === 'cowork' && (!config || config.stateDir !== state || config.rest?.enabled !== true)) {
|
|
12
|
+
throw new Error('Cowork config must enable REST and use its declared state directory');
|
|
13
|
+
}
|
|
14
|
+
if (service === 'messenger' && !process.env.OURS_MESSENGER_IDENTITY?.trim()) {
|
|
15
|
+
throw new Error('Configure a messenger identity');
|
|
16
|
+
}
|
|
17
|
+
recordBuild(state);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error(`OURS client startup refused: ${error.message}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { checkCredential, checkRuntime, jsonConfig, recordBuild } from './runtime-common.mjs';
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
const state = '/var/lib/ours';
|
|
5
|
+
checkRuntime(state, process.env.OURS_DAEMON_ID);
|
|
6
|
+
checkCredential(`${state}/daemon-token`);
|
|
7
|
+
const config = jsonConfig(`${state}/config.json`);
|
|
8
|
+
if (config && ('apiToken' in config || (config.stateDir && config.stateDir !== state))) {
|
|
9
|
+
throw new Error('Daemon config must use its declared state directory and current token file');
|
|
10
|
+
}
|
|
11
|
+
recordBuild(state);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error(`OURS startup refused: ${error.message}`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readBuildRecords, initializeBuildMarker } from '../maintenance/build-context.mjs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync,
|
|
6
|
+
realpathSync, renameSync, unlinkSync, writeFileSync, readdirSync, chmodSync, chownSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const DELIVERY_FILES = [
|
|
10
|
+
'/credentials/telegram/daemon-token',
|
|
11
|
+
'/credentials/cowork/daemon-token',
|
|
12
|
+
'/credentials/messenger/daemon-token',
|
|
13
|
+
];
|
|
14
|
+
const fail = (message) => { throw new Error(message); };
|
|
15
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
16
|
+
|
|
17
|
+
function privatePath(path, directory, exact = directory ? 0o700 : 0o600) {
|
|
18
|
+
let value;
|
|
19
|
+
if (directory) {
|
|
20
|
+
const descriptor = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
21
|
+
try { value = fstatSync(descriptor); } finally { closeSync(descriptor); }
|
|
22
|
+
} else {
|
|
23
|
+
value = lstatSync(path);
|
|
24
|
+
}
|
|
25
|
+
if ((directory ? !value.isDirectory() : !value.isFile()) || value.isSymbolicLink()
|
|
26
|
+
|| value.uid !== process.getuid() || value.gid !== process.getgid()
|
|
27
|
+
|| (value.mode & 0o7777) !== exact) {
|
|
28
|
+
fail(`Unsafe ownership or permissions: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function existingPrivate(path, directory, exact) {
|
|
34
|
+
try { return privatePath(path, directory, exact); }
|
|
35
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function jsonObject(path) {
|
|
39
|
+
privatePath(path, false);
|
|
40
|
+
let value;
|
|
41
|
+
try { value = JSON.parse(readFileSync(path, 'utf8')); }
|
|
42
|
+
catch { fail(`Existing config is not valid JSON: ${path}`); }
|
|
43
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') fail(`Existing config must be an object: ${path}`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function atomicJson(path, value) {
|
|
48
|
+
const temp = `${path}.setup-${randomBytes(8).toString('hex')}`;
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
51
|
+
renameSync(temp, path);
|
|
52
|
+
} finally {
|
|
53
|
+
try { unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ensureDirectory(path) {
|
|
58
|
+
if (existingPrivate(path, true, 0o700)) return;
|
|
59
|
+
mkdirSync(path, { mode: 0o700 });
|
|
60
|
+
privatePath(path, true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateCommon(idName = 'OURS_DAEMON_ID') {
|
|
64
|
+
if (!process.getuid() || !process.getgid()) fail('A non-root UID and GID are required');
|
|
65
|
+
if (!uuid.test(process.env[idName] ?? '')) fail('Configure the shared lowercase daemon UUID');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function composeConfig(domain) {
|
|
69
|
+
if (domain === 'daemon') {
|
|
70
|
+
const value = { stateDir: '/var/lib/ours', port: 3050, apiVisibility: 'owner', networkMcp: { profile: { endpoint: 'http://127.0.0.1:3050', expectedInstanceId: process.env.OURS_DAEMON_ID, credentialPath: '/var/lib/ours/daemon-token' }, applicationConfigPath: '/var/lib/ours-mcp/config.json' } };
|
|
71
|
+
if (process.env.OURS_BROKER_URL) value.brokerUrl = process.env.OURS_BROKER_URL;
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
if (domain === 'cowork') {
|
|
75
|
+
const port = Number(process.env.OURS_COWORK_REST_PORT);
|
|
76
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) fail('Configure a valid cowork REST port');
|
|
77
|
+
return { version: 1, stateDir: '/var/lib/ours-cowork', rest: { enabled: true, host: '0.0.0.0', port } };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function prepare() {
|
|
83
|
+
const uid = Number(process.env.OURS_UID), gid = Number(process.env.OURS_GID);
|
|
84
|
+
if (![uid, gid].every((n) => Number.isSafeInteger(n) && n > 0)) fail('Configure non-root numeric OURS_UID and OURS_GID');
|
|
85
|
+
const roots = ['/storage', '/owner-locks'];
|
|
86
|
+
for (const path of roots) {
|
|
87
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
88
|
+
let st; try { st = fstatSync(fd); } finally { closeSync(fd); }
|
|
89
|
+
if (st.uid === 0 && readdirSync(path).length === 0) {
|
|
90
|
+
chmodSync(path, 0o700); chownSync(path, uid, gid);
|
|
91
|
+
} else if (st.uid !== uid || st.gid !== gid || (st.mode & 0o7777) !== 0o700) {
|
|
92
|
+
fail(`Unsafe existing volume: ${path}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
process.setgroups([]); process.setgid(gid); process.setuid(uid);
|
|
96
|
+
process.umask(0o077);
|
|
97
|
+
validateCommon();
|
|
98
|
+
for (const path of ['/storage/state', '/storage/state/mcp', '/storage/state/credentials',
|
|
99
|
+
...['telegram', 'cowork', 'messenger'].map(name => `/storage/state/credentials/${name}`),
|
|
100
|
+
'/storage/backups', '/storage/.maintenance']) ensureDirectory(path);
|
|
101
|
+
const coworkPort = Number(process.env.OURS_COWORK_REST_PORT);
|
|
102
|
+
if (!Number.isSafeInteger(coworkPort) || coworkPort < 1 || coworkPort > 65535) fail('Configure a valid cowork REST port');
|
|
103
|
+
for (const domain of ['daemon', 'telegram', 'cowork', 'messenger']) {
|
|
104
|
+
const data = `/storage/state/${domain}`;
|
|
105
|
+
ensureDirectory(data);
|
|
106
|
+
// access-init owns the fresh-state check; no installer files enter daemon
|
|
107
|
+
// state until it has initialized or retained the selected authority.
|
|
108
|
+
if (domain === 'daemon') continue;
|
|
109
|
+
initializeBuildMarker(`${data}/.ours-provenance`, readBuildRecords('/opt/ours'));
|
|
110
|
+
}
|
|
111
|
+
const path = '/storage/state/daemon/config.json';
|
|
112
|
+
const expected = composeConfig('daemon');
|
|
113
|
+
if (existingPrivate(path, false)) {
|
|
114
|
+
const config = jsonObject(path);
|
|
115
|
+
if ('apiToken' in config || Object.entries(expected).some(([key, value]) => JSON.stringify(config[key]) !== JSON.stringify(value))) {
|
|
116
|
+
fail('Existing daemon configuration differs; use the supported maintenance workflow');
|
|
117
|
+
}
|
|
118
|
+
} else atomicJson(path, expected);
|
|
119
|
+
const coworkPath = '/storage/state/cowork/config.json';
|
|
120
|
+
const cowork = composeConfig('cowork');
|
|
121
|
+
if (!existingPrivate(coworkPath, false)) atomicJson(coworkPath, cowork);
|
|
122
|
+
else {
|
|
123
|
+
const config = jsonObject(coworkPath);
|
|
124
|
+
if (config.version !== 1 || config.stateDir !== cowork.stateDir || config.rest?.enabled !== true || config.rest?.host !== '0.0.0.0') fail('Existing cowork configuration conflicts with Compose');
|
|
125
|
+
}
|
|
126
|
+
console.log('OURS persistent volumes are ready');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function finishDaemonSetup() {
|
|
130
|
+
const state = '/var/lib/ours';
|
|
131
|
+
initializeBuildMarker(`${state}/.ours-provenance`, readBuildRecords('/opt/ours'));
|
|
132
|
+
privatePath('/var/lib/ours-mcp', true);
|
|
133
|
+
const mcpProfile = { endpoint: 'http://127.0.0.1:3050', expectedInstanceId: process.env.OURS_DAEMON_ID, credentialPath: `${state}/daemon-token` };
|
|
134
|
+
const mcpPath = '/var/lib/ours-mcp/profile.json';
|
|
135
|
+
if (!existingPrivate(mcpPath, false)) atomicJson(mcpPath, mcpProfile);
|
|
136
|
+
else if (JSON.stringify(jsonObject(mcpPath)) !== JSON.stringify(mcpProfile)) fail('Existing MCP profile differs from the selected daemon');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function telegramInput() {
|
|
140
|
+
let raw = '';
|
|
141
|
+
for await (const chunk of process.stdin) {
|
|
142
|
+
raw += chunk;
|
|
143
|
+
if (Buffer.byteLength(raw) > 1024 * 1024) fail('Telegram input exceeds 1 MiB');
|
|
144
|
+
}
|
|
145
|
+
let value; try { value = JSON.parse(raw); } catch { fail('Telegram input must be JSON'); }
|
|
146
|
+
exactKeys(value, ['config', 'provision'], 'Telegram input');
|
|
147
|
+
for (const [key, input] of Object.entries(value)) {
|
|
148
|
+
if (!input || Array.isArray(input) || typeof input !== 'object') fail(`Telegram ${key} must be an object`);
|
|
149
|
+
const target = `/storage/state/telegram/${key === 'config' ? 'config' : 'provision'}.json`;
|
|
150
|
+
if (existingPrivate(target, false)) {
|
|
151
|
+
if (JSON.stringify(jsonObject(target)) !== JSON.stringify(input)) fail(`Existing Telegram ${key} differs; change it through the owning application`);
|
|
152
|
+
} else atomicJson(target, input);
|
|
153
|
+
}
|
|
154
|
+
console.log('OURS protected Telegram input stored');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function cliJson(args) {
|
|
158
|
+
const result = spawnSync(process.execPath, ['/opt/ours/node_modules/@ours.network/cli/dist/cli.js', ...args], {
|
|
159
|
+
encoding: 'utf8', timeout: 60_000, maxBuffer: 1024 * 1024,
|
|
160
|
+
});
|
|
161
|
+
if (result.status !== 0) fail('Official OURS CLI operation failed');
|
|
162
|
+
try { return JSON.parse(result.stdout); }
|
|
163
|
+
catch { fail('Official OURS CLI returned malformed JSON'); }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function access(operation, output) {
|
|
167
|
+
validateCommon();
|
|
168
|
+
const config = '/var/lib/ours/config.json';
|
|
169
|
+
if (operation === 'access-init') {
|
|
170
|
+
cliJson(['config', operation, '--config', config, ...(process.env.OURS_ACCESS_MIGRATE === '1' ? ['--migrate'] : []), '--json']);
|
|
171
|
+
finishDaemonSetup();
|
|
172
|
+
}
|
|
173
|
+
else if (operation === 'access-replace') cliJson(['config', operation, '--config', config, '--confirm', '--json']);
|
|
174
|
+
else if (operation === 'access-issue') {
|
|
175
|
+
for (const path of output ? [output] : ['/var/lib/ours/daemon-token', ...DELIVERY_FILES]) {
|
|
176
|
+
cliJson(['config', operation, '--config', config, '--output', path, '--replace', '--json']);
|
|
177
|
+
}
|
|
178
|
+
} else fail('Unknown access operation');
|
|
179
|
+
console.log(`OURS ${operation} completed`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function exactKeys(value, allowed, label) {
|
|
183
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') fail(`${label} must be an object`);
|
|
184
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) fail(`${label} contains an unsupported field`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const operation = process.argv[2];
|
|
190
|
+
if (operation === 'prepare') prepare();
|
|
191
|
+
else if (operation === 'telegram-input') { prepare(); await telegramInput(); }
|
|
192
|
+
else if (['access-init', 'access-issue', 'access-replace'].includes(operation)) access(operation, process.argv[3]);
|
|
193
|
+
else fail('usage: client-setup.mjs prepare | telegram-input | access-init | access-issue [OUTPUT] | access-replace');
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.error(`OURS client setup refused: ${error.message}`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
umask 077
|
|
4
|
+
exec 3</var/lib/ours
|
|
5
|
+
if flock -n -E 73 3; then
|
|
6
|
+
:
|
|
7
|
+
else
|
|
8
|
+
status=$?
|
|
9
|
+
echo "OURS startup refused: daemon state is already in use" >&2
|
|
10
|
+
exit "$status"
|
|
11
|
+
fi
|
|
12
|
+
node /opt/ours/docker/check-start.mjs
|
|
13
|
+
exec node /opt/ours/node_modules/@ours.network/cli/dist/cli.js daemon serve
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
node /opt/ours/node_modules/@ours.network/cowork/dist/cli.js --json status |
|
|
3
|
+
node -e '
|
|
4
|
+
let input = "";
|
|
5
|
+
process.stdin.on("data", chunk => input += chunk).on("end", () => {
|
|
6
|
+
try {
|
|
7
|
+
const status = JSON.parse(input);
|
|
8
|
+
process.exit(status.ok === true && status.result?.running === true ? 0 : 1);
|
|
9
|
+
} catch { process.exit(1); }
|
|
10
|
+
});
|
|
11
|
+
'
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The connector exposes readiness only after optional provisioning succeeds.
|
|
3
|
+
node -e '
|
|
4
|
+
fetch("http://127.0.0.1:3051/health", {
|
|
5
|
+
signal: AbortSignal.timeout(3000), redirect: "error"
|
|
6
|
+
}).then(response => process.exit(response.ok ? 0 : 1))
|
|
7
|
+
.catch(() => process.exit(1));
|
|
8
|
+
'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
2
|
+
let client;
|
|
3
|
+
try {
|
|
4
|
+
client = await attachOursClient({
|
|
5
|
+
endpoint: 'http://127.0.0.1:3050',
|
|
6
|
+
expectedInstanceId: process.env.OURS_DAEMON_ID,
|
|
7
|
+
credentialPath: '/var/lib/ours/daemon-token',
|
|
8
|
+
requiredCapabilities: ['external-sessions-v1'],
|
|
9
|
+
sessionMode: 'external',
|
|
10
|
+
leaseToken: 'container-readiness',
|
|
11
|
+
env: {},
|
|
12
|
+
});
|
|
13
|
+
await client.listIdentities();
|
|
14
|
+
} catch {
|
|
15
|
+
console.error('Daemon API is not ready');
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
} finally { await client?.close(); }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readBuildRecords, initializeBuildMarker } from '../maintenance/build-context.mjs';
|
|
2
|
+
import { accessSync, constants, lstatSync, readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export function privatePath(path, directory = false, writable = false) {
|
|
5
|
+
const stat = lstatSync(path);
|
|
6
|
+
if ((directory ? !stat.isDirectory() : !stat.isFile()) || stat.isSymbolicLink()
|
|
7
|
+
|| stat.uid !== process.getuid() || stat.gid !== process.getgid()
|
|
8
|
+
|| (stat.mode & 0o7077) !== 0) {
|
|
9
|
+
throw new Error(`Unsafe ownership or permissions: ${path}`);
|
|
10
|
+
}
|
|
11
|
+
accessSync(path, constants.R_OK | (directory ? constants.X_OK : 0) | (writable ? constants.W_OK : 0));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function jsonConfig(path) {
|
|
15
|
+
try {
|
|
16
|
+
privatePath(path);
|
|
17
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
18
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`Invalid config: ${path}`);
|
|
19
|
+
return value;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code === 'ENOENT') return null;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function checkRuntime(state, id) {
|
|
27
|
+
if (!process.getuid() || !process.getgid()) throw new Error('Run as a non-root UID and GID');
|
|
28
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id ?? '')) {
|
|
29
|
+
throw new Error('Configure a stable daemon UUID');
|
|
30
|
+
}
|
|
31
|
+
privatePath(state, true, true);
|
|
32
|
+
for (const name of ['OURS_API_TOKEN', 'OURS_DAEMON_TOKEN', 'OURS_TG_BOT_TOKEN', 'OURS_TG_STT_API_KEY', 'TELEGRAM_BOT_TOKEN']) {
|
|
33
|
+
if (process.env[name] !== undefined) throw new Error(`Use a protected credential file instead of ${name}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function checkCredential(path) {
|
|
38
|
+
privatePath(path);
|
|
39
|
+
// Credential format and authenticity belong to the SDK attachment API.
|
|
40
|
+
if (!readFileSync(path, 'utf8').trim()) throw new Error(`Empty daemon credential: ${path}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Called with the startup state-directory lock held. These are build records,
|
|
44
|
+
// not a storage schema or a declaration that arbitrary upgrades are compatible.
|
|
45
|
+
export function recordBuild(state) {
|
|
46
|
+
initializeBuildMarker(`${state}/.ours-provenance`, readBuildRecords('/opt/ours'));
|
|
47
|
+
}
|