@ours.network/install 1.2.1-nightly.11 → 1.2.1-nightly.13

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/lib/effects.mjs CHANGED
@@ -20,6 +20,7 @@ import { randomUUID, createHash } from 'node:crypto';
20
20
  import { dirname, join, resolve } from 'node:path';
21
21
  import { clientPackageNames, maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
22
22
  import { validateHostProfile } from './target.mjs';
23
+ import { serverBase, validateGatewayDiscovery, gatewayCompose, gatewayNginx, gatewayAddress } from './gateway.mjs';
23
24
  import { createServerOnboarding } from './server-onboarding.mjs';
24
25
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
25
26
  import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
@@ -537,6 +538,7 @@ export function networkEffects(effects) {
537
538
  const baseEnv = (record) => ({
538
539
  OURS_DAEMON_ID: record.instanceId,
539
540
  OURS_IMAGE: `${record.project}:runtime`,
541
+ OURS_GATEWAY_IMAGE: `${record.project}:gateway`,
540
542
  OURS_MAINTENANCE_IMAGE: `${record.project}:maintenance`,
541
543
  OURS_UID: String(record.uid ?? 1000), OURS_GID: String(record.gid ?? 1000),
542
544
  OURS_HOST_PORT: String(record.port ?? 3050),
@@ -548,6 +550,7 @@ export function networkEffects(effects) {
548
550
  'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
549
551
  record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
550
552
  ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
553
+ ...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
551
554
  '--project-name', record.project, ...args,
552
555
  ];
553
556
  const compose = (record, args, options = {}) => effects.run('docker', composeArgs(record, args),
@@ -630,7 +633,7 @@ export function networkEffects(effects) {
630
633
  const instanceId = env.OURS_DAEMON_ID || randomUUID();
631
634
  if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(instanceId)) throw new Error('OURS_DAEMON_ID must be a lowercase UUID');
632
635
  const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
633
- return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES], port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
636
+ return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES, ...(mode === 'docker' ? ['gateway'] : [])], ...(mode === 'docker' ? { gateway: { version: 1 } } : {}), port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
634
637
  },
635
638
  async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
636
639
  if (existing) {
@@ -709,6 +712,44 @@ export function networkEffects(effects) {
709
712
  if (!retainConfig) writePrivateNew(record.configPath, JSON.stringify({ stateDir: record.mode === 'docker' ? '/var/lib/ours' : installationPaths(record).daemon, port: record.port, apiVisibility: 'owner' }, null, 2) + '\n');
710
713
  },
711
714
  async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
715
+ async prepareGateway(record) {
716
+ atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
717
+ atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
718
+ atomicWriteConfig(join(record.workDir, 'Dockerfile.gateway'), readFileSync(join(INSTALLER_ASSETS, 'Dockerfile.gateway'), 'utf8'));
719
+ await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
720
+ },
721
+ async removeGatewayContainer(record) { await compose(record, ['rm', '-f', 'gateway']); },
722
+ async verifyGateway(record) {
723
+ const { prefix } = gatewayAddress(record);
724
+ const script = `const fs=require('node:fs');(async()=>{
725
+ const base=${JSON.stringify('http://gateway:8080' + prefix)};
726
+ const get=async(path,options={})=>{const r=await fetch(base+path,{...options,redirect:'error',signal:AbortSignal.timeout(5000)});if(!r.ok)throw Error('Gateway readiness HTTP '+r.status);return r.json();};
727
+ const selection=await get('/daemon/selection');
728
+ if(selection.instanceId!==${JSON.stringify(record.instanceId)})throw Error('Gateway daemon instance mismatch');
729
+ const denied=await fetch(base+'/cowork/management/rpc',{method:'POST',redirect:'error',signal:AbortSignal.timeout(5000),headers:{'content-type':'application/json'},body:'{}'});
730
+ await denied.body?.cancel();if(denied.status!==401)throw Error('Gateway management does not reject unauthenticated requests');
731
+ const token=fs.readFileSync('/var/lib/ours/daemon-token','utf8').trim();
732
+ const headers={'x-ours-api-token':token,'content-type':'application/json'};
733
+ const identities=await get('/daemon/identities',{headers});
734
+ if(!Array.isArray(identities.identities))throw Error('Gateway daemon API invalid');
735
+ const result=await get('/cowork/management/rpc',{method:'POST',headers,body:JSON.stringify({version:1,id:'installer-readiness',method:'room.list',params:{}})});
736
+ if(result.version!==1||result.id!=='installer-readiness'||!Array.isArray(result.result)||result.error)throw Error('Gateway Cowork management unavailable');
737
+ })().catch(()=>{console.error('Authenticated gateway readiness failed');process.exitCode=1;});`;
738
+ await compose(record, ['exec', '-T', 'daemon', 'node', '-e', script], { sensitive: true });
739
+ },
740
+ async qualifyGatewayRuntime(record) {
741
+ for (const [name, args, capability] of [
742
+ ['cowork', ['--json', 'capabilities'], 'cowork.http-management-v1'],
743
+ ['messenger-server', ['capabilities'], 'messenger.gateway-prefix-v1'],
744
+ ['tg-connector', ['capabilities'], 'telegram.gateway-listener-v1'],
745
+ ]) {
746
+ const probe = await effects.run('docker', ['run', '--rm', '--network', 'none', '--read-only', '--entrypoint', 'node', `${record.project}:runtime`, `/opt/ours/node_modules/@ours.network/${name}/dist/cli.js`, ...args]);
747
+ let value; try { value = JSON.parse(probe.stdout); } catch {}
748
+ const capabilities = value?.result?.capabilities ?? value?.capabilities;
749
+ if (!Array.isArray(capabilities) || !capabilities.includes(capability))
750
+ throw new Error(`Selected ${name} artifact lacks ${capability}; select a compatible release before enabling the gateway`);
751
+ }
752
+ },
712
753
  async prepareInstallation(record, { runtimeOnly = false } = {}) {
713
754
  let copied = false;
714
755
  if (!existsSync(record.workDir)) {
@@ -723,12 +764,20 @@ export function networkEffects(effects) {
723
764
  else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
724
765
  if (record.mode === 'docker') {
725
766
  refreshDockerPolicyCopy(record);
767
+ if (record.gateway) {
768
+ atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
769
+ atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
770
+ }
726
771
  // The installer owns these dependencies in both installation modes.
727
772
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
728
773
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
729
774
  const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
730
775
  if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
731
776
  await effects.qualifyDockerRuntime(record);
777
+ if (record.gateway) {
778
+ await effects.qualifyGatewayRuntime(record);
779
+ await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
780
+ }
732
781
  if (runtimeOnly) return;
733
782
  await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
734
783
  } else {
@@ -869,7 +918,7 @@ export function networkEffects(effects) {
869
918
  async publishServerBuild(record, candidate) {
870
919
  const previous = join(candidate.root, 'previous-runtime');
871
920
  if (record.mode === 'docker') {
872
- for (const target of ['runtime', 'maintenance']) {
921
+ for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
873
922
  const retained = `${candidate.project}:previous-${target}`;
874
923
  const found = await effects.run('docker', ['image', 'inspect', retained], { allowCodes: [1] });
875
924
  if (found.code !== 0) {
@@ -890,7 +939,7 @@ export function networkEffects(effects) {
890
939
  renameSync(candidate.workDir, record.workDir);
891
940
  } else privateDirectory(record.workDir);
892
941
  if (record.mode === 'docker') {
893
- for (const target of ['runtime', 'maintenance']) {
942
+ for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
894
943
  await effects.run('docker', ['tag', `${candidate.project}:${target}`, `${record.project}:${target}`]);
895
944
  }
896
945
  }
@@ -905,7 +954,7 @@ export function networkEffects(effects) {
905
954
  },
906
955
  async discardServerBuild(candidate) {
907
956
  if (candidate.mode === 'docker') {
908
- for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance']) {
957
+ for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance', ...(candidate.gateway ? ['gateway', 'previous-gateway'] : [])]) {
909
958
  const image = `${candidate.project}:${target}`;
910
959
  const found = await effects.run('docker', ['image', 'inspect', image], { allowCodes: [1] });
911
960
  if (found.code === 0) await effects.run('docker', ['image', 'rm', image]);
@@ -1155,14 +1204,17 @@ export function networkEffects(effects) {
1155
1204
  return JSON.parse(readFileSync(path, 'utf8'));
1156
1205
  },
1157
1206
  async discoverClientProfile(endpoint, credentialPath) {
1158
- const url = new URL(endpoint);
1159
- if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
1160
- throw new Error('Client endpoint must be an HTTP or HTTPS origin');
1161
- const response = await fetch(`${url.origin}/selection`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
1207
+ const base = serverBase(endpoint);
1208
+ const request = path => fetch(`${base}${path}`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
1209
+ const discovery = await request('/.well-known/ours');
1210
+ if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
1211
+ // Only an absent discovery resource denotes a legacy direct daemon. Never
1212
+ // reinterpret authentication, redirect, or malformed metadata as legacy.
1213
+ if (discovery.status !== 404) throw new Error(`Gateway discovery answered HTTP ${discovery.status}`);
1214
+ const response = await request('/selection');
1162
1215
  if (!response.ok) throw new Error(`Daemon selection answered HTTP ${response.status}`);
1163
1216
  const selection = await response.json();
1164
- // Full metadata and authenticated capability validation follows before publication.
1165
- return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1217
+ return validateHostProfile({ endpoint: base, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1166
1218
  },
1167
1219
  importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
1168
1220
  const root = join(home, '.ours-client');
@@ -1196,7 +1248,26 @@ export function networkEffects(effects) {
1196
1248
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1197
1249
  return { configPath, profile: validateHostProfile(saved), settings };
1198
1250
  },
1199
- async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false } = {}) {
1251
+ async qualifyInstalledGatewayClient(profile) {
1252
+ if (!profile.installer?.integrations?.includes('fleet')) return;
1253
+ let info;
1254
+ try { info = JSON.parse((await effects.run('ours-fleet', ['version', '--json'])).stdout); } catch {}
1255
+ if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
1256
+ throw new Error('Installed Fleet does not support gateway HTTP management; upgrade Fleet before gateway-enable');
1257
+ },
1258
+ async qualifyGatewayClient({ profile, sourcesPath, sources, integrations, refresh = false }) {
1259
+ if (!profile.serverUrl || !integrations.includes('fleet')) return;
1260
+ // Acquire into the retained package cache without publishing a profile,
1261
+ // credential, native command, or integration. Normal acquisition rechecks it.
1262
+ const staging = mkdtempSync(join(home, '.ours-gateway-client-'));
1263
+ try {
1264
+ const stagedSources = join(staging, 'sources.json');
1265
+ writeFileSync(stagedSources, sources ? JSON.stringify(sources, null, 2) + '\n' : readFileSync(sourcesPath), { mode: 0o600 });
1266
+ await effects.acquireClientPackages(join(home, '.ours-client/profile.json'), stagedSources, integrations,
1267
+ { refresh, gatewayServerUrl: profile.serverUrl, qualifyOnly: true });
1268
+ } finally { rmSync(staging, { recursive: true, force: true }); }
1269
+ },
1270
+ async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false, gatewayServerUrl, qualifyOnly = false } = {}) {
1200
1271
  const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
1201
1272
  // Every client installation includes the native CLI and its SDK dependency.
1202
1273
  const selected = clientPackageNames(integrations);
@@ -1234,6 +1305,13 @@ export function networkEffects(effects) {
1234
1305
  writePrivateNew(join(root, '.packages-ready'), 'ready\n');
1235
1306
  }
1236
1307
  verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1308
+ if (integrations.includes('fleet') && (gatewayServerUrl || readHostProfileFile(configPath)?.serverUrl)) {
1309
+ let info;
1310
+ try { info = JSON.parse(readFileSync(join(root, 'node_modules/@ours.network/fleet/dist/build-info.json'), 'utf8')); } catch {}
1311
+ if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
1312
+ throw new Error('Selected Fleet artifact does not support gateway HTTP management; select a compatible client release');
1313
+ }
1314
+ if (qualifyOnly) return;
1237
1315
  const cliPolicy = hostCliOnly ? null : hostCliPolicy(manifest);
1238
1316
  // Local acquisition alone does not publish native commands. Use the user's
1239
1317
  // configured npm prefix and retained dependency closure, including on retry.
@@ -0,0 +1,87 @@
1
+ import { existsSync, lstatSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { gatewayAddress } from './gateway.mjs';
4
+ import { validateInstallation } from './plan.mjs';
5
+ import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
6
+
7
+ // Called under the installation lock. No stored state or credentials are changed.
8
+ export async function enableGateway(record, args, effects) {
9
+ validateInstallation(record, record.root);
10
+ if (record.mode !== 'docker' || record.schema !== 2 || record.buildTransition || record.layoutConversion)
11
+ throw new Error('Gateway migration requires a settled schema-2 Docker installation');
12
+ const journal = join(record.root, 'gateway-transition.json');
13
+ const recordPath = join(record.root, 'installation.json');
14
+ const profilePath = join(effects.home, '.ours-client', 'profile.json');
15
+ const paths = [recordPath, ...['docker-compose.gateway.yaml', 'nginx.conf', 'Dockerfile.gateway'].map(name => join(record.workDir, name)), profilePath];
16
+ const snapshot = path => {
17
+ if (existsSync(path)) {
18
+ const stat = lstatSync(path);
19
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || (stat.mode & (path === profilePath || path === journal || path === recordPath ? 0o077 : 0o022)))
20
+ throw new Error('Gateway migration requires private owned configuration files');
21
+ }
22
+ return snapshotConfig(path);
23
+ };
24
+ async function rollback(saved) {
25
+ const phase = value => { saved.phase = value; atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n'); };
26
+ if (saved.phase === 'switching') phase('rollback-stopping');
27
+ if (saved.phase === 'rollback-stopping') {
28
+ await effects.serverLifecycle(saved.candidate, 'stop');
29
+ await effects.removeGatewayContainer(saved.candidate);
30
+ phase('rollback-restoring');
31
+ }
32
+ if (saved.phase === 'preparing') phase('rollback-restoring');
33
+ if (saved.phase === 'rollback-restoring') {
34
+ for (let i = 0; i < paths.length; i++) restoreConfig(paths[i], saved.snapshots[i]);
35
+ phase('rollback-restarting');
36
+ }
37
+ await effects.serverLifecycle(saved.original, 'start', saved.running);
38
+ unlinkSync(journal);
39
+ }
40
+ if (existsSync(journal)) {
41
+ snapshot(journal);
42
+ const saved = JSON.parse(readFileSync(journal, 'utf8'));
43
+ validateInstallation(saved.original, record.root);
44
+ validateInstallation(saved.candidate, record.root);
45
+ if (saved.schema !== 1 || !['preparing', 'switching', 'rollback-stopping', 'rollback-restoring', 'rollback-restarting'].includes(saved.phase) || !Array.isArray(saved.snapshots) || saved.snapshots.length !== paths.length
46
+ || !Array.isArray(saved.running) || saved.running.some(name => !saved.original.services.includes(name)))
47
+ throw new Error('Invalid retained gateway transition; operator recovery required');
48
+ await rollback(saved);
49
+ throw new Error('Interrupted gateway migration rolled back; repeat gateway-enable to begin a new migration');
50
+ }
51
+ if (record.gateway) {
52
+ if (args.serverUrl && args.serverUrl !== gatewayAddress(record).base) throw new Error('Gateway URL is already configured; refusing implicit URL replacement');
53
+ await effects.verifyGateway(record);
54
+ return record;
55
+ }
56
+ const candidate = { ...record, gateway: { version: 1, ...(args.serverUrl ? { serverUrl: args.serverUrl } : {}) }, services: [...record.services, 'gateway'] };
57
+ validateInstallation(candidate, record.root);
58
+ const current = effects.readManagedClientProfile();
59
+ const selected = current?.expectedInstanceId === record.instanceId && current.endpoint === `http://127.0.0.1:${record.port}`;
60
+ const saved = { schema: 1, phase: 'preparing', original: record, candidate, snapshots: paths.map(snapshot), running: await effects.serverLifecycle(record, 'status') };
61
+ // Reject old service artifacts before stopping the working deployment.
62
+ await effects.qualifyGatewayRuntime(candidate);
63
+ if (selected) await effects.qualifyInstalledGatewayClient(current);
64
+ atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n');
65
+ try {
66
+ await effects.prepareGateway(candidate);
67
+ saved.phase = 'switching';
68
+ atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n');
69
+ await effects.serverLifecycle(record, 'stop');
70
+ await effects.serverLifecycle(candidate, 'start', [...new Set([...saved.running, 'daemon', 'cowork', 'gateway'])]);
71
+ await effects.verifyGateway(candidate);
72
+ if (selected) {
73
+ const serverUrl = gatewayAddress(candidate).base;
74
+ atomicWriteConfig(profilePath, JSON.stringify({ ...current, serverUrl, endpoint: serverUrl + '/daemon' }, null, 2) + '\n');
75
+ }
76
+ // Preserve the running/stopped selection; gateway follows a running daemon.
77
+ const keep = [...saved.running, ...(saved.running.includes('daemon') ? ['gateway'] : [])];
78
+ await effects.serverLifecycle(candidate, 'stop', candidate.services.filter(name => !keep.includes(name)));
79
+ atomicWriteConfig(recordPath, JSON.stringify(candidate, null, 2) + '\n');
80
+ unlinkSync(journal);
81
+ return candidate;
82
+ } catch (cause) {
83
+ try { await rollback(saved); }
84
+ catch (rollbackError) { throw new AggregateError([cause, rollbackError], 'Gateway migration and rollback failed; retained journal requires gateway-enable recovery'); }
85
+ throw new Error('Gateway migration failed; previous routing, profile and service selection restored', { cause });
86
+ }
87
+ }
@@ -0,0 +1,142 @@
1
+ /** One public URL; fixed same-origin service paths are never supplied by a peer. */
2
+ export const GATEWAY_SERVICES = Object.freeze({daemon:'/daemon',cowork:'/cowork',telegram:'/tg-connector',messenger:'/messenger'});
3
+ export function serverBase(value) {
4
+ if(typeof value!=='string'||/[\s\\?#]/.test(value)||!/^https?:\/\//.test(value))throw new Error('Server URL must be an HTTP or HTTPS base URL');
5
+ const url=new URL(value);
6
+ if(url.username||url.password)throw new Error('Server URL cannot contain credentials');
7
+ return url.origin+url.pathname.replace(/\/+$/,'');
8
+ }
9
+ export function gatewayDiscovery(record) {
10
+ return {schema:1,instanceId:record.instanceId,services:GATEWAY_SERVICES,capabilities:['ours.gateway-v1','cowork.http-management-v1']};
11
+ }
12
+ export function validateGatewayDiscovery(url,value,credentialPath) {
13
+ const base=serverBase(url);
14
+ if(value?.schema!==1||!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value.instanceId??'')
15
+ ||!Array.isArray(value.capabilities)||!value.capabilities.includes('ours.gateway-v1')||!value.capabilities?.includes('cowork.http-management-v1')
16
+ ||Object.keys(GATEWAY_SERVICES).some(key=>value.services?.[key]!==GATEWAY_SERVICES[key]))throw new Error('Incompatible server gateway discovery');
17
+ return {serverUrl:base,endpoint:base+'/daemon',expectedInstanceId:value.instanceId,credentialPath};
18
+ }
19
+ export function gatewayAddress(record) {
20
+ const base = serverBase(record.gateway?.serverUrl ?? `http://127.0.0.1:${record.port}`);
21
+ const url = new URL(base);
22
+ if (!/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]*$/.test(url.pathname)) throw new Error('Gateway base path must contain plain URL path segments');
23
+ return { base, origin: url.origin, prefix: url.pathname.replace(/\/$/, '') };
24
+ }
25
+ export function gatewayCompose(record) {
26
+ const { origin, prefix } = gatewayAddress(record);
27
+ return `services:
28
+ daemon:
29
+ ports: !reset []
30
+ telegram:
31
+ ports: !reset []
32
+ environment:
33
+ OURS_TG_CONTROL_HOST: "0.0.0.0"
34
+ cowork:
35
+ ports: !reset []
36
+ environment:
37
+ OURS_COWORK_HTTP_MANAGEMENT: "1"
38
+ OURS_COWORK_PUBLIC_ORIGIN: ${JSON.stringify(origin)}
39
+ messenger:
40
+ ports: !reset []
41
+ environment:
42
+ OURS_MESSENGER_PUBLIC_ORIGIN: ${JSON.stringify(origin)}
43
+ OURS_MESSENGER_BASE_PATH: ${JSON.stringify(prefix + "/messenger/")}
44
+ gateway:
45
+ image: "\${OURS_GATEWAY_IMAGE:-${record.project}:gateway}"
46
+ build:
47
+ context: .
48
+ dockerfile: Dockerfile.gateway
49
+ user: "101:101"
50
+ read_only: true
51
+ cap_drop: [ALL]
52
+ security_opt: [no-new-privileges:true]
53
+ tmpfs: ["/tmp:rw,nosuid,nodev,noexec,size=32m,mode=1777"]
54
+ networks: [ours]
55
+ ports:
56
+ - {target: 8080, published: "${record.port}", host_ip: "127.0.0.1"}
57
+ restart: unless-stopped
58
+ stop_grace_period: 10s
59
+ healthcheck:
60
+ test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:8080/healthz]
61
+ interval: 5s
62
+ timeout: 3s
63
+ retries: 6
64
+ `;
65
+ }
66
+ export function gatewayNginx(record) {
67
+ const { prefix: basePath } = gatewayAddress(record);
68
+ const routes=[['daemon',3050],['cowork',record.coworkPort??3052],['telegram',3051],['messenger',8420]];
69
+ const upstream=Object.fromEntries(routes);
70
+ for(const port of Object.values(upstream))if(!Number.isInteger(port)||port<1||port>65535)throw new Error('Invalid gateway upstream port');
71
+ const route=(name,servicePath)=> { const prefix=basePath+servicePath; return`location ${prefix}/ {
72
+ set $upstream_${name} http://${name}:${upstream[name]};
73
+ rewrite ^${prefix}/(.*)$ /$1 break;
74
+ proxy_pass $upstream_${name};
75
+ proxy_set_header Host $http_host;
76
+ proxy_set_header X-Forwarded-Proto $scheme;
77
+ proxy_set_header Upgrade $http_upgrade;
78
+ proxy_set_header Connection $connection_upgrade;
79
+ proxy_http_version 1.1;
80
+ proxy_buffering off;
81
+ proxy_read_timeout 300s;
82
+ }`; };
83
+ return `worker_processes 1;
84
+ pid /tmp/nginx.pid;
85
+ error_log /dev/stderr warn;
86
+ events { worker_connections 1024; }
87
+ http {
88
+ access_log off;
89
+ client_body_temp_path /tmp/client;
90
+ proxy_temp_path /tmp/proxy;
91
+ fastcgi_temp_path /tmp/fastcgi;
92
+ uwsgi_temp_path /tmp/uwsgi;
93
+ scgi_temp_path /tmp/scgi;
94
+ proxy_next_upstream off;
95
+ resolver 127.0.0.11 valid=5s ipv6=off;
96
+ map $http_upgrade $connection_upgrade { default upgrade; '' close; }
97
+ server {
98
+ listen 8080;
99
+ server_name _;
100
+ client_max_body_size 64m;
101
+ location = /healthz { return 200 'ok'; }
102
+ location = ${basePath}/.well-known/ours { default_type application/json; return 200 '${JSON.stringify(gatewayDiscovery(record))}'; }
103
+ location = / { return 302 ${basePath}/messenger/; }
104
+ location = ${basePath}/daemon { return 308 ${basePath}/daemon/; }
105
+ location = ${basePath}/cowork { return 308 ${basePath}/cowork/; }
106
+ location = ${basePath}/tg-connector { return 308 ${basePath}/tg-connector/; }
107
+ location = ${basePath}/messenger { return 308 ${basePath}/messenger/; }
108
+ # Never publish the unauthenticated loopback browser RPC. Management has its
109
+ # own application-level credential check and rejects browser-origin requests.
110
+ location = ${basePath}/cowork/rpc { return 403; }
111
+ location ${basePath}/cowork/management/ {
112
+ client_max_body_size 1m;
113
+ set $management http://cowork:${upstream.cowork};
114
+ rewrite ^${basePath}/cowork/(.*)$ /$1 break;
115
+ proxy_pass $management;
116
+ proxy_set_header Host $http_host;
117
+ proxy_http_version 1.1;
118
+ proxy_read_timeout 60s;
119
+ }
120
+ ${route('daemon','/daemon')}
121
+ ${route('cowork','/cowork')}
122
+ # Telegram's control API is local-only; no unauthenticated remote proxy.
123
+ location ${basePath}/tg-connector/ {
124
+ auth_request /_server_auth;
125
+ set $telegram http://telegram:3051;
126
+ rewrite ^${basePath}/tg-connector/(.*)$ /$1 break;
127
+ proxy_pass $telegram;
128
+ }
129
+ location = /_server_auth {
130
+ internal;
131
+ set $auth http://daemon:3050/identities;
132
+ proxy_pass $auth;
133
+ proxy_pass_request_body off;
134
+ proxy_set_header Content-Length "";
135
+ proxy_set_header X-Ours-Api-Token $http_x_ours_api_token;
136
+ }
137
+ ${route('messenger','/messenger')}
138
+ location / { return 404; }
139
+ }
140
+ }
141
+ `;
142
+ }
@@ -1,3 +1,4 @@
1
+ import { enableGateway } from './gateway-transition.mjs';
1
2
  import { executeLegacyMigration } from './legacy-migration.mjs';
2
3
  // ours-install v3 — the orchestrator.
3
4
  //
@@ -1324,6 +1325,12 @@ async function executeServerCommand(args, effects) {
1324
1325
  record = effects.newInstallation(args.stateDir, args.mode);
1325
1326
  for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
1326
1327
  }
1328
+ if (effects.readJson(join(record.root, 'gateway-transition.json')) && args.operation !== 'gateway-enable' && args.operation !== 'status') throw new Error('Interrupted gateway migration; run server gateway-enable before other changes');
1329
+ if (args.serverUrl && args.operation === 'install') {
1330
+ if (!record.gateway) throw new Error('Use server gateway-enable to migrate an existing Docker installation');
1331
+ if (existing && record.gateway.serverUrl !== args.serverUrl) throw new Error('Conflicting gateway server URL');
1332
+ record.gateway = { ...record.gateway, serverUrl: args.serverUrl };
1333
+ }
1327
1334
  if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
1328
1335
  throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
1329
1336
  }
@@ -1390,7 +1397,10 @@ async function executeServerCommand(args, effects) {
1390
1397
  } else {
1391
1398
  await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
1392
1399
  }
1400
+ if (record.gateway) await effects.verifyGateway(record);
1393
1401
  if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
1402
+ } else if (args.operation === 'gateway-enable') {
1403
+ await enableGateway(record, args, effects);
1394
1404
  } else if (['backup', 'restore', 'reset'].includes(args.operation)) {
1395
1405
  await effects.serverMaintenance(record, args);
1396
1406
  } else if (['update', 'rebuild'].includes(args.operation)) {
@@ -1479,6 +1489,7 @@ export async function runClientCommand(command, effects) {
1479
1489
  resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
1480
1490
  }
1481
1491
  effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
1492
+ if (profile.serverUrl && integrations.includes('fleet')) await effects.qualifyGatewayClient({ profile, sourcesPath, sources: resolvedSources, integrations, refresh: !!command.preset });
1482
1493
  const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
1483
1494
  let phase = 'Validate saved server connection';
1484
1495
  try {
package/lib/plan.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { gatewayAddress } from './gateway.mjs';
1
2
  // ours-install v3 — daemon creation and boot-service installation.
2
3
  //
3
4
  // Pure planning code, like lib/target.mjs: the orchestrator
@@ -362,9 +363,14 @@ export function validateInstallation(record, root) {
362
363
  || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(record.instanceId ?? '')
363
364
  || !/^ours-[a-z0-9]+$/.test(record.project ?? '')
364
365
  || (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))) {
366
+ || !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
367
  throw new Error('Invalid or conflicting installation selection');
367
368
  }
369
+ if (record.gateway !== undefined && (record.schema !== 2 || record.mode !== 'docker' || record.gateway?.version !== 1
370
+ || Object.keys(record.gateway).some(key => !['version', 'serverUrl'].includes(key)) || record.services.at(-1) !== 'gateway')) {
371
+ throw new Error('Invalid gateway installation selection');
372
+ }
373
+ if (record.gateway) gatewayAddress(record);
368
374
  if (record.layoutConversion !== undefined) {
369
375
  // DEPRECATED (introduced in 2.0): legacy managed-layout conversion only.
370
376
  // Removal target: 3.0, after supported installs convert and upgrade inputs
@@ -402,7 +408,7 @@ export function validateInstallation(record, root) {
402
408
  throw new Error('Invalid server build transition');
403
409
  }
404
410
  validateInstallation(candidate, candidate.root);
405
- for (const key of ['schema', 'mode', 'instanceId', 'services', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
411
+ for (const key of ['schema', 'mode', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
406
412
  if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
407
413
  }
408
414
  }
@@ -1,3 +1,4 @@
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';
@@ -82,7 +83,8 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
82
83
  const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
83
84
  if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
84
85
  }
85
- const endpoint = `http://127.0.0.1:${record.port}`;
86
+ const serverUrl = record.gateway ? gatewayAddress(record).base : undefined;
87
+ const endpoint = serverUrl ? `${serverUrl}/daemon` : `http://127.0.0.1:${record.port}`;
86
88
  const current = effects.readManagedClientProfile();
87
89
  if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
88
90
  privatePath(record.root, true);
@@ -93,13 +95,16 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
93
95
  const published = join(root, 'issued-' + randomUUID());
94
96
  const credential = join(stage, 'credential');
95
97
  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');
98
+ if (!current) {
99
+ effects.out?.('Issuing a separate local client credential with the retained server authority.');
100
+ try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
101
+ catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
102
+ }
103
+ const retainedCredential = current?.credentialPath ?? credential;
104
+ const stat = privatePath(retainedCredential);
105
+ if (stat.size > 4096 || !readFileSync(retainedCredential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
101
106
  const profile = {
102
- ...validateHostProfile({ endpoint, expectedInstanceId: record.instanceId, credentialPath: join(published, 'credential') }),
107
+ ...validateHostProfile({ ...(serverUrl ? { serverUrl } : {}), endpoint, expectedInstanceId: record.instanceId, credentialPath: current?.credentialPath ?? join(published, 'credential') }),
103
108
  installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
104
109
  };
105
110
  writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
@@ -1,9 +1,10 @@
1
+ import { serverBase } from './gateway.mjs';
1
2
  import { join, resolve, isAbsolute, dirname } from 'node:path';
2
3
 
3
4
  const scopes = ['all', 'server', 'client'];
4
5
  const integrations = ['codex', 'claude-code', 'fleet'];
5
6
  const valueFlags = new Map(Object.entries({
6
- '--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
7
+ '--server-url': 'serverUrl', '--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
7
8
  '--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
8
9
  '--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
9
10
  }));
@@ -40,6 +41,10 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
40
41
  if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
41
42
  if (options.mode === 'native') options.mode = 'packages';
42
43
  if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
44
+ if (options.serverUrl !== undefined) {
45
+ if (!server || options.mode !== 'docker' || options.operation !== 'install') throw new Error('--server-url requires Docker server installation');
46
+ options.serverUrl = serverBase(options.serverUrl);
47
+ }
43
48
  if (server && options.config !== undefined) throw new Error('--config is only valid for client scope');
44
49
  if (!server) for (const key of ['mode', 'stateDir', 'identityName', ...Object.keys(defaults), 'compatible', 'migrate']) {
45
50
  if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
package/lib/setup.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { gatewayAddress } from './gateway.mjs';
1
2
  import { inspectLegacyMigration } from './legacy-migration.mjs';
2
3
  import { join } from 'node:path';
3
4
  import { parseSetupArgs, collectSetupOptions, validateSetupOptions } from './setup-options.mjs';
@@ -10,7 +11,7 @@ import { USAGE } from './usage.mjs';
10
11
  import { validateIdentityName } from './server-onboarding.mjs';
11
12
  import { validateFleetSettings } from './fleet-settings.mjs';
12
13
 
13
- const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
14
+ const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
14
15
 
15
16
  export function completeReleasePolicy(retained, supplied) {
16
17
  if (retained?.release) {
@@ -67,7 +68,7 @@ export async function prepareSetupPlan(options, effects) {
67
68
  }
68
69
  // Reject a local client already attached to another server before changing the server.
69
70
  const saved = options.scope === 'all' ? effects.readManagedClientProfile() : null;
70
- if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== `http://127.0.0.1:${plan.port}`)) {
71
+ if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== (plan.existing?.gateway ? gatewayAddress(plan.existing).base + '/daemon' : `http://127.0.0.1:${plan.port}`))) {
71
72
  throw new InstallUsageError('This user already has clients attached to a different server; their saved connection was not changed');
72
73
  }
73
74
  } else {