@ours.network/install 1.2.1-nightly.4 → 1.2.1-nightly.40
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 +453 -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 +1139 -4470
- 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/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +12 -2
- 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 +3 -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 +323 -132
- 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/lib/plan.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateContainerEngine } from './container-engine.mjs';
|
|
2
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
3
|
// ours-install v3 — daemon creation and boot-service installation.
|
|
2
4
|
//
|
|
3
5
|
// Pure planning code, like lib/target.mjs: the orchestrator
|
|
@@ -97,7 +99,7 @@ export function launchdLabelForStateDir(stateDir) {
|
|
|
97
99
|
export function classifyUnit(text) {
|
|
98
100
|
if (text === null || text === undefined) return { kind: 'absent' };
|
|
99
101
|
const s = String(text);
|
|
100
|
-
if (s.startsWith(CLI_UNIT_MARKER)) return { kind: 'cli-managed' };
|
|
102
|
+
if ((s.startsWith(CLI_UNIT_MARKER) || s.startsWith('# Managed by @ours.network/daemon'))) return { kind: 'cli-managed' };
|
|
101
103
|
const looksLikeOursMcp = /ExecStart=.*\bours-mcp\b/.test(s)
|
|
102
104
|
|| /^Description=ours MCP daemon\b/m.test(s)
|
|
103
105
|
|| (/^Environment=OURS_STATE_DIR=/m.test(s) && /^Environment=OURS_TRANSPORT=http$/m.test(s));
|
|
@@ -138,7 +140,7 @@ export function planServiceInstall({ stateDir, home, readText, platform = 'linux
|
|
|
138
140
|
platform,
|
|
139
141
|
reason: 'no-service-manager',
|
|
140
142
|
message: `installing a boot service is not available on ${platform} — the ours CLI supports Linux user systemd and macOS launchd`,
|
|
141
|
-
manual: ['ours
|
|
143
|
+
manual: ['ours-daemon', 'serve', '--config'],
|
|
142
144
|
};
|
|
143
145
|
}
|
|
144
146
|
|
|
@@ -224,7 +226,7 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
|
224
226
|
// --json so the caller can read back whether the unit actually CHANGED rather
|
|
225
227
|
// than assuming it did. --force is reachable only through the explicit argument
|
|
226
228
|
// above, and the CLI refuses to overwrite a unit it did not write.
|
|
227
|
-
const cmd = ['ours
|
|
229
|
+
const cmd = ['ours-daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
|
|
228
230
|
if (adoptLegacyUnit) cmd.push('--force');
|
|
229
231
|
return cmd;
|
|
230
232
|
}
|
|
@@ -265,20 +267,23 @@ export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedI
|
|
|
265
267
|
const steps = [{ id: 'cli', label: 'install the ours-sdk CLI', command: ['npm', 'i', '-g', '@ours.network/cli'] }];
|
|
266
268
|
steps.push({ id: 'config', label: `write ${join(dir, 'config.json')}`, port: target.port });
|
|
267
269
|
if (target.action === 'create') {
|
|
268
|
-
steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours
|
|
270
|
+
steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours-daemon', 'start', '--config', join(dir, 'config.json')] });
|
|
269
271
|
} else if (cliVersionChanged) {
|
|
270
272
|
// `ours daemon stop` refuses to signal a daemon it did not start, so a
|
|
271
273
|
// daemon under another launcher is left running and the caller says which
|
|
272
274
|
// launcher must be restarted instead.
|
|
273
275
|
steps.push(cliStartedIt
|
|
274
|
-
? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours
|
|
276
|
+
? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours-daemon', 'restart', '--config', join(dir, 'config.json')] }
|
|
275
277
|
: { id: 'restart-external', label: 'daemon was not started by the CLI — restart it with its own launcher', command: null });
|
|
276
278
|
}
|
|
277
279
|
steps.push({ id: 'service', label: 'install the boot service', command: serviceInstallCommand({ stateDir: dir }) });
|
|
278
280
|
return steps;
|
|
279
281
|
}
|
|
280
282
|
|
|
281
|
-
export const SERVER_PACKAGES = ['sdk', 'cli', '
|
|
283
|
+
export const SERVER_PACKAGES = ['sdk', 'cli', 'daemon', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
|
|
284
|
+
/** Selected when the policy names it; retained policies from before it existed stay valid. */
|
|
285
|
+
export const NOTIFICATIONS_PACKAGE = '@ours.network/notifications';
|
|
286
|
+
const serverPackages = manifest => [...SERVER_PACKAGES, ...(manifest?.packages?.[NOTIFICATIONS_PACKAGE] ? [NOTIFICATIONS_PACKAGE] : [])];
|
|
282
287
|
export const SERVER_DEPENDENCIES = {
|
|
283
288
|
daemon: [], telegram: ['daemon'], cowork: ['daemon'], messenger: ['daemon'],
|
|
284
289
|
};
|
|
@@ -294,7 +299,7 @@ export function maintenanceServices(record, domain) {
|
|
|
294
299
|
|
|
295
300
|
/** Validate exact supplied selections, without rewriting the source authority. */
|
|
296
301
|
export function selectSourcePackages(manifest, role, clients = []) {
|
|
297
|
-
const names = role === 'server' ?
|
|
302
|
+
const names = role === 'server' ? serverPackages(manifest) : clients.map(n => `@ours.network/${n}`);
|
|
298
303
|
const result = {};
|
|
299
304
|
for (const name of names) {
|
|
300
305
|
const selected = manifest?.packages?.[name];
|
|
@@ -312,7 +317,7 @@ export function selectSourcePackages(manifest, role, clients = []) {
|
|
|
312
317
|
/** Resolve a packaged compatibility policy into a role-filtered exact selection. */
|
|
313
318
|
export async function resolveSourcePolicy(manifest, role, clients = [], resolveNpm) {
|
|
314
319
|
const release = releaseBinding(manifest);
|
|
315
|
-
const names = role === 'server' ?
|
|
320
|
+
const names = role === 'server' ? serverPackages(manifest) : clients.map(name => `@ours.network/${name}`);
|
|
316
321
|
const packages = {};
|
|
317
322
|
const sourceNames = new Set();
|
|
318
323
|
for (const name of names) {
|
|
@@ -356,15 +361,21 @@ export function installationPaths(record) {
|
|
|
356
361
|
}
|
|
357
362
|
|
|
358
363
|
export function validateInstallation(record, root) {
|
|
364
|
+
if (record) validateContainerEngine(record);
|
|
359
365
|
if (!record || ![1, 2].includes(record.schema) || !['docker', 'packages'].includes(record.mode)
|
|
360
366
|
|| record.root !== root || record.configPath !== installationPaths(record).config
|
|
361
367
|
|| record.sourcesPath !== join(root, 'sources.json') || record.workDir !== join(root, 'runtime')
|
|
362
368
|
|| !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(record.instanceId ?? '')
|
|
363
369
|
|| !/^ours-[a-z0-9]+$/.test(record.project ?? '')
|
|
364
370
|
|| (record.sourcePolicyHash !== undefined && !/^[0-9a-f]{64}$/.test(record.sourcePolicyHash))
|
|
365
|
-
|| !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s))) {
|
|
371
|
+
|| !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s) && !(s === 'gateway' && record.gateway?.version === 1))) {
|
|
366
372
|
throw new Error('Invalid or conflicting installation selection');
|
|
367
373
|
}
|
|
374
|
+
if (record.gateway !== undefined && (record.schema !== 2 || record.mode !== 'docker' || record.gateway?.version !== 1
|
|
375
|
+
|| Object.keys(record.gateway).some(key => !['version', 'serverUrl'].includes(key)) || record.services.at(-1) !== 'gateway')) {
|
|
376
|
+
throw new Error('Invalid gateway installation selection');
|
|
377
|
+
}
|
|
378
|
+
if (record.gateway) gatewayAddress(record);
|
|
368
379
|
if (record.layoutConversion !== undefined) {
|
|
369
380
|
// DEPRECATED (introduced in 2.0): legacy managed-layout conversion only.
|
|
370
381
|
// Removal target: 3.0, after supported installs convert and upgrade inputs
|
|
@@ -380,7 +391,8 @@ export function validateInstallation(record, root) {
|
|
|
380
391
|
throw new Error('Invalid layout conversion record');
|
|
381
392
|
}
|
|
382
393
|
validateInstallation(source, root);
|
|
383
|
-
if (
|
|
394
|
+
if (JSON.stringify(source.containerBinding) !== JSON.stringify(record.containerBinding)) throw new Error('Conflicting conversion backend');
|
|
395
|
+
if (['mode', 'containerEngine', 'instanceId', 'project', 'sourcesPath', 'workDir'].some(key => source[key] !== record[key])
|
|
384
396
|
|| JSON.stringify(source.services) !== JSON.stringify(record.services)
|
|
385
397
|
|| conversion.runningServices.some(service => !source.services.includes(service))) {
|
|
386
398
|
throw new Error('Conflicting layout conversion source');
|
|
@@ -402,7 +414,7 @@ export function validateInstallation(record, root) {
|
|
|
402
414
|
throw new Error('Invalid server build transition');
|
|
403
415
|
}
|
|
404
416
|
validateInstallation(candidate, candidate.root);
|
|
405
|
-
for (const key of ['schema', 'mode', 'instanceId', 'services', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
417
|
+
for (const key of ['schema', 'mode', 'containerEngine', 'containerBinding', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
406
418
|
if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
|
|
407
419
|
}
|
|
408
420
|
}
|
|
@@ -470,3 +482,7 @@ export function consumerServiceState(text, service, platform) {
|
|
|
470
482
|
}
|
|
471
483
|
return matches === 1 ? selected : undefined;
|
|
472
484
|
}
|
|
485
|
+
|
|
486
|
+
export function clientPackageNames(integrations) {
|
|
487
|
+
return [...new Set(['sdk', 'cli', ...(integrations.some(name => ['codex', 'claude-code'].includes(name)) ? ['mcp'] : []), ...integrations])];
|
|
488
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { runContainer } from './container-engine.mjs';
|
|
6
|
+
|
|
7
|
+
export const PROBE_IMAGE = 'docker.io/library/nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236';
|
|
8
|
+
/** Behavioral checks, in disposable resources, before touching installation storage. */
|
|
9
|
+
export async function qualifyPodman(effects, record) {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), 'ours-podman-preflight-'));
|
|
11
|
+
const project = `ours-probe-${randomUUID()}`;
|
|
12
|
+
const file = join(root, 'compose.json'), overlay = join(root, 'reset.yaml');
|
|
13
|
+
const common = { image: PROBE_IMAGE, entrypoint: ['/bin/sh', '-ec'], read_only: true, cap_drop: ['ALL'], security_opt: ['no-new-privileges:true'] };
|
|
14
|
+
writeFileSync(file, JSON.stringify({ services: {
|
|
15
|
+
prepare: { ...common, user: '0:0', network_mode: 'none', cap_add: ['CHOWN', 'DAC_OVERRIDE'],
|
|
16
|
+
command: ['mkdir -p /storage/state/daemon /storage/state/sibling; chmod 700 /storage/state/daemon; chown 1000:1000 /storage/state/daemon'],
|
|
17
|
+
volumes: [{ type: 'volume', source: 'data', target: '/storage', volume: { nocopy: true } }] },
|
|
18
|
+
probe: { ...common, user: '1000:1000', command: ['test ! -e /data/sibling; test "$(stat -c %a /data)" = 700; nslookup -type=A probe.; echo ready > /data/probe; sleep 120'],
|
|
19
|
+
healthcheck: { test: ['CMD-SHELL', 'test -s /data/probe'], interval: '1s', timeout: '2s', retries: 10 },
|
|
20
|
+
volumes: [{ type: 'volume', source: 'data', target: '/data', volume: { nocopy: true, subpath: 'state/daemon' } }] },
|
|
21
|
+
}, volumes: { data: {} } }), { mode: 0o600 });
|
|
22
|
+
writeFileSync(overlay, 'services:\n probe:\n ports: !reset []\n', { mode: 0o600 });
|
|
23
|
+
const compose = args => runContainer(effects, record, ['compose', '-p', project, '-f', file, '-f', overlay, ...args], { timeout: 180000 });
|
|
24
|
+
try {
|
|
25
|
+
await compose(['config', '--format', 'json']);
|
|
26
|
+
await compose(['run', '--rm', '--no-deps', '-T', 'prepare']);
|
|
27
|
+
await compose(['up', '-d', '--no-build', '--wait', 'probe']);
|
|
28
|
+
const result = await compose(['ps', '--format', 'json']);
|
|
29
|
+
const rows = result.stdout.trim().startsWith('[') ? JSON.parse(result.stdout) : result.stdout.trim().split('\n').map(s => JSON.parse(s));
|
|
30
|
+
if (!rows.some(r => r.Service === 'probe' && r.State === 'running')) throw new Error('Compose JSON status is incompatible');
|
|
31
|
+
await compose(['exec', '-T', 'probe', '/bin/sh', '-ec', 'test -s /data/probe; test ! -e /data/state']);
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
let logs = '';
|
|
34
|
+
try { logs = (await compose(['logs', '--no-color', '--tail', '30'])).stdout; } catch {}
|
|
35
|
+
throw new Error(`Podman capability check failed (Compose !reset/wait/status, DNS, rootless UID, volume subpath): ${cause.message}\n${logs}`, { cause });
|
|
36
|
+
} finally {
|
|
37
|
+
try { await compose(['down', '--volumes', '--remove-orphans']); }
|
|
38
|
+
finally { rmSync(root, { recursive: true, force: true }); }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
2
|
/** Human bootstrap and local client handoff through the existing owner interfaces. */
|
|
2
3
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
4
5
|
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import { installationPaths } from './plan.mjs';
|
|
6
7
|
import { validateHostProfile } from './target.mjs';
|
|
8
|
+
/** Read by Fleet beside the profile credential; bound to one server installation. */
|
|
9
|
+
export const NOTIFICATIONS_PRODUCER_FILE = 'notifications-producer.json';
|
|
7
10
|
|
|
8
11
|
export function validateIdentityName(name) {
|
|
9
12
|
if (typeof name !== 'string' || [...name].length < 1 || [...name].length > 64 || name !== name.normalize('NFC')
|
|
@@ -28,16 +31,67 @@ function validateRecord(record) {
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// CLI client commands only accept gateway profiles in current releases. Use
|
|
35
|
+
// the installed SDK against the selected local daemon and its private file.
|
|
36
|
+
const identityScript = `
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import { pathToFileURL } from 'node:url';
|
|
39
|
+
import { randomUUID } from 'node:crypto';
|
|
40
|
+
const selection = JSON.parse(process.argv[1]);
|
|
41
|
+
const require = createRequire(selection.anchor);
|
|
42
|
+
const { attachOursClient } = await import(pathToFileURL(require.resolve('@ours.network/sdk/client')).href);
|
|
43
|
+
const client = await attachOursClient({ endpoint: selection.endpoint,
|
|
44
|
+
expectedInstanceId: selection.instanceId, credentialPath: selection.credentialPath,
|
|
45
|
+
sessionMode: 'external', leaseToken: randomUUID(), env: {} });
|
|
46
|
+
// The lease this short-lived client takes is released exactly once. An operation is not reported as done
|
|
47
|
+
// while that release is incomplete, and what failed first is what is reported.
|
|
48
|
+
let output, released = false;
|
|
49
|
+
try {
|
|
50
|
+
const result = selection.operation === 'list' ? await client.listIdentities()
|
|
51
|
+
: selection.operation === 'create-role' ? { created: await client.createIdentity({ name: selection.name, bio: '',
|
|
52
|
+
exposeLocal: true, localAutoAccept: true }), current: await client.currentIdentity() }
|
|
53
|
+
: selection.operation === 'describe-role' ? (await client.chooseIdentity({ name: selection.name, force: false }),
|
|
54
|
+
{ current: await client.currentIdentity() })
|
|
55
|
+
: await client.createRootIdentity({ name: selection.name, bio: '',
|
|
56
|
+
exposeLocal: true, localAutoAccept: true, skipIfRootExists: true });
|
|
57
|
+
released = true;
|
|
58
|
+
const release = await client.releaseLease();
|
|
59
|
+
output = release?.failed > 0 ? { onboardingError: 'LEASE_RELEASE_INCOMPLETE' } : result;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
output = { onboardingError: error.code || 'OWNER_OPERATION_FAILED' };
|
|
62
|
+
if (!released) try { await client.releaseLease(); } catch {}
|
|
63
|
+
} finally {
|
|
64
|
+
try { await client.close(); } catch {}
|
|
65
|
+
}
|
|
66
|
+
process.stdout.write(JSON.stringify(output));
|
|
67
|
+
`;
|
|
68
|
+
async function identityCommand(record, operation, name) {
|
|
69
|
+
validateRecord(record);
|
|
70
|
+
const docker = record.mode === 'docker';
|
|
71
|
+
const selection = {
|
|
72
|
+
operation, ...(name === undefined ? {} : { name }),
|
|
73
|
+
endpoint: `http://127.0.0.1:${docker ? 3050 : record.port}`,
|
|
74
|
+
instanceId: record.instanceId,
|
|
75
|
+
credentialPath: docker ? '/var/lib/ours/daemon-token' : join(installationPaths(record).daemon, 'daemon-token'),
|
|
76
|
+
anchor: docker ? '/opt/ours/package.json' : join(record.workDir, 'package.json'),
|
|
77
|
+
};
|
|
78
|
+
const args = ['--input-type=module', '-e', identityScript, JSON.stringify(selection)];
|
|
79
|
+
const result = docker
|
|
80
|
+
? await compose(record, ['exec', '-T', 'daemon', 'node', ...args])
|
|
81
|
+
: await effects.run(process.execPath, args, { env: localEnv(record), sensitive: true });
|
|
35
82
|
if (result.code !== undefined && result.code !== 0) throw new Error('Owner identity operation failed');
|
|
36
|
-
|
|
83
|
+
let value;
|
|
84
|
+
try { value = JSON.parse(result.stdout); }
|
|
37
85
|
catch { throw new Error('Owner identity operation returned malformed JSON'); }
|
|
86
|
+
if (value?.onboardingError) {
|
|
87
|
+
const error = new Error('Owner identity operation failed');
|
|
88
|
+
error.code = value.onboardingError;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
38
92
|
}
|
|
39
93
|
async function identities(record) {
|
|
40
|
-
const rows = await identityCommand(record,
|
|
94
|
+
const rows = await identityCommand(record, 'list');
|
|
41
95
|
if (!Array.isArray(rows) || rows.some(row => !row || typeof row.name !== 'string'
|
|
42
96
|
|| (!['root', 'role'].includes(row.kind) && !['reconciling', 'awaiting-root', 'migration-failed', 'refresh-failed'].includes(row.status))
|
|
43
97
|
|| (row.kind && (typeof row.cid !== 'string' || !row.cid)))) throw new Error('Owner identity list is malformed');
|
|
@@ -59,10 +113,11 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
59
113
|
if (prior.rows.some(row => row.name === name)) throw new Error('Requested Human identity name already exists; no identity was changed');
|
|
60
114
|
effects.out?.(`Creating Human identity ${name}; retaining ${prior.rows.length} existing identities.`);
|
|
61
115
|
try {
|
|
62
|
-
await identityCommand(record,
|
|
116
|
+
await identityCommand(record, 'create-root', name);
|
|
63
117
|
} catch (error) {
|
|
64
|
-
//
|
|
118
|
+
// A concurrent creator is safe only
|
|
65
119
|
// when the authoritative list now contains a root; other errors stay errors.
|
|
120
|
+
if (error.code !== 'ROOT_EXISTS') throw error;
|
|
66
121
|
const after = await identities(record);
|
|
67
122
|
if (after.root) return retained(after);
|
|
68
123
|
throw error;
|
|
@@ -73,16 +128,98 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
73
128
|
effects.out?.(`Human identity ${after.root.name} is ready.`);
|
|
74
129
|
return { name: after.root.name, cid: after.root.cid, created: true };
|
|
75
130
|
},
|
|
131
|
+
/**
|
|
132
|
+
* The person's own identity under the Human root, which Messenger runs as. An identity of that name is
|
|
133
|
+
* used only when the daemon itself describes it as a permanent role of exactly the retained root;
|
|
134
|
+
* nothing else is renamed or adopted.
|
|
135
|
+
*/
|
|
136
|
+
async serverEnsureMessengerIdentity(record, name) {
|
|
137
|
+
validateRecord(record);
|
|
138
|
+
validateIdentityName(name);
|
|
139
|
+
const listed = row => row?.kind === 'role' && row.temp === null && typeof row.cid === 'string';
|
|
140
|
+
const taken = () => new Error('Requested Messenger identity name is already used by another identity; no identity was changed');
|
|
141
|
+
const prior = await identities(record);
|
|
142
|
+
if (!prior.root) throw new Error('Messenger identity requires the Human identity; no identity was changed');
|
|
143
|
+
// The list has no parent column. What the daemon describes for the identity, bound for a moment, names its root.
|
|
144
|
+
const under = (current, row) => current?.name === name && current.temporary === false && current.isRoot === false
|
|
145
|
+
&& current.described === true && typeof current.roleId === 'string' && current.roleId !== ''
|
|
146
|
+
&& typeof current.cid === 'string' && current.cid === row.cid
|
|
147
|
+
&& typeof current.rootCid === 'string' && current.rootCid.toLowerCase() === String(prior.root.cid).toLowerCase();
|
|
148
|
+
const adopt = async rows => {
|
|
149
|
+
const row = rows.rows.find(candidate => candidate.name === name);
|
|
150
|
+
if (rows.root?.cid !== prior.root.cid || !listed(row)) throw taken();
|
|
151
|
+
const { current } = await identityCommand(record, 'describe-role', name);
|
|
152
|
+
if (!under(current, row)) throw taken();
|
|
153
|
+
return row;
|
|
154
|
+
};
|
|
155
|
+
if (prior.rows.some(row => row.name === name)) {
|
|
156
|
+
const row = await adopt(prior);
|
|
157
|
+
effects.out?.(`Retained Messenger identity ${name}.`);
|
|
158
|
+
return { name, cid: row.cid, created: false };
|
|
159
|
+
}
|
|
160
|
+
effects.out?.(`Creating Messenger identity ${name} under ${prior.root.name}; retaining ${prior.rows.length} existing identities.`);
|
|
161
|
+
let result;
|
|
162
|
+
try {
|
|
163
|
+
result = await identityCommand(record, 'create-role', name);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// Only a name collision can mean a concurrent creator; any other failure is reported as it is.
|
|
166
|
+
if (error.code !== 'NAME_TAKEN') throw error;
|
|
167
|
+
// That creator is accepted only when the authoritative list now shows the name under the same root.
|
|
168
|
+
const after = await identities(record);
|
|
169
|
+
if (!after.rows.some(row => row.name === name)) throw error;
|
|
170
|
+
const row = await adopt(after);
|
|
171
|
+
return { name, cid: row.cid, created: false };
|
|
172
|
+
}
|
|
173
|
+
const after = await identities(record);
|
|
174
|
+
const row = after.rows.find(candidate => candidate.name === name);
|
|
175
|
+
if (result?.created?.hierarchy !== 'role' || result.created.underRoot !== prior.root.name || after.root?.cid !== prior.root.cid
|
|
176
|
+
|| !listed(row) || result.created.info?.cid !== row.cid || !under(result.current, row))
|
|
177
|
+
throw new Error('Messenger identity creation completed without a usable identity under the Human identity');
|
|
178
|
+
effects.out?.(`Messenger identity ${name} is ready.`);
|
|
179
|
+
return { name, cid: row.cid, created: true };
|
|
180
|
+
},
|
|
181
|
+
async serverEnsureHumanProfile(record, human) {
|
|
182
|
+
validateRecord(record);
|
|
183
|
+
for (const key of ['name', 'surname']) if (typeof human[key] !== 'string' || !human[key].trim() || human[key].length > 100 || /[\x00-\x1f\x7f]/.test(human[key])) throw new Error('Invalid Messenger human profile');
|
|
184
|
+
// Messenger owns the profile lifecycle. Never rename the daemon root or
|
|
185
|
+
// overwrite a profile already present on a retained installation.
|
|
186
|
+
const script = `
|
|
187
|
+
import { readFileSync } from 'node:fs';
|
|
188
|
+
const selection = JSON.parse(process.argv[1]);
|
|
189
|
+
const token = readFileSync(selection.credentialPath, 'utf8').trim();
|
|
190
|
+
const base = selection.base;
|
|
191
|
+
async function request(path, value) {
|
|
192
|
+
const response = await fetch(base + '/api/' + path, { method: value === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { 'X-Ours-Api-Token': token, ...(value === undefined ? {} : { 'Content-Type': 'application/json', Origin: selection.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }) });
|
|
193
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error('Messenger human profile operation failed (HTTP ' + response.status + ')'); }
|
|
194
|
+
return response.json();
|
|
195
|
+
}
|
|
196
|
+
const identity = await request('identity');
|
|
197
|
+
if (identity.humanProfile) process.stdout.write('Retained Messenger Name and Surname.');
|
|
198
|
+
else { await request('identity/profile', selection.human); process.stdout.write('Messenger Name and Surname initialized.'); }
|
|
199
|
+
`;
|
|
200
|
+
const docker = record.mode === 'docker';
|
|
201
|
+
const selection = { base: `http://127.0.0.1:${docker ? 8420 : record.messengerPort}`,
|
|
202
|
+
// Behind the gateway Messenger accepts mutations only from the gateway origin.
|
|
203
|
+
origin: record.gateway ? gatewayAddress(record).origin : `http://127.0.0.1:${record.messengerPort}`,
|
|
204
|
+
credentialPath: docker ? '/credentials/messenger/daemon-token' : installationPaths(record).credentials.messenger,
|
|
205
|
+
human: { name: human.name, surname: human.surname } };
|
|
206
|
+
const args = ['--input-type=module', '-e', script, JSON.stringify(selection)];
|
|
207
|
+
const result = docker ? await compose(record, ['exec', '-T', 'messenger', 'node', ...args])
|
|
208
|
+
: await effects.run(process.execPath, args, { env: localEnv(record), sensitive: true });
|
|
209
|
+
if (result.code !== undefined && result.code !== 0) throw new Error('Messenger human profile initialization failed');
|
|
210
|
+
effects.out?.(result.stdout);
|
|
211
|
+
},
|
|
76
212
|
async prepareLocalClient(record, integrations, fleetSettingsPath) {
|
|
77
213
|
validateRecord(record);
|
|
78
|
-
if (!Array.isArray(integrations) ||
|
|
214
|
+
if (!Array.isArray(integrations) || new Set(integrations).size !== integrations.length
|
|
79
215
|
|| integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name))) throw new Error('Client integrations must select codex, claude-code and/or fleet');
|
|
80
216
|
if (fleetSettingsPath !== undefined) {
|
|
81
217
|
if (typeof fleetSettingsPath !== 'string' || !isAbsolute(fleetSettingsPath) || resolve(fleetSettingsPath) !== fleetSettingsPath) throw new Error('Fleet settings path must be absolute');
|
|
82
218
|
const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
|
|
83
219
|
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
|
|
84
220
|
}
|
|
85
|
-
const
|
|
221
|
+
const serverUrl = record.gateway ? gatewayAddress(record).base : undefined;
|
|
222
|
+
const endpoint = serverUrl ? `${serverUrl}/daemon` : `http://127.0.0.1:${record.port}`;
|
|
86
223
|
const current = effects.readManagedClientProfile();
|
|
87
224
|
if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
|
|
88
225
|
privatePath(record.root, true);
|
|
@@ -93,16 +230,27 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
93
230
|
const published = join(root, 'issued-' + randomUUID());
|
|
94
231
|
const credential = join(stage, 'credential');
|
|
95
232
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
233
|
+
if (!current) {
|
|
234
|
+
effects.out?.('Issuing a separate local client credential with the retained server authority.');
|
|
235
|
+
try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
|
|
236
|
+
catch (cause) { throw new Error('Client credential issuance failed; existing profiles were retained', { cause }); }
|
|
237
|
+
}
|
|
238
|
+
const retainedCredential = current?.credentialPath ?? credential;
|
|
239
|
+
const stat = privatePath(retainedCredential);
|
|
240
|
+
if (stat.size > 4096 || !readFileSync(retainedCredential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
|
|
101
241
|
const profile = {
|
|
102
|
-
...validateHostProfile({ endpoint, expectedInstanceId: record.instanceId, credentialPath: join(published, 'credential') }),
|
|
242
|
+
...validateHostProfile({ ...(serverUrl ? { serverUrl } : {}), endpoint, expectedInstanceId: record.instanceId, credentialPath: current?.credentialPath ?? join(published, 'credential') }),
|
|
103
243
|
installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
|
|
104
244
|
};
|
|
105
245
|
writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
246
|
+
// Fleet on this host produces agent notifications through the server gateway.
|
|
247
|
+
// A runtime without the notification service prepares no producer credential.
|
|
248
|
+
const issued = record.gateway ? await compose(record, ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'cat', 'messenger', '/var/lib/ours-notifications/fleet-producer'], { sensitive: true, allowCodes: [1] }) : null;
|
|
249
|
+
if (issued && (issued.code ?? 0) === 0) {
|
|
250
|
+
const token = String(issued.stdout ?? '');
|
|
251
|
+
if (!/^[A-Za-z0-9_-]{32,256}$/.test(token)) throw new Error('Notification producer credential is invalid; repeat server install');
|
|
252
|
+
writeFileSync(join(stage, NOTIFICATIONS_PRODUCER_FILE), JSON.stringify({ schema: 1, serverUrl, expectedInstanceId: record.instanceId, token }) + '\n', { flag: 'wx', mode: 0o600 });
|
|
253
|
+
}
|
|
106
254
|
// Publish the complete pair in one rename. Managed-default activation and
|
|
107
255
|
// package/source selection remain the normal client installer's job.
|
|
108
256
|
renameSync(stage, published);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Fresh selections only; retained installations keep their recorded addresses. */
|
|
2
|
+
export function selectServerPorts(record, isTaken, explicitPorts = [], out = () => {}) {
|
|
3
|
+
const reserved = new Set([3051]);
|
|
4
|
+
for (const key of ['port', 'coworkPort', 'messengerPort']) {
|
|
5
|
+
const preferred = record[key];
|
|
6
|
+
let selected = preferred;
|
|
7
|
+
while (reserved.has(selected) || isTaken(selected)) {
|
|
8
|
+
if (explicitPorts.includes(key)) throw Error(`Requested ${key} ${preferred} is unavailable; select another explicit port`);
|
|
9
|
+
selected++;
|
|
10
|
+
if (selected > 65535) throw Error(`No free loopback port available for ${key}`);
|
|
11
|
+
}
|
|
12
|
+
reserved.add(selected);
|
|
13
|
+
record[key] = selected;
|
|
14
|
+
if (selected !== preferred) out(`${key}: ${preferred} is unavailable; selected loopback port ${selected}`);
|
|
15
|
+
}
|
|
16
|
+
return record;
|
|
17
|
+
}
|