@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.
Files changed (57) hide show
  1. package/GATEWAY_SETUP.md +178 -0
  2. package/README.md +453 -9
  3. package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
  4. package/assets/Dockerfile +14 -12
  5. package/assets/Dockerfile.gateway +5 -0
  6. package/assets/docker-compose.yaml +8 -1
  7. package/assets/fleet-settings.example.json +48 -0
  8. package/assets/gateway-entrypoint.sh +37 -0
  9. package/assets/release-lock.json +1139 -4470
  10. package/assets/release.json +37 -19
  11. package/assets/scripts/build/build-common.mjs +2 -1
  12. package/assets/scripts/build/build-sdk.mjs +6 -1
  13. package/assets/scripts/maintenance/build-context.mjs +1 -1
  14. package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
  15. package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
  16. package/assets/scripts/maintenance/release-graph.mjs +18 -1
  17. package/assets/scripts/maintenance/state-operation.mjs +12 -2
  18. package/assets/scripts/runtime/README.md +7 -0
  19. package/assets/scripts/runtime/client-setup.mjs +80 -6
  20. package/assets/scripts/runtime/dependency-ready.mjs +13 -0
  21. package/assets/scripts/runtime/diagnostics.mjs +72 -0
  22. package/assets/scripts/runtime/entrypoint.sh +5 -1
  23. package/assets/scripts/runtime/health-messenger.mjs +7 -6
  24. package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
  25. package/assets/scripts/runtime/recover.mjs +165 -0
  26. package/assets/scripts/runtime/start-cowork.sh +5 -0
  27. package/assets/scripts/runtime/start-messenger.sh +29 -4
  28. package/assets/scripts/runtime/start-telegram.sh +5 -0
  29. package/assets/scripts/runtime/supervise.mjs +35 -0
  30. package/assets/sources.json +54 -28
  31. package/install.mjs +7 -2
  32. package/lib/boot-readiness.mjs +43 -0
  33. package/lib/build-transition.mjs +3 -0
  34. package/lib/client-cli.mjs +127 -0
  35. package/lib/container-engine.mjs +127 -0
  36. package/lib/diagnostics.mjs +1 -0
  37. package/lib/docker-conversion-runtime.mjs +3 -2
  38. package/lib/docker-layout-installation.mjs +2 -1
  39. package/lib/docker-recovery.mjs +55 -0
  40. package/lib/docker-runtime-repair.mjs +160 -0
  41. package/lib/effects.mjs +323 -132
  42. package/lib/gateway-transition.mjs +87 -0
  43. package/lib/gateway.mjs +158 -0
  44. package/lib/legacy-migration.mjs +13 -2
  45. package/lib/orchestrate-uninstall.mjs +18 -6
  46. package/lib/orchestrate.mjs +193 -43
  47. package/lib/plan.mjs +27 -11
  48. package/lib/podman-preflight.mjs +40 -0
  49. package/lib/server-onboarding.mjs +164 -16
  50. package/lib/server-ports.mjs +17 -0
  51. package/lib/setup-options.mjs +99 -28
  52. package/lib/setup.mjs +35 -17
  53. package/lib/target.mjs +24 -8
  54. package/lib/uninstall.mjs +2 -2
  55. package/lib/usage.mjs +21 -7
  56. package/lib/workspace-setup.mjs +83 -0
  57. 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', 'daemon', 'serve', '--config'],
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', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
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', 'daemon', 'start', '--config', join(dir, 'config.json')] });
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', 'daemon', 'restart', '--config', join(dir, 'config.json')] }
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', 'mcp', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
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' ? SERVER_PACKAGES : clients.map(n => `@ours.network/${n}`);
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' ? SERVER_PACKAGES : clients.map(name => `@ours.network/${name}`);
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 (['mode', 'instanceId', 'project', 'sourcesPath', 'workDir'].some(key => source[key] !== record[key])
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
- async function identityCommand(record, args) {
32
- const result = record.mode === 'docker'
33
- ? await compose(record, ['exec', '-T', 'daemon', 'node', '/opt/ours/node_modules/@ours.network/cli/dist/cli.js', ...args, '--config', '/var/lib/ours/config.json', '--state-dir', '/var/lib/ours', '--json'])
34
- : await effects.run(bin(record, 'ours'), [...args, '--config', record.configPath, '--state-dir', installationPaths(record).daemon, '--json'], { env: localEnv(record) });
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
- try { return JSON.parse(result.stdout); }
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, ['identity', 'list']);
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, ['identity', 'create-root', '--name', name, '--skip-if-root-exists', 'true']);
116
+ await identityCommand(record, 'create-root', name);
63
117
  } catch (error) {
64
- // ROOT_EXISTS is currently a CLI error. A concurrent creator is safe only
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) || !integrations.length || new Set(integrations).size !== integrations.length
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 endpoint = `http://127.0.0.1:${record.port}`;
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
- effects.out?.('Issuing a separate local client credential with the retained server authority.');
97
- try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
98
- catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
99
- const stat = privatePath(credential);
100
- if (stat.size > 4096 || !readFileSync(credential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
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
+ }