@ours.network/install 1.2.1-nightly.4 → 1.2.1-nightly.41
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/GATEWAY_SETUP.md +178 -0
- package/README.md +471 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +14 -12
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +8 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1177 -4508
- package/assets/release.json +37 -19
- package/assets/scripts/build/build-common.mjs +2 -1
- package/assets/scripts/build/build-sdk.mjs +6 -1
- package/assets/scripts/maintenance/build-context.mjs +1 -1
- package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
- package/assets/scripts/maintenance/recover-notifications.mjs +26 -0
- package/assets/scripts/maintenance/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +62 -5
- package/assets/scripts/runtime/README.md +7 -0
- package/assets/scripts/runtime/client-setup.mjs +80 -6
- package/assets/scripts/runtime/dependency-ready.mjs +13 -0
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +5 -1
- package/assets/scripts/runtime/health-messenger.mjs +7 -6
- package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
- package/assets/scripts/runtime/recover.mjs +165 -0
- package/assets/scripts/runtime/start-cowork.sh +5 -0
- package/assets/scripts/runtime/start-messenger.sh +29 -4
- package/assets/scripts/runtime/start-telegram.sh +5 -0
- package/assets/scripts/runtime/supervise.mjs +35 -0
- package/assets/sources.json +54 -28
- package/install.mjs +7 -2
- package/lib/boot-readiness.mjs +43 -0
- package/lib/build-transition.mjs +16 -0
- package/lib/client-cli.mjs +127 -0
- package/lib/container-engine.mjs +127 -0
- package/lib/diagnostics.mjs +1 -0
- package/lib/docker-conversion-runtime.mjs +3 -2
- package/lib/docker-layout-installation.mjs +2 -1
- package/lib/docker-recovery.mjs +55 -0
- package/lib/docker-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +357 -133
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +158 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate-uninstall.mjs +18 -6
- package/lib/orchestrate.mjs +193 -43
- package/lib/plan.mjs +27 -11
- package/lib/podman-preflight.mjs +40 -0
- package/lib/server-onboarding.mjs +164 -16
- package/lib/server-ports.mjs +17 -0
- package/lib/setup-options.mjs +99 -28
- package/lib/setup.mjs +35 -17
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +21 -7
- package/lib/workspace-setup.mjs +83 -0
- package/package.json +4 -2
package/assets/release.json
CHANGED
|
@@ -1,43 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"channel": "nightly",
|
|
4
|
-
"installerVersion": "1.2.1-nightly.
|
|
4
|
+
"installerVersion": "1.2.1-nightly.41",
|
|
5
5
|
"packages": {
|
|
6
6
|
"@ours.network/sdk": {
|
|
7
|
-
"version": "3.8.1-nightly.
|
|
8
|
-
"integrity": "sha512-
|
|
7
|
+
"version": "3.8.1-nightly.17",
|
|
8
|
+
"integrity": "sha512-GCPbGzh0IxFJluyRk4y1O+9419kZcnHapnr42FQKs4u2kKU7EcDRLBlINqSYgF0YUCZyZUDv/9Rk1//GKs/6oQ=="
|
|
9
9
|
},
|
|
10
10
|
"@ours.network/cli": {
|
|
11
|
-
"version": "2.8.1-nightly.
|
|
12
|
-
"integrity": "sha512-
|
|
11
|
+
"version": "2.8.1-nightly.15",
|
|
12
|
+
"integrity": "sha512-ht96LfYH7Du19YWeht+5eRsZxuF1Fobfj6sPSH+aFgWD9hYdrWTeG/5rrCTAClQflnl49Qaskk4n/TclkWrkRw=="
|
|
13
13
|
},
|
|
14
14
|
"@ours.network/tg-connector": {
|
|
15
|
-
"version": "1.0.1-nightly.
|
|
16
|
-
"integrity": "sha512
|
|
15
|
+
"version": "1.0.1-nightly.11",
|
|
16
|
+
"integrity": "sha512-ZT/51g+YgjCpPoYKF+6zuWhn/rAUa49Qq/mChOHZmZf2WJR16VLPGCc31LDA26kRE+kQtdkWiuGf2v0XOa7CXg=="
|
|
17
17
|
},
|
|
18
18
|
"@ours.network/cowork": {
|
|
19
|
-
"version": "1.3.
|
|
20
|
-
"integrity": "sha512-
|
|
19
|
+
"version": "1.3.3-nightly.20261005.f549477",
|
|
20
|
+
"integrity": "sha512-9RYvgAPrsKtzAOUSYsNLws3zhse3BvKu4Z2xC5vUYniWhyU8s2RiHO6bH7oBrjcRyjpCiFwnNpOE6oobi1Vllg=="
|
|
21
21
|
},
|
|
22
22
|
"@ours.network/messenger-server": {
|
|
23
|
-
"version": "1.0.31-nightly.
|
|
24
|
-
"integrity": "sha512-
|
|
23
|
+
"version": "1.0.31-nightly.17",
|
|
24
|
+
"integrity": "sha512-03qDDGRgN9pBGsBsCcvArhPuYfMblqsaXS9HinxAzOSiI3PaoNOh+4MxIAOoHXoE/12xxCDf7kbMdEAR0CPIfg=="
|
|
25
|
+
},
|
|
26
|
+
"@ours.network/notifications": {
|
|
27
|
+
"version": "0.3.1-nightly.2",
|
|
28
|
+
"integrity": "sha512-OjnrJ8ZuOfKYT4KAmiRLU7czfiuacs6rShwOvvqxqBaUKcs1UwjsYwHARNb6erMxG4am3xxcE0ItahL+d2vt4A=="
|
|
25
29
|
},
|
|
26
30
|
"@ours.network/fleet": {
|
|
27
|
-
"version": "1.2.0-nightly.
|
|
28
|
-
"integrity": "sha512-
|
|
31
|
+
"version": "1.2.0-nightly.54",
|
|
32
|
+
"integrity": "sha512-Sh3t8iRGBoYzbyslS+IKuqQ573Zn+M0GVhR0cGepy/hlyNMAwXD2TBM+j7d8QiA1+ALugkMhKnanivE97JBmhQ=="
|
|
29
33
|
},
|
|
30
34
|
"@ours.network/mcp": {
|
|
31
|
-
"version": "1.2.0-nightly.
|
|
32
|
-
"integrity": "sha512
|
|
35
|
+
"version": "1.2.0-nightly.13",
|
|
36
|
+
"integrity": "sha512-/SxWOeiQfOu0i+jV3su2khYQ4eUCH+exe6CjSUZ9u5FmTUfXmg7cBnPOzFT2PKbNulsSLf9vnU+QBGBwSa/Iag=="
|
|
33
37
|
},
|
|
34
38
|
"@ours.network/codex": {
|
|
35
|
-
"version": "1.2.0-nightly.
|
|
36
|
-
"integrity": "sha512-
|
|
39
|
+
"version": "1.2.0-nightly.13",
|
|
40
|
+
"integrity": "sha512-m5tcTu6QoMhrcrKgglTyG+nxe1nwRLc2t7nPJcjVRqf/5m8e9CRGqXssrioOAS/wD7qGKl2jPVwJr/0ZmMltVQ=="
|
|
37
41
|
},
|
|
38
42
|
"@ours.network/claude-code": {
|
|
39
|
-
"version": "1.2.0-nightly.
|
|
40
|
-
"integrity": "sha512-
|
|
43
|
+
"version": "1.2.0-nightly.13",
|
|
44
|
+
"integrity": "sha512-KnVGMyZAL9i8vqFTvI3V6OEpbOqk4YH106jfeB0JlTCRkk0Oy5H1rx764GucIxmYCiasAF1NKExHe2tDLlmiXQ=="
|
|
45
|
+
},
|
|
46
|
+
"@ours.network/daemon": {
|
|
47
|
+
"version": "3.8.1-nightly.9",
|
|
48
|
+
"integrity": "sha512-KQUAmd/Ae97DcaxKFinsVuyQiREUuXp6uiv0FeOsU59patpFKUcw7gYpmifBeywnvsDVrS+XX33CubYYkpMacw=="
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"hostCli": {
|
|
52
|
+
"@ours.network/sdk": {
|
|
53
|
+
"version": "3.8.1-nightly.17",
|
|
54
|
+
"integrity": "sha512-GCPbGzh0IxFJluyRk4y1O+9419kZcnHapnr42FQKs4u2kKU7EcDRLBlINqSYgF0YUCZyZUDv/9Rk1//GKs/6oQ=="
|
|
55
|
+
},
|
|
56
|
+
"@ours.network/cli": {
|
|
57
|
+
"version": "2.8.1-nightly.15",
|
|
58
|
+
"integrity": "sha512-ht96LfYH7Du19YWeht+5eRsZxuF1Fobfj6sPSH+aFgWD9hYdrWTeG/5rrCTAClQflnl49Qaskk4n/TclkWrkRw=="
|
|
41
59
|
}
|
|
42
60
|
}
|
|
43
61
|
}
|
|
@@ -7,7 +7,8 @@ export const ROOT = resolve(process.env.OURS_BUILD_ROOT || '/opt/ours');
|
|
|
7
7
|
export const SOURCE_ROOT = resolve(process.env.OURS_SOURCE_ROOT || '/src');
|
|
8
8
|
export const OUT = join(ROOT, 'docker/vendor');
|
|
9
9
|
export const CONFIG = JSON.parse(readFileSync(join(ROOT, 'sources.json'), 'utf8'));
|
|
10
|
-
|
|
10
|
+
// Notifications joined the server set later; retained selections without it still build.
|
|
11
|
+
export const SELECTED = new Set((process.env.OURS_BUILD_PACKAGES || ['sdk,cli,daemon,tg-connector,cowork,messenger-server', ...(CONFIG.packages?.['@ours.network/notifications'] ? ['notifications'] : [])].join(',')).split(',').map(name => '@ours.network/' + name));
|
|
11
12
|
export const inheritedLock = process.env.OURS_INSTALLER_LOCK_FD === '3' ? [3] : [];
|
|
12
13
|
export const run = (args, cwd) => execFileSync(args[0], args.slice(1), { cwd, stdio: ['inherit', 'inherit', 'inherit', ...inheritedLock] });
|
|
13
14
|
export const capture = (args, cwd, env = process.env) => execFileSync(args[0], args.slice(1), { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit', ...inheritedLock] });
|
|
@@ -2,8 +2,13 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { SOURCE_ROOT, SELECTED, pack, run } from './build-common.mjs';
|
|
3
3
|
const source = join(SOURCE_ROOT, 'sdk');
|
|
4
4
|
run(['npm', 'ci', '--no-audit', '--no-fund'], source);
|
|
5
|
-
run(['bash', 'scripts/compile-mufl.sh'], source);
|
|
5
|
+
if (SELECTED.has('@ours.network/daemon')) run(['bash', 'scripts/compile-mufl.sh'], source);
|
|
6
6
|
run(['npm', 'run', 'build'], source);
|
|
7
7
|
if (SELECTED.has('@ours.network/cli')) run(['npm', 'run', 'build:cli'], source);
|
|
8
8
|
pack(source, '@ours.network/sdk', 'sdk');
|
|
9
9
|
pack(join(source, 'packages/cli'), '@ours.network/cli', 'sdk');
|
|
10
|
+
|
|
11
|
+
if (SELECTED.has('@ours.network/daemon')) {
|
|
12
|
+
run(['npm', 'run', 'build:daemon'], source);
|
|
13
|
+
pack(join(source, 'packages/daemon'), '@ours.network/daemon', 'sdk');
|
|
14
|
+
}
|
|
@@ -8,7 +8,7 @@ import { semanticRecordEqual } from './provenance-compare.mjs';
|
|
|
8
8
|
export const BASE_RECORDS = ['package-lock.json', 'dependency-tree.json'];
|
|
9
9
|
export const CONTEXT = 'build-context.json';
|
|
10
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));
|
|
11
|
+
const packages = new Set(['sdk', 'cli', 'daemon', 'mcp', 'tg-connector', 'cowork', 'messenger-server', 'notifications', 'fleet', 'codex', 'claude-code', 'install'].map(n => '@ours.network/' + n));
|
|
12
12
|
const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding);
|
|
13
13
|
const fail = text => { throw new Error(`Build context: ${text}`); };
|
|
14
14
|
const keys = (v, expected) => v && !Array.isArray(v) && typeof v === 'object' && Object.keys(v).sort().join('\0') === [...expected].sort().join('\0');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Native callers provide an exact selected executable. Compose mounts the
|
|
5
|
+
// selected runtime and supplies its bin directory, including retained releases.
|
|
6
|
+
export function daemonOwner(env = process.env) {
|
|
7
|
+
if (env.OURS_CLI_PATH) return env.OURS_CLI_PATH;
|
|
8
|
+
const bin = env.OURS_DAEMON_BIN_DIR;
|
|
9
|
+
if (!bin) throw new Error('daemon maintenance requires its selected executable or bin directory');
|
|
10
|
+
const daemon = join(bin, 'ours-daemon');
|
|
11
|
+
if (existsSync(daemon)) return daemon;
|
|
12
|
+
const legacy = join(bin, 'ours');
|
|
13
|
+
if (existsSync(legacy)) return legacy;
|
|
14
|
+
throw new Error('selected runtime contains no daemon administrator');
|
|
15
|
+
}
|
|
@@ -6,6 +6,8 @@ import { pathToFileURL } from 'node:url';
|
|
|
6
6
|
import { copyPrivateTree, scanSource, createArchive, validateArchive } from './state-archive.mjs';
|
|
7
7
|
import { publishNoReplace } from './state-native.mjs';
|
|
8
8
|
|
|
9
|
+
import { daemonOwner } from './daemon-owner.mjs';
|
|
10
|
+
|
|
9
11
|
const COMPONENTS = ['daemon', 'telegram', 'cowork', 'messenger'];
|
|
10
12
|
import { recordNames, readBuildRecords, validateBuildRecordSet } from './build-context.mjs';
|
|
11
13
|
const DAEMON_STATE = '/var/lib/ours';
|
|
@@ -113,12 +115,14 @@ export function validateDockerLayout(tree, options) {
|
|
|
113
115
|
const config = join(tree, component, 'config.json');
|
|
114
116
|
if (fs.existsSync(config)) objectAt(config, options);
|
|
115
117
|
}
|
|
118
|
+
// Fresh managed installs keep the MCP profile separately until configured.
|
|
119
|
+
// Retained embedded configuration, when present, must still match exactly.
|
|
116
120
|
const daemon = objectAt(join(tree, 'daemon/config.json'), options);
|
|
117
121
|
const profile = objectAt(join(tree, 'mcp/profile.json'), options);
|
|
118
122
|
validateProfile(profile, options.instanceId);
|
|
119
123
|
if (daemon.stateDir !== DAEMON_STATE || daemon.port !== 3050
|
|
120
|
-
|| daemon.networkMcp?.applicationConfigPath !== join(MCP_STATE, 'config.json')
|
|
121
|
-
|
|
124
|
+
|| (daemon.networkMcp !== undefined && (daemon.networkMcp?.applicationConfigPath !== join(MCP_STATE, 'config.json')
|
|
125
|
+
|| JSON.stringify(daemon.networkMcp?.profile) !== JSON.stringify(profile)))
|
|
122
126
|
|| objectAt(join(tree, 'cowork/config.json'), options).stateDir !== '/var/lib/ours-cowork') {
|
|
123
127
|
throw new Error('Converted Docker deployment configuration is inconsistent');
|
|
124
128
|
}
|
|
@@ -207,7 +211,7 @@ export async function runDockerLayoutCommand(argv, env = process.env) {
|
|
|
207
211
|
const build = env.OURS_BUILD_ROOT || '/opt/ours';
|
|
208
212
|
const options = {
|
|
209
213
|
uid: process.getuid(), gid: process.getgid(), instanceId: env.OURS_DAEMON_ID,
|
|
210
|
-
cli:
|
|
214
|
+
cli: daemonOwner({ OURS_DAEMON_BIN_DIR: '/opt/ours/node_modules/.bin', ...env }),
|
|
211
215
|
configPath: env.OURS_DAEMON_CONFIG || '/var/lib/ours/config.json',
|
|
212
216
|
provenance: readBuildRecords(build),
|
|
213
217
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Non-secret retained build evidence arrives on stdin, never in argv/env. */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { runStateOperation } from './state-operation.mjs';
|
|
6
|
+
|
|
7
|
+
const root = fs.mkdtempSync(join(tmpdir(), 'ours-notifications-recovery-'));
|
|
8
|
+
fs.chmodSync(root, 0o700);
|
|
9
|
+
try {
|
|
10
|
+
const evidence = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
11
|
+
if (!evidence || Object.keys(evidence).sort().join(',') !== 'phase,previous,target'
|
|
12
|
+
|| !['prepared', 'state-updated', 'runtime-activated'].includes(evidence.phase)) throw new Error('Invalid recovery evidence');
|
|
13
|
+
for (const name of ['previous', 'target']) {
|
|
14
|
+
const records = evidence[name], names = Object.keys(records).sort();
|
|
15
|
+
if (!['dependency-tree.json,package-lock.json', 'build-context.json,dependency-tree.json,package-lock.json'].includes(names.join(','))) throw new Error('Invalid recovery record set');
|
|
16
|
+
const directory = join(root, name); fs.mkdirSync(directory, { mode: 0o700 });
|
|
17
|
+
for (const file of names) {
|
|
18
|
+
if (typeof records[file] !== 'string') throw new Error('Invalid recovery record bytes');
|
|
19
|
+
fs.writeFileSync(join(directory, file), Buffer.from(records[file], 'base64'), { flag: 'wx', mode: 0o600 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const result = await runStateOperation(['recover-notifications', 'server', '--compatible'], {
|
|
23
|
+
...process.env, OURS_PREVIOUS_BUILD_ROOT: join(root, 'previous'), OURS_EXPECTED_BUILD_ROOT: join(root, 'target'), OURS_RECOVERY_PHASE: evidence.phase,
|
|
24
|
+
});
|
|
25
|
+
console.log(JSON.stringify(result));
|
|
26
|
+
} finally { fs.rmSync(root, { recursive: true }); }
|
|
@@ -20,10 +20,19 @@ export function releaseBinding(policy) {
|
|
|
20
20
|
if (release?.schema !== 1 || !['stable', 'nightly'].includes(release.channel)) fail('invalid release binding');
|
|
21
21
|
const pattern = release.channel === 'nightly' ? nightly : stable;
|
|
22
22
|
if (typeof release.installerVersion !== 'string' || !pattern.test(release.installerVersion)) fail('invalid installer version/channel');
|
|
23
|
-
if (
|
|
23
|
+
if (release.scope !== undefined && release.scope !== 'host-cli') fail('invalid release scope');
|
|
24
|
+
const required = release.scope === 'host-cli' ? ['@ours.network/cli', '@ours.network/sdk'] : [...names, ...['@ours.network/daemon', '@ours.network/notifications'].filter(name => release.packages?.[name])];
|
|
25
|
+
if (!release.packages || JSON.stringify(Object.keys(release.packages).sort()) !== JSON.stringify([...required].sort())) fail('release must select exactly the required ours packages');
|
|
26
|
+
if (release.scope === 'host-cli' && Object.hasOwn(release, 'hostCli')) fail('nested host CLI selection');
|
|
24
27
|
for (const [name, entry] of Object.entries(release.packages)) {
|
|
25
28
|
if (typeof entry?.version !== 'string' || !pattern.test(entry.version) || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(entry.integrity ?? '')) fail(`invalid release artifact: ${name}`);
|
|
26
29
|
}
|
|
30
|
+
if (Object.hasOwn(release, 'hostCli')) {
|
|
31
|
+
if (!release.hostCli || Object.keys(release.hostCli).sort().join(',') !== '@ours.network/cli,@ours.network/sdk') fail('host CLI must select exactly CLI and SDK');
|
|
32
|
+
for (const [name, entry] of Object.entries(release.hostCli)) {
|
|
33
|
+
if (typeof entry?.version !== 'string' || !pattern.test(entry.version) || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(entry.integrity ?? '')) fail(`invalid host CLI artifact: ${name}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
27
36
|
if (!policy.packages || !Object.keys(policy.packages).length) fail('missing release source selection');
|
|
28
37
|
for (const [name, entry] of Object.entries(policy.packages)) {
|
|
29
38
|
if (entry?.type !== 'npm' || entry.version !== release.packages[name]?.version || Object.keys(entry).sort().join(',') !== 'type,version') fail(`source selection differs from release: ${name}`);
|
|
@@ -92,3 +101,11 @@ export function verifyRuntimeRelease(root) {
|
|
|
92
101
|
if (!existsSync(path)) return { verified: false, reason: 'legacy runtime without sources' };
|
|
93
102
|
return verifyReleaseGraph(root, json(path));
|
|
94
103
|
}
|
|
104
|
+
|
|
105
|
+
/** The client command has its own exact dependency selection, never a graph bypass. */
|
|
106
|
+
export function hostCliPolicy(policy) {
|
|
107
|
+
const release = releaseBinding(policy);
|
|
108
|
+
if (!release?.hostCli) return null;
|
|
109
|
+
const { hostCli, ...base } = release;
|
|
110
|
+
return { release: { ...base, scope: 'host-cli', packages: hostCli }, packages: Object.fromEntries(Object.entries(hostCli).map(([name, p]) => [name, { type: 'npm', version: p.version }])) };
|
|
111
|
+
}
|
|
@@ -9,6 +9,8 @@ import { createArchive, extractArchive, validateArchive, scanSource, copyPrivate
|
|
|
9
9
|
import { exchange, tryLock, setMtimeNs } from './state-native.mjs';
|
|
10
10
|
import { recordNames, readBuildRecords, equalBuildRecords, initializeBuildMarker } from './build-context.mjs';
|
|
11
11
|
|
|
12
|
+
import { daemonOwner } from './daemon-owner.mjs';
|
|
13
|
+
|
|
12
14
|
const PROVENANCE = '.ours-provenance';
|
|
13
15
|
const APPLICATIONS = ['daemon', 'telegram', 'cowork', 'messenger'];
|
|
14
16
|
const exists = path => { try { fs.lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
|
|
@@ -35,11 +37,12 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
35
37
|
const operation = argv[0];
|
|
36
38
|
const paired = domain === 'daemon' && ['backup', 'restore', 'reset'].includes(operation);
|
|
37
39
|
const commonTree = domain === 'server' || paired;
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
+
const recovery = operation === 'recover-notifications' && domain === 'server' && compatible;
|
|
41
|
+
if (compatible && !['restore', 'update'].includes(operation) && !recovery) fail('compatibility attestation applies only to update or restore');
|
|
42
|
+
if (domain === 'server' && !['backup', 'restore', 'update', 'rebuild'].includes(operation) && !recovery) fail('full-server scope supports only backup, restore, update and rebuild');
|
|
40
43
|
if (operation === 'rebuild' && domain !== 'server') fail('rebuild selects the complete server');
|
|
41
44
|
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');
|
|
45
|
+
const valid = ((['init', 'update', 'rebuild'].includes(operation) || recovery) && argv.length === 2) || (adopt && argv.length === 3) || (['backup', 'restore'].includes(operation) && argv.length === 3) || (operation === 'reset' && argv.length === 3 && argv[2] === '--confirm');
|
|
43
46
|
if (!valid) fail('usage: init DOMAIN | backup DOMAIN LABEL | restore DOMAIN LABEL | reset DOMAIN --confirm | update DOMAIN');
|
|
44
47
|
const uid = process.getuid(), gid = process.getgid();
|
|
45
48
|
if (!uid || !gid) fail('state maintenance requires a non-root UID and GID');
|
|
@@ -86,6 +89,14 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
86
89
|
}
|
|
87
90
|
function validateTree(tree, records) {
|
|
88
91
|
const roots = domain === 'server' ? APPLICATIONS.map(name => join(tree, name)) : [tree];
|
|
92
|
+
// Notifications shares Messenger's service, but owns a separate state directory.
|
|
93
|
+
// Older installations/backups may contain only its empty mount placeholder.
|
|
94
|
+
// Include every retained notification generation in full-server maintenance.
|
|
95
|
+
if (domain === 'server' && exists(join(tree, 'notifications'))) {
|
|
96
|
+
const notifications = join(tree, 'notifications');
|
|
97
|
+
privateStat(notifications, true, 0o700);
|
|
98
|
+
if (exists(join(notifications, PROVENANCE))) roots.push(notifications);
|
|
99
|
+
}
|
|
89
100
|
for (const root of roots) if (!sameRecords(readRecords(join(root, PROVENANCE)), records)) fail('restored component provenance does not match the archive build');
|
|
90
101
|
if (domain === 'server') for (const name of ['mcp', 'credentials']) privateStat(join(tree, name), true, 0o700);
|
|
91
102
|
if (paired) privateStat(join(tree, '.mcp'), true, 0o700);
|
|
@@ -145,9 +156,9 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
145
156
|
}
|
|
146
157
|
function retainAuthority(staging) {
|
|
147
158
|
if (!['daemon', 'server'].includes(domain)) return;
|
|
148
|
-
if (!env.
|
|
159
|
+
if (!env.OURS_DAEMON_CONFIG) fail('daemon maintenance requires its selected executable and configuration');
|
|
149
160
|
const daemon = commonTree ? join(staging, 'daemon') : staging;
|
|
150
|
-
owner([env
|
|
161
|
+
owner([daemonOwner(env), 'config', 'access-retain', '--config', env.OURS_DAEMON_CONFIG, '--target-state-dir', daemon, '--json']);
|
|
151
162
|
if (!commonTree) return;
|
|
152
163
|
const credentials = join(live, 'credentials'), destination = join(staging, 'credentials');
|
|
153
164
|
for (const name of ['telegram', 'cowork', 'messenger']) if (!privateStat(join(credentials, name, 'daemon-token'), false, 0o600).size) fail('current managed credential is empty');
|
|
@@ -174,6 +185,52 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
174
185
|
try { if (!sameInode(fs.fstatSync(fd), fs.fstatSync(check))) fail('domain state changed while acquiring its lock'); }
|
|
175
186
|
finally { fs.closeSync(check); }
|
|
176
187
|
let records;
|
|
188
|
+
if (recovery) {
|
|
189
|
+
// Only the installer supplies the retained transition's previous and
|
|
190
|
+
// candidate records. Never infer compatibility from a mixed live tree.
|
|
191
|
+
const previous = readRecords(selected('OURS_PREVIOUS_BUILD_ROOT'), false);
|
|
192
|
+
const expected = readRecords(selected('OURS_EXPECTED_BUILD_ROOT'), false);
|
|
193
|
+
if (!sameRecords(target, expected)) fail('recovery runtime differs from the retained candidate');
|
|
194
|
+
const core = APPLICATIONS.map(name => readRecords(join(live, name, PROVENANCE)));
|
|
195
|
+
const oldCore = core.every(records => sameRecords(records, previous));
|
|
196
|
+
const newCore = core.every(records => sameRecords(records, target));
|
|
197
|
+
if (!newCore && !(env.OURS_RECOVERY_PHASE === 'prepared' && oldCore)) fail('recovery core provenance differs from the retained candidate');
|
|
198
|
+
for (const name of ['mcp', 'credentials']) privateStat(join(live, name), true, 0o700);
|
|
199
|
+
const notifications = join(live, 'notifications'), marker = join(notifications, PROVENANCE);
|
|
200
|
+
if (!exists(notifications)) return { stateUpdated: newCore };
|
|
201
|
+
privateStat(notifications, true, 0o700);
|
|
202
|
+
if (!exists(marker)) { scanSource(notifications, { uid, gid }); return { stateUpdated: newCore }; }
|
|
203
|
+
privateStat(marker, true, 0o700);
|
|
204
|
+
if (!fs.readdirSync(marker).length) { scanSource(notifications, { uid, gid }); return { stateUpdated: newCore }; }
|
|
205
|
+
const retained = readRecords(marker);
|
|
206
|
+
if (newCore && sameRecords(retained, target)) return { stateUpdated: true }; // Already repaired, including retries after exchange.
|
|
207
|
+
if (!sameRecords(retained, previous)) fail('recovery notification provenance differs from the retained previous build');
|
|
208
|
+
if (!newCore) return { stateUpdated: false }; // Prepared journal with untouched state: use the regular state operation.
|
|
209
|
+
prepare(); layout();
|
|
210
|
+
const archive = labelPath(`pre-notifications-recovery-${new Date().toISOString().replace(/[-:.]/g, '')}-${randomBytes(4).toString('hex')}`);
|
|
211
|
+
checkpoints.beforeBackup?.();
|
|
212
|
+
await createArchive(notifications, archive, { domain: 'notifications', uid, gid, provenance: previous });
|
|
213
|
+
checkpoints.afterBackup?.();
|
|
214
|
+
const staging = join(maintenance, `recovery-${randomBytes(12).toString('hex')}`);
|
|
215
|
+
try {
|
|
216
|
+
const entries = scanSource(live, { uid, gid });
|
|
217
|
+
copyTree(live, staging);
|
|
218
|
+
// Ordinary maintenance emits private copies; recovery retains every
|
|
219
|
+
// accepted payload mode and timestamp outside the replaced marker.
|
|
220
|
+
for (const entry of entries.reverse()) {
|
|
221
|
+
const path = entry.name === 'state' ? staging : join(staging, entry.name.slice(6));
|
|
222
|
+
fs.chmodSync(path, Number(entry.st.mode) & 0o777);
|
|
223
|
+
setMtimeNs(path, entry.st.mtimeNs);
|
|
224
|
+
}
|
|
225
|
+
const replacement = join(staging, 'notifications', PROVENANCE);
|
|
226
|
+
fs.rmSync(replacement, { recursive: true }); mkdir(replacement);
|
|
227
|
+
for (const name of recordNames(target)) writeRecord(join(replacement, name), target[name]);
|
|
228
|
+
setMtimeNs(dirname(replacement), fs.lstatSync(notifications, { bigint: true }).mtimeNs);
|
|
229
|
+
validateTree(staging, target);
|
|
230
|
+
replace(staging);
|
|
231
|
+
} finally { if (exists(staging)) fs.rmSync(staging, { recursive: true }); }
|
|
232
|
+
return { stateUpdated: true };
|
|
233
|
+
}
|
|
177
234
|
if (domain === 'server') { records = readRecords(join(live, 'daemon', PROVENANCE)); validateTree(live, records); }
|
|
178
235
|
else if (paired) { records = readRecords(join(live, 'daemon', PROVENANCE)); privateStat(join(live, 'mcp'), true, 0o700); }
|
|
179
236
|
else if (operation !== 'init') records = readRecords(join(live, PROVENANCE));
|
|
@@ -22,3 +22,10 @@ mode. Docker uses one named `server-storage` volume: `state/daemon`, `state/mcp`
|
|
|
22
22
|
Preparation mounts the storage root; running applications mount only their own
|
|
23
23
|
state children and selected credentials. Backups and maintenance staging are
|
|
24
24
|
outside `state/`. The owner-lock volume contains transient runtime locks.
|
|
25
|
+
|
|
26
|
+
`recover.mjs` supervises the locked entrypoints with timed health probes and three
|
|
27
|
+
automatic recovery attempts per container start. It kills the entire child group
|
|
28
|
+
and waits for lock release before a new generation. Exhaustion holds unhealthy
|
|
29
|
+
until explicit restart. `dependency-ready.mjs` waits for the authenticated pinned
|
|
30
|
+
daemon without consuming the consumer's failure circuit. Host boot still depends
|
|
31
|
+
on an independently configured Docker Engine or Desktop startup.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readBuildRecords, initializeBuildMarker } from '../maintenance/build-context.mjs';
|
|
2
|
+
import { redactDiagnostic } from './diagnostics.mjs';
|
|
2
3
|
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { generateKeyPairSync, randomBytes } from 'node:crypto';
|
|
4
5
|
import {
|
|
5
|
-
closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync,
|
|
6
|
+
existsSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync,
|
|
6
7
|
realpathSync, renameSync, unlinkSync, writeFileSync, readdirSync, chmodSync, chownSync,
|
|
7
8
|
} from 'node:fs';
|
|
8
9
|
|
|
@@ -12,6 +13,7 @@ const DELIVERY_FILES = [
|
|
|
12
13
|
'/credentials/messenger/daemon-token',
|
|
13
14
|
];
|
|
14
15
|
const fail = (message) => { throw new Error(message); };
|
|
16
|
+
let diagnosticStage = 'validation';
|
|
15
17
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
16
18
|
|
|
17
19
|
function privatePath(path, directory, exact = directory ? 0o700 : 0o600) {
|
|
@@ -67,7 +69,7 @@ function validateCommon(idName = 'OURS_DAEMON_ID') {
|
|
|
67
69
|
|
|
68
70
|
function composeConfig(domain) {
|
|
69
71
|
if (domain === 'daemon') {
|
|
70
|
-
const value = { stateDir: '/var/lib/ours', port: 3050, apiVisibility: 'owner'
|
|
72
|
+
const value = { stateDir: '/var/lib/ours', port: 3050, apiVisibility: 'owner' };
|
|
71
73
|
if (process.env.OURS_BROKER_URL) value.brokerUrl = process.env.OURS_BROKER_URL;
|
|
72
74
|
return value;
|
|
73
75
|
}
|
|
@@ -79,6 +81,61 @@ function composeConfig(domain) {
|
|
|
79
81
|
return null;
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
const NOTIFICATIONS_PORT = 49677;
|
|
85
|
+
const NOTIFICATION_FILES = {
|
|
86
|
+
config: '/storage/state/notifications/config.json',
|
|
87
|
+
messenger: '/storage/state/credentials/messenger/notifications-producer',
|
|
88
|
+
// Read through the Messenger service by local client setup.
|
|
89
|
+
fleet: '/storage/state/notifications/fleet-producer',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function privateText(path, value) {
|
|
93
|
+
if (existingPrivate(path, false) && readFileSync(path, 'utf8') === value) return;
|
|
94
|
+
const temp = `${path}.setup-${randomBytes(8).toString('hex')}`;
|
|
95
|
+
try { writeFileSync(temp, value, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); renameSync(temp, path); }
|
|
96
|
+
finally { try { unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function notificationConfig() {
|
|
100
|
+
const token = () => randomBytes(32).toString('base64url');
|
|
101
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
102
|
+
const pub = publicKey.export({ format: 'jwk' }), key = privateKey.export({ format: 'jwk' });
|
|
103
|
+
const raw = Buffer.concat([Buffer.from([4]), Buffer.from(pub.x, 'base64url'), Buffer.from(pub.y, 'base64url')]);
|
|
104
|
+
return {
|
|
105
|
+
host: '0.0.0.0', port: NOTIFICATIONS_PORT, stateFile: '/var/lib/ours-notifications/state.json',
|
|
106
|
+
users: [{ userId: 'owner', token: token() }],
|
|
107
|
+
producers: [{ userId: 'owner', source: 'fleet', token: token() }, { userId: 'owner', source: 'messenger', token: token() }],
|
|
108
|
+
vapid: { subject: 'https://ours.network', publicKey: raw.toString('base64url'), privateKey: key.d },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Exact retained shape; any other content is operator state the installer will not rewrite. */
|
|
113
|
+
function validNotificationConfig(config) {
|
|
114
|
+
const tokens = [...(config.users ?? []), ...(config.producers ?? [])].map(entry => entry?.token);
|
|
115
|
+
return config.host === '0.0.0.0' && config.port === NOTIFICATIONS_PORT && config.stateFile === '/var/lib/ours-notifications/state.json'
|
|
116
|
+
&& Array.isArray(config.users) && config.users.length === 1 && config.users[0].userId === 'owner'
|
|
117
|
+
&& Array.isArray(config.producers) && config.producers.length === 2
|
|
118
|
+
&& ['fleet', 'messenger'].every(source => config.producers.some(p => p?.source === source && p.userId === 'owner'))
|
|
119
|
+
&& tokens.every(t => typeof t === 'string' && t.length >= 32) && new Set(tokens).size === tokens.length
|
|
120
|
+
&& typeof config.vapid?.subject === 'string' && /^[A-Za-z0-9_-]{87}$/.test(config.vapid.publicKey ?? '') && /^[A-Za-z0-9_-]{43}$/.test(config.vapid.privateKey ?? '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Notification secrets are created once and then only validated: rotating the
|
|
125
|
+
* owner token or VAPID keys would orphan every browser subscription. Producer
|
|
126
|
+
* delivery files are derived from the retained configuration on every run.
|
|
127
|
+
*/
|
|
128
|
+
function prepareNotifications() {
|
|
129
|
+
// Messenger mounts this directory whether or not this runtime ships the service.
|
|
130
|
+
ensureDirectory('/storage/state/notifications');
|
|
131
|
+
if (!existsSync('/opt/ours/node_modules/@ours.network/notifications/package.json')) return;
|
|
132
|
+
initializeBuildMarker('/storage/state/notifications/.ours-provenance', readBuildRecords('/opt/ours'));
|
|
133
|
+
if (!existingPrivate(NOTIFICATION_FILES.config, false)) atomicJson(NOTIFICATION_FILES.config, notificationConfig());
|
|
134
|
+
const config = jsonObject(NOTIFICATION_FILES.config);
|
|
135
|
+
if (!validNotificationConfig(config)) fail('Existing notification configuration differs; use the supported maintenance workflow');
|
|
136
|
+
for (const source of ['messenger', 'fleet']) privateText(NOTIFICATION_FILES[source], config.producers.find(p => p.source === source).token);
|
|
137
|
+
}
|
|
138
|
+
|
|
82
139
|
function prepare() {
|
|
83
140
|
const uid = Number(process.env.OURS_UID), gid = Number(process.env.OURS_GID);
|
|
84
141
|
if (![uid, gid].every((n) => Number.isSafeInteger(n) && n > 0)) fail('Configure non-root numeric OURS_UID and OURS_GID');
|
|
@@ -123,15 +180,19 @@ function prepare() {
|
|
|
123
180
|
const config = jsonObject(coworkPath);
|
|
124
181
|
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
182
|
}
|
|
183
|
+
prepareNotifications();
|
|
126
184
|
console.log('OURS persistent volumes are ready');
|
|
127
185
|
}
|
|
128
186
|
|
|
129
187
|
function finishDaemonSetup() {
|
|
130
188
|
const state = '/var/lib/ours';
|
|
189
|
+
diagnosticStage = 'daemon-provenance';
|
|
131
190
|
initializeBuildMarker(`${state}/.ours-provenance`, readBuildRecords('/opt/ours'));
|
|
191
|
+
diagnosticStage = 'mcp-directory';
|
|
132
192
|
privatePath('/var/lib/ours-mcp', true);
|
|
133
193
|
const mcpProfile = { endpoint: 'http://127.0.0.1:3050', expectedInstanceId: process.env.OURS_DAEMON_ID, credentialPath: `${state}/daemon-token` };
|
|
134
194
|
const mcpPath = '/var/lib/ours-mcp/profile.json';
|
|
195
|
+
diagnosticStage = 'mcp-profile';
|
|
135
196
|
if (!existingPrivate(mcpPath, false)) atomicJson(mcpPath, mcpProfile);
|
|
136
197
|
else if (JSON.stringify(jsonObject(mcpPath)) !== JSON.stringify(mcpProfile)) fail('Existing MCP profile differs from the selected daemon');
|
|
137
198
|
}
|
|
@@ -155,10 +216,19 @@ async function telegramInput() {
|
|
|
155
216
|
}
|
|
156
217
|
|
|
157
218
|
function cliJson(args) {
|
|
158
|
-
|
|
219
|
+
diagnosticStage = 'official-cli';
|
|
220
|
+
const result = spawnSync(process.execPath, [existsSync('/opt/ours/node_modules/@ours.network/daemon/dist/cli.js') ? '/opt/ours/node_modules/@ours.network/daemon/dist/cli.js' : '/opt/ours/node_modules/@ours.network/cli/dist/cli.js', ...args], {
|
|
159
221
|
encoding: 'utf8', timeout: 60_000, maxBuffer: 1024 * 1024,
|
|
160
222
|
});
|
|
161
|
-
if (result.status !== 0)
|
|
223
|
+
if (result.error || result.status !== 0) {
|
|
224
|
+
// --json writes failures on stderr. Successful credential stdout is never
|
|
225
|
+
// forwarded, even if a later operation exits unsuccessfully.
|
|
226
|
+
let reason = 'CLI supplied no structured error reason';
|
|
227
|
+
for (const line of String(result.stderr ?? '').split('\n').reverse()) {
|
|
228
|
+
try { const value = JSON.parse(line); if (typeof value?.error?.message === 'string') { reason = value.error.message; break; } } catch {}
|
|
229
|
+
}
|
|
230
|
+
fail(`Official OURS CLI operation failed (${args[1]}; exit=${result.status ?? 'none'}; signal=${result.signal ?? 'none'}; system=${result.error?.code ?? 'none'}): ${reason}`);
|
|
231
|
+
}
|
|
162
232
|
try { return JSON.parse(result.stdout); }
|
|
163
233
|
catch { fail('Official OURS CLI returned malformed JSON'); }
|
|
164
234
|
}
|
|
@@ -192,6 +262,10 @@ try {
|
|
|
192
262
|
else if (['access-init', 'access-issue', 'access-replace'].includes(operation)) access(operation, process.argv[3]);
|
|
193
263
|
else fail('usage: client-setup.mjs prepare | telegram-input | access-init | access-issue [OUTPUT] | access-replace');
|
|
194
264
|
} catch (error) {
|
|
195
|
-
|
|
265
|
+
// Structured stderr retains the failing stage across sensitive Compose
|
|
266
|
+
// capture. The installer redacts secrets before exposing this envelope.
|
|
267
|
+
const message = redactDiagnostic(error.message);
|
|
268
|
+
console.error(JSON.stringify({ oursInstallerError: { stage: diagnosticStage, message } }));
|
|
269
|
+
console.error(`OURS client setup refused: ${message}`);
|
|
196
270
|
process.exit(1);
|
|
197
271
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
2
|
+
let client;
|
|
3
|
+
try {
|
|
4
|
+
const service = process.argv[2];
|
|
5
|
+
if (!['telegram', 'cowork', 'messenger'].includes(service)) throw new Error('Unknown dependency');
|
|
6
|
+
client = await attachOursClient({ endpoint: 'http://daemon:3050',
|
|
7
|
+
expectedInstanceId: process.env[service === 'telegram' ? 'OURS_TG_DAEMON_ID' : 'OURS_DAEMON_ID'],
|
|
8
|
+
credentialPath: `/credentials/${service}/daemon-token`,
|
|
9
|
+
requiredCapabilities: ['external-sessions-v1'], sessionMode: 'external',
|
|
10
|
+
leaseToken: `container-dependency-${service}`, env: {} });
|
|
11
|
+
await client.listIdentities();
|
|
12
|
+
} catch { process.exitCode = 1; }
|
|
13
|
+
finally { await client?.close(); }
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Retain actionable failure details without emitting credential output. */
|
|
2
|
+
export function redactDiagnostic(value, env = process.env) {
|
|
3
|
+
let text = String(value ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
4
|
+
for (const [key, secret] of Object.entries(env)) {
|
|
5
|
+
if (/(?:token|password|secret|api.?key|private.?key|invitation|enrollment|authorization|credential|access.?token|refresh.?token)/i.test(key) && typeof secret === 'string' && secret)
|
|
6
|
+
text = text.split(secret).join('[redacted]');
|
|
7
|
+
}
|
|
8
|
+
return text
|
|
9
|
+
.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, '[redacted private key]')
|
|
10
|
+
.replace(/(Bearer\s+)[^\s"',;]+/gi, '$1[redacted]')
|
|
11
|
+
.replace(/((?:["']?(?:api[-_]?token|token|password|secret|api[-_]?key|private[-_]?key|invitation|enrollment|authorization|credential|access.?token|refresh.?token)["']?)\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;}]+)/gi, '$1[redacted]')
|
|
12
|
+
.replace(/(--(?:setup-workspace|password|token|secret|api-key|private-key|invitation|enrollment)(?:=|\s+))\S+/gi, '$1[redacted]')
|
|
13
|
+
.replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/g, '$1[redacted]@')
|
|
14
|
+
.replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[redacted credential]')
|
|
15
|
+
.replace(/[A-Za-z0-9_+/=-]{43,}/g, '[redacted opaque value]');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const bounded = text => text.length <= 65536 ? text : text.slice(0, 65536) + '\n[Diagnostic output exceeds 64 KiB; remainder omitted.]';
|
|
19
|
+
function credentialFailure(stderr) {
|
|
20
|
+
// Credential stdout can be a successful issuance followed by failure: never
|
|
21
|
+
// include it. Only a structured error envelope or shipped helper refusal is
|
|
22
|
+
// accepted from stderr; arbitrary lines/warnings remain private.
|
|
23
|
+
const lines = String(stderr ?? '').split('\n').reverse();
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
try {
|
|
26
|
+
const value = JSON.parse(line);
|
|
27
|
+
if (typeof value?.oursInstallerError?.message === 'string') {
|
|
28
|
+
const detail = value.oursInstallerError;
|
|
29
|
+
const stage = /^[a-z][a-z-]{0,40}$/.test(detail.stage ?? '') ? detail.stage : 'credential-operation';
|
|
30
|
+
return `Stage: ${stage}\nReason: ${detail.message}`;
|
|
31
|
+
}
|
|
32
|
+
if (typeof value?.error?.message === 'string') return `Reason: ${value.error.message}`;
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
const prefix = 'OURS client setup refused: ';
|
|
36
|
+
const legacy = lines.find(line => line.startsWith(prefix));
|
|
37
|
+
if (legacy) return `Reason: ${legacy.slice(prefix.length)}`;
|
|
38
|
+
return 'The credential operation provided no structured reason. Its raw output was withheld because it can contain credentials.';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function commandFailure(command, args, result, { sensitive = false, cwd, env = process.env } = {}) {
|
|
42
|
+
const visibleArgs = [...args];
|
|
43
|
+
if (sensitive) {
|
|
44
|
+
for (let i = 0; i < visibleArgs.length - 1; i++) if (['-e', '--eval'].includes(visibleArgs[i])) visibleArgs[i + 1] = '[captured installer helper]';
|
|
45
|
+
}
|
|
46
|
+
const details = [`Command: ${command} ${visibleArgs.join(' ')}${result.status != null ? ` exited ${result.status}` : ''}`];
|
|
47
|
+
if (cwd) details.push(`Working directory: ${cwd}`);
|
|
48
|
+
if (result.status !== null && result.status !== undefined) details.push(`Exit code: ${result.status}`);
|
|
49
|
+
if (result.signal) details.push(`Signal: ${result.signal}`);
|
|
50
|
+
if (result.error?.code) details.push(`System error: ${result.error.code}`);
|
|
51
|
+
if (result.error?.code === 'ETIMEDOUT') details.push('Reason: command timed out.');
|
|
52
|
+
if (sensitive) details.push(credentialFailure(result.stderr));
|
|
53
|
+
else {
|
|
54
|
+
const stderr = String(result.stderr ?? '').trim(), stdout = String(result.stdout ?? '').trim();
|
|
55
|
+
if (stderr) details.push(`Standard error:\n${stderr}`);
|
|
56
|
+
if (stdout) details.push(`Standard output:\n${stdout}`);
|
|
57
|
+
if (!stderr && !stdout) details.push('No captured command output. Streaming commands print their output above.');
|
|
58
|
+
}
|
|
59
|
+
details.push('Keep installation data intact. Include this error and the preceding installation stage when requesting help.');
|
|
60
|
+
return new Error(bounded(redactDiagnostic(details.join('\n'), env)), result.error ? { cause: result.error } : undefined);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function installerFailure(error, env = process.env) {
|
|
64
|
+
const details = []; const seen = new Set();
|
|
65
|
+
for (let cause = error; cause && !seen.has(cause) && seen.size < 8; cause = cause.cause) {
|
|
66
|
+
seen.add(cause);
|
|
67
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
68
|
+
if (!details.includes(message)) details.push(message);
|
|
69
|
+
if (typeof cause.code === 'string' && /^[A-Z_0-9]+$/.test(cause.code)) details.push(`System error: ${cause.code}`);
|
|
70
|
+
}
|
|
71
|
+
return bounded(redactDiagnostic(details.join('\nCaused by: '), env));
|
|
72
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
+
# Re-enter this entrypoint for every recovery, including its locks and checks.
|
|
3
|
+
if [ "${OURS_RECOVERY_CHILD:-}" != 1 ]; then
|
|
4
|
+
exec node "${OURS_RUNTIME_ROOT:-/opt/ours}/docker/recover.mjs" daemon
|
|
5
|
+
fi
|
|
2
6
|
set -eu
|
|
3
7
|
umask 077
|
|
4
8
|
exec 3</var/lib/ours
|
|
@@ -10,4 +14,4 @@ else
|
|
|
10
14
|
exit "$status"
|
|
11
15
|
fi
|
|
12
16
|
node /opt/ours/docker/check-start.mjs
|
|
13
|
-
exec node /opt/ours/node_modules/@ours.network/
|
|
17
|
+
exec node /opt/ours/node_modules/@ours.network/daemon/dist/cli.js serve
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
});
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const healthy = async url => {
|
|
4
|
+
try { return (await fetch(url, { signal: AbortSignal.timeout(3000), redirect: 'error' })).ok; } catch { return false; }
|
|
5
|
+
};
|
|
6
|
+
const notifications = process.env.OURS_NOTIFICATIONS_CONFIG && existsSync('/opt/ours/node_modules/@ours.network/notifications/package.json') && existsSync(process.env.OURS_NOTIFICATIONS_CONFIG);
|
|
7
|
+
process.exitCode = await healthy('http://127.0.0.1:8420/api/healthz') && (!notifications || await healthy('http://127.0.0.1:49677/healthz')) ? 0 : 1;
|