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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,12 +68,12 @@ it. They exclude the daemon and its native database/ADAPT dependencies. Fleet
68
68
  runs beside the harnesses on the client machine and calls the daemon HTTP API.
69
69
  Client commands report an unavailable server and never start one automatically.
70
70
 
71
- The development policy selects published SDK `3.8.1-nightly.9`, CLI
72
- `2.8.1-nightly.7`, and daemon `3.8.1-nightly.1`, together with exact reviewed
73
- consumer source commits. The isolated host CLI pair uses these released client
74
- artifacts. The nightly release manifest selects the complete published consumer
75
- set and standalone daemon with verified SHA512 integrities. Packaged installers
76
- use this exact release set; mixed or unselected nested SDK versions remain rejected.
71
+ The checked-in source policy and packed installer select the same exact published
72
+ nightly release set, including SDK `3.8.1-nightly.10`, CLI `2.8.1-nightly.8`, and
73
+ daemon `3.8.1-nightly.2`. The isolated host CLI pair uses these released client
74
+ artifacts. `releases/nightly.json` binds the gateway-capable consumers and daemon
75
+ to verified SHA512 integrities. Mixed or unselected nested ours versions remain
76
+ rejected. An explicit `--sources` override remains available for development.
77
77
 
78
78
  ## Migrate an existing global installation
79
79
 
@@ -333,3 +333,113 @@ origin directly. UUID/capability checks still precede credential-bearing calls.
333
333
  This adds client HTTPS support, not an HTTPS daemon listener, certificate
334
334
  provisioning or automatic reverse-proxy configuration. Existing local HTTP and
335
335
  SSH-tunnel profiles continue to work.
336
+
337
+ ## One-URL Docker gateway
338
+
339
+ New Docker installations use one loopback-published nginx port (default 3050).
340
+ Clients import one `serverUrl`; their daemon endpoint is `<serverUrl>/daemon` and
341
+ Fleet derives `<serverUrl>/cowork/management/rpc`. Cowork, Messenger and Telegram
342
+ backend ports are internal only. Existing installations retain their prior layout
343
+ on ordinary install/update. Select compatible releases before explicitly migrating:
344
+
345
+ ```sh
346
+ ours-install server gateway-enable --state-dir /private/ours
347
+ ```
348
+
349
+ Migration checks the installed Cowork, Messenger and Telegram capabilities before
350
+ stopping services, verifies authenticated daemon and read-only Cowork management,
351
+ and preserves the existing credential and client integration settings. Failure
352
+ restores routing, client profile and prior service selection. An interrupted
353
+ migration blocks other mutations: repeat `gateway-enable` to finish rollback,
354
+ then run it again to attempt migration. The private `gateway-transition.json`
355
+ retains recovery state until rollback or migration completes. Back up the whole
356
+ installation using the normal server backup procedure before maintenance.
357
+
358
+ For a nested external URL, pass `--server-url https://ours.example/base` during
359
+ Docker install or `gateway-enable`. This sets browser origin and path configuration;
360
+ it does **not** publish a public listener, provide TLS, or install external
361
+ credentials. Configure the authenticated entry first and retain the `/base` path
362
+ when proxying. A compatible release must contain these capabilities:
363
+ `cowork.http-management-v1`, `messenger.gateway-prefix-v1`, and
364
+ `telegram.gateway-listener-v1`. Older releases are rejected without enabling the
365
+ new listener; no package version is silently substituted.
366
+
367
+ ### Authenticated external entry
368
+
369
+ Messenger has no application authentication. Loopback access trusts local users;
370
+ never forward this gateway publicly without authentication. All applications on
371
+ this origin share one trust boundary: scripts or XSS in any application can make
372
+ requests to the others. Cowork's entered server credential grants operator room
373
+ administration and stays in memory until reload. Path prefixes do not isolate
374
+ applications. Only expose this origin to trusted operators.
375
+
376
+ A supported external entry is nginx TLS plus HTTP Basic authentication for browser
377
+ paths, with machine paths left to their existing server-token checks. Create the
378
+ password file using an interactive `htpasswd` prompt and make it readable by the
379
+ proxy worker. Use real certificates and the matching `--server-url`. The inner
380
+ gateway remains bound to `127.0.0.1:3050`; neither backend ports nor the inner
381
+ listener should be externally forwarded. Example for `/base`:
382
+
383
+ ```nginx
384
+ # In http {}:
385
+ map $http_upgrade $ours_connection { default upgrade; '' close; }
386
+ server {
387
+ listen 443 ssl;
388
+ server_name ours.example;
389
+ ssl_certificate /private/tls/fullchain.pem;
390
+ ssl_certificate_key /private/tls/key.pem;
391
+ auth_basic "ours operators";
392
+ auth_basic_user_file /private/ours-users;
393
+ access_log off;
394
+ proxy_set_header Host $http_host;
395
+ proxy_set_header Upgrade $http_upgrade;
396
+ proxy_set_header Connection $ours_connection;
397
+ proxy_http_version 1.1;
398
+ proxy_read_timeout 300s;
399
+ proxy_buffering off;
400
+ location = /base/.well-known/ours { auth_basic off; proxy_pass http://127.0.0.1:3050; }
401
+ location /base/daemon/ { auth_basic off; proxy_pass http://127.0.0.1:3050; }
402
+ location = /base/cowork/management/rpc { auth_basic off; proxy_pass http://127.0.0.1:3050; }
403
+ location /base/tg-connector/ { auth_basic off; proxy_pass http://127.0.0.1:3050; }
404
+ location /base/ { proxy_pass http://127.0.0.1:3050; }
405
+ location / { return 404; }
406
+ }
407
+ ```
408
+
409
+ Browser requests include same-origin proxy credentials. Fleet/SDK use the issued
410
+ `X-Ours-Api-Token`, require no browser login, and reject redirects. An SSH tunnel
411
+ to the loopback gateway is another private transport option, but every client must
412
+ establish its own tunnel and use a URL/origin matching that tunnel; it is not
413
+ automatic remote access. Do not treat a token entered in Cowork as Messenger auth.
414
+
415
+ Publish compatible Cowork, Messenger, Telegram and Fleet artifacts before enabling
416
+ gateway defaults in a qualified installer release. Gateway client setup acquires
417
+ and checks Fleet before activating the managed profile or publishing native
418
+ commands. Migration also checks the installed `ours-fleet version --json`
419
+ capability before stopping services when the selected managed profile includes
420
+ Fleet. Upgrade that executable first. The gate does not inventory differently pinned
421
+ running Fleet instances or remote clients: upgrade every client using this server
422
+ to a compatible Fleet release before cutover.
423
+
424
+ ### Cross-repository gateway qualification
425
+
426
+ Build Cowork, Fleet, Messenger and Telegram checkouts with their locked dependencies.
427
+ The real-service test uses disposable local daemon state, a local test broker,
428
+ nginx Docker containers and Chromium; it never uses installed identities or tokens.
429
+ It requires Linux Docker host networking and `openssl`. Set:
430
+
431
+ ```sh
432
+ OURS_TEST_GATEWAY_SERVICES=1 \
433
+ OURS_TEST_COWORK_ROOT=/checkouts/ours-cowork \
434
+ OURS_TEST_FLEET_ROOT=/checkouts/ours-fleet \
435
+ OURS_TEST_MESSENGER_ROOT=/checkouts/ours-messenger-server \
436
+ OURS_TEST_TELEGRAM_ROOT=/checkouts/ours-tg-connector \
437
+ OURS_TEST_BROWSER=/opt/google/chrome/chrome \
438
+ node --test packages/installer/test/gateway-services.test.mjs
439
+ ```
440
+
441
+ Run `OURS_TEST_DOCKER=1 node --test packages/installer/test/gateway-docker.test.mjs`
442
+ separately for Compose port isolation with a client container. That routing test
443
+ uses echo upstreams; the real-service test covers Fleet room operations, browser
444
+ paths, external authentication and Messenger WebSocket access. Neither test is
445
+ cross-platform Docker Desktop qualification or a live deployment test.
@@ -0,0 +1,4 @@
1
+ FROM nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236
2
+ COPY --chmod=644 nginx.conf /etc/nginx/nginx.conf
3
+ USER 101:101
4
+ ENTRYPOINT ["nginx", "-g", "daemon off;"]
@@ -11,13 +11,13 @@
11
11
  "@ours.network/claude-code": "1.2.0-nightly.6",
12
12
  "@ours.network/cli": "2.8.1-nightly.8",
13
13
  "@ours.network/codex": "1.2.0-nightly.6",
14
- "@ours.network/cowork": "1.3.2-nightly.20260921.4b9a45e",
14
+ "@ours.network/cowork": "1.3.2-nightly.20260922.70e6adf",
15
15
  "@ours.network/daemon": "3.8.1-nightly.2",
16
- "@ours.network/fleet": "1.2.0-nightly.4",
16
+ "@ours.network/fleet": "1.2.0-nightly.5",
17
17
  "@ours.network/mcp": "1.2.0-nightly.6",
18
- "@ours.network/messenger-server": "1.0.31-nightly.4",
18
+ "@ours.network/messenger-server": "1.0.31-nightly.5",
19
19
  "@ours.network/sdk": "3.8.1-nightly.10",
20
- "@ours.network/tg-connector": "1.0.1-nightly.4"
20
+ "@ours.network/tg-connector": "1.0.1-nightly.5"
21
21
  }
22
22
  },
23
23
  "node_modules/@adapt-toolkit/sdk": {
@@ -566,9 +566,9 @@
566
566
  ]
567
567
  },
568
568
  "node_modules/@anthropic-ai/sdk": {
569
- "version": "0.127.0",
570
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.127.0.tgz",
571
- "integrity": "sha512-OKiVbG9LHCxjn4fBJZJFy/Bz3hqE1RYpwaeSEDNheQnvz0XNKmGT/Lhkz2Sf1a27Ldt3vVv+BkU81I0U/8EpoQ==",
569
+ "version": "0.128.0",
570
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.128.0.tgz",
571
+ "integrity": "sha512-tl5cBFZC1jVFrTuQyvQObA4WmuNgcYVeXfriqOumgtLAHUm4gPUj18YnszBW60v0PrjDB8nBDOC6wd9CmHQFlA==",
572
572
  "license": "MIT",
573
573
  "optional": true,
574
574
  "peer": true,
@@ -1103,9 +1103,9 @@
1103
1103
  }
1104
1104
  },
1105
1105
  "node_modules/@ours.network/cowork": {
1106
- "version": "1.3.2-nightly.20260921.4b9a45e",
1107
- "resolved": "https://registry.npmjs.org/@ours.network/cowork/-/cowork-1.3.2-nightly.20260921.4b9a45e.tgz",
1108
- "integrity": "sha512-RWhWpthIl3yU7aU5TdwJvdSAFQQYyKFrTNrtFxk/P0CBxLegfT3ILYRn4Pl1SR1ToIACXvKQc3DpBT6ifFhuVA==",
1106
+ "version": "1.3.2-nightly.20260922.70e6adf",
1107
+ "resolved": "https://registry.npmjs.org/@ours.network/cowork/-/cowork-1.3.2-nightly.20260922.70e6adf.tgz",
1108
+ "integrity": "sha512-RpbbD9pxundiln4+3eD1wwMgXd13nM24CbaabFUQMartqpNMVm1cVzU6sGvuZ2hgMMRVwWbrmjk1gL7DtxiZ4g==",
1109
1109
  "license": "FSL-1.1-Apache-2.0",
1110
1110
  "dependencies": {
1111
1111
  "@ours.network/sdk": "3.8.1-nightly.10",
@@ -1145,9 +1145,9 @@
1145
1145
  }
1146
1146
  },
1147
1147
  "node_modules/@ours.network/fleet": {
1148
- "version": "1.2.0-nightly.4",
1149
- "resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.4.tgz",
1150
- "integrity": "sha512-8BNjKSv4kjrnqrnRxEQnTR8nTqiLyrqTT3GPA6JTiH8xftfMCTkGQLK3xhwRYWQHihG3QPnJ+du/ljtFrtlUUA==",
1148
+ "version": "1.2.0-nightly.5",
1149
+ "resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.5.tgz",
1150
+ "integrity": "sha512-wCM7oLwgpfnMuJv1/nB/2zhHqEqyl2x29gCo41RXKBMw7PJI64NVMpjeSu7SiCyjNlelAMOkRMGRcv+IdUnvgA==",
1151
1151
  "license": "FSL-1.1-Apache-2.0",
1152
1152
  "dependencies": {
1153
1153
  "@agentclientprotocol/sdk": "^1.3.0",
@@ -1220,9 +1220,9 @@
1220
1220
  }
1221
1221
  },
1222
1222
  "node_modules/@ours.network/messenger-server": {
1223
- "version": "1.0.31-nightly.4",
1224
- "resolved": "https://registry.npmjs.org/@ours.network/messenger-server/-/messenger-server-1.0.31-nightly.4.tgz",
1225
- "integrity": "sha512-YuMUBzCvHjUchoITtugj9Oj50TojPQd/lR2VfrqgiZUbVnG1hNZVvcSF/qIrLUB/1wrBcXHbPdX+tNvET5dCmQ==",
1223
+ "version": "1.0.31-nightly.5",
1224
+ "resolved": "https://registry.npmjs.org/@ours.network/messenger-server/-/messenger-server-1.0.31-nightly.5.tgz",
1225
+ "integrity": "sha512-4UtUeq+WNgTSQjeWKMAer7mSPeue8hUrY23AgnSwreBfVui4GjN/aJ6jS6Znz9v3nS5D9DM+faeW1JVHEpgyDg==",
1226
1226
  "license": "FSL-1.1-Apache-2.0",
1227
1227
  "dependencies": {
1228
1228
  "@eslint-community/regexpp": "4.12.2",
@@ -1266,9 +1266,9 @@
1266
1266
  }
1267
1267
  },
1268
1268
  "node_modules/@ours.network/tg-connector": {
1269
- "version": "1.0.1-nightly.4",
1270
- "resolved": "https://registry.npmjs.org/@ours.network/tg-connector/-/tg-connector-1.0.1-nightly.4.tgz",
1271
- "integrity": "sha512-PN4kwCU45NKKoE+mkmFxrhpzY5dYEtjDK6pmQqNqnHFUHDSnoBHACIuUITqtbqeG+srnRGpSQwu9gSbDmcFiaw==",
1269
+ "version": "1.0.1-nightly.5",
1270
+ "resolved": "https://registry.npmjs.org/@ours.network/tg-connector/-/tg-connector-1.0.1-nightly.5.tgz",
1271
+ "integrity": "sha512-WyoqnWg7Y2D45J2b4aJ20PydSKaSheC8NdVJTnrjQozsD4pW0ueDfuTwvuO5zx56KUqhhWXRVP7kztk9pycU5w==",
1272
1272
  "license": "FSL-1.1-Apache-2.0",
1273
1273
  "dependencies": {
1274
1274
  "@ours.network/sdk": "3.8.1-nightly.10",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "channel": "nightly",
4
- "installerVersion": "1.2.1-nightly.11",
4
+ "installerVersion": "1.2.1-nightly.12",
5
5
  "packages": {
6
6
  "@ours.network/sdk": {
7
7
  "version": "3.8.1-nightly.10",
@@ -12,20 +12,20 @@
12
12
  "integrity": "sha512-7kZ1EzLpWNsyAdil4NoZfQc5RtAcbfIqc0AgTaMyaGA+ayqZRVdarYuBOo+fOT6dwbLLlMvWpZQgVMtR7ND4Lg=="
13
13
  },
14
14
  "@ours.network/tg-connector": {
15
- "version": "1.0.1-nightly.4",
16
- "integrity": "sha512-PN4kwCU45NKKoE+mkmFxrhpzY5dYEtjDK6pmQqNqnHFUHDSnoBHACIuUITqtbqeG+srnRGpSQwu9gSbDmcFiaw=="
15
+ "version": "1.0.1-nightly.5",
16
+ "integrity": "sha512-WyoqnWg7Y2D45J2b4aJ20PydSKaSheC8NdVJTnrjQozsD4pW0ueDfuTwvuO5zx56KUqhhWXRVP7kztk9pycU5w=="
17
17
  },
18
18
  "@ours.network/cowork": {
19
- "version": "1.3.2-nightly.20260921.4b9a45e",
20
- "integrity": "sha512-RWhWpthIl3yU7aU5TdwJvdSAFQQYyKFrTNrtFxk/P0CBxLegfT3ILYRn4Pl1SR1ToIACXvKQc3DpBT6ifFhuVA=="
19
+ "version": "1.3.2-nightly.20260922.70e6adf",
20
+ "integrity": "sha512-RpbbD9pxundiln4+3eD1wwMgXd13nM24CbaabFUQMartqpNMVm1cVzU6sGvuZ2hgMMRVwWbrmjk1gL7DtxiZ4g=="
21
21
  },
22
22
  "@ours.network/messenger-server": {
23
- "version": "1.0.31-nightly.4",
24
- "integrity": "sha512-YuMUBzCvHjUchoITtugj9Oj50TojPQd/lR2VfrqgiZUbVnG1hNZVvcSF/qIrLUB/1wrBcXHbPdX+tNvET5dCmQ=="
23
+ "version": "1.0.31-nightly.5",
24
+ "integrity": "sha512-4UtUeq+WNgTSQjeWKMAer7mSPeue8hUrY23AgnSwreBfVui4GjN/aJ6jS6Znz9v3nS5D9DM+faeW1JVHEpgyDg=="
25
25
  },
26
26
  "@ours.network/fleet": {
27
- "version": "1.2.0-nightly.4",
28
- "integrity": "sha512-8BNjKSv4kjrnqrnRxEQnTR8nTqiLyrqTT3GPA6JTiH8xftfMCTkGQLK3xhwRYWQHihG3QPnJ+du/ljtFrtlUUA=="
27
+ "version": "1.2.0-nightly.5",
28
+ "integrity": "sha512-wCM7oLwgpfnMuJv1/nB/2zhHqEqyl2x29gCo41RXKBMw7PJI64NVMpjeSu7SiCyjNlelAMOkRMGRcv+IdUnvgA=="
29
29
  },
30
30
  "@ours.network/mcp": {
31
31
  "version": "1.2.0-nightly.6",
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.11",
5
+ "installerVersion": "1.2.1-nightly.12",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
8
  "version": "3.8.1-nightly.10",
@@ -13,20 +13,20 @@
13
13
  "integrity": "sha512-7kZ1EzLpWNsyAdil4NoZfQc5RtAcbfIqc0AgTaMyaGA+ayqZRVdarYuBOo+fOT6dwbLLlMvWpZQgVMtR7ND4Lg=="
14
14
  },
15
15
  "@ours.network/tg-connector": {
16
- "version": "1.0.1-nightly.4",
17
- "integrity": "sha512-PN4kwCU45NKKoE+mkmFxrhpzY5dYEtjDK6pmQqNqnHFUHDSnoBHACIuUITqtbqeG+srnRGpSQwu9gSbDmcFiaw=="
16
+ "version": "1.0.1-nightly.5",
17
+ "integrity": "sha512-WyoqnWg7Y2D45J2b4aJ20PydSKaSheC8NdVJTnrjQozsD4pW0ueDfuTwvuO5zx56KUqhhWXRVP7kztk9pycU5w=="
18
18
  },
19
19
  "@ours.network/cowork": {
20
- "version": "1.3.2-nightly.20260921.4b9a45e",
21
- "integrity": "sha512-RWhWpthIl3yU7aU5TdwJvdSAFQQYyKFrTNrtFxk/P0CBxLegfT3ILYRn4Pl1SR1ToIACXvKQc3DpBT6ifFhuVA=="
20
+ "version": "1.3.2-nightly.20260922.70e6adf",
21
+ "integrity": "sha512-RpbbD9pxundiln4+3eD1wwMgXd13nM24CbaabFUQMartqpNMVm1cVzU6sGvuZ2hgMMRVwWbrmjk1gL7DtxiZ4g=="
22
22
  },
23
23
  "@ours.network/messenger-server": {
24
- "version": "1.0.31-nightly.4",
25
- "integrity": "sha512-YuMUBzCvHjUchoITtugj9Oj50TojPQd/lR2VfrqgiZUbVnG1hNZVvcSF/qIrLUB/1wrBcXHbPdX+tNvET5dCmQ=="
24
+ "version": "1.0.31-nightly.5",
25
+ "integrity": "sha512-4UtUeq+WNgTSQjeWKMAer7mSPeue8hUrY23AgnSwreBfVui4GjN/aJ6jS6Znz9v3nS5D9DM+faeW1JVHEpgyDg=="
26
26
  },
27
27
  "@ours.network/fleet": {
28
- "version": "1.2.0-nightly.4",
29
- "integrity": "sha512-8BNjKSv4kjrnqrnRxEQnTR8nTqiLyrqTT3GPA6JTiH8xftfMCTkGQLK3xhwRYWQHihG3QPnJ+du/ljtFrtlUUA=="
28
+ "version": "1.2.0-nightly.5",
29
+ "integrity": "sha512-wCM7oLwgpfnMuJv1/nB/2zhHqEqyl2x29gCo41RXKBMw7PJI64NVMpjeSu7SiCyjNlelAMOkRMGRcv+IdUnvgA=="
30
30
  },
31
31
  "@ours.network/mcp": {
32
32
  "version": "1.2.0-nightly.6",
@@ -67,19 +67,19 @@
67
67
  },
68
68
  "@ours.network/tg-connector": {
69
69
  "type": "npm",
70
- "version": "1.0.1-nightly.4"
70
+ "version": "1.0.1-nightly.5"
71
71
  },
72
72
  "@ours.network/cowork": {
73
73
  "type": "npm",
74
- "version": "1.3.2-nightly.20260921.4b9a45e"
74
+ "version": "1.3.2-nightly.20260922.70e6adf"
75
75
  },
76
76
  "@ours.network/messenger-server": {
77
77
  "type": "npm",
78
- "version": "1.0.31-nightly.4"
78
+ "version": "1.0.31-nightly.5"
79
79
  },
80
80
  "@ours.network/fleet": {
81
81
  "type": "npm",
82
- "version": "1.2.0-nightly.4"
82
+ "version": "1.2.0-nightly.5"
83
83
  },
84
84
  "@ours.network/mcp": {
85
85
  "type": "npm",
@@ -53,6 +53,7 @@ export async function serverBuildTransition(record, args, effects) {
53
53
  }
54
54
  await stage('Verify retained state and identities', () => effects.validateServerBuildState(record));
55
55
  await stage('Restore previously running services and check readiness', () => effects.serverLifecycle(record, 'start', runningServices));
56
+ if (record.gateway && ['daemon', 'cowork', 'gateway'].every(name => runningServices.includes(name))) await stage('Verify authenticated gateway management', () => effects.verifyGateway(record));
56
57
  const completed = { ...record, sourcePolicyHash: candidate.sourcePolicyHash };
57
58
  delete completed.buildTransition;
58
59
  effects.writeJson(path, JSON.stringify(completed, null, 2) + '\n');
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 {
package/lib/target.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { serverBase } from './gateway.mjs';
1
2
  // ours-install v3 — argument handling and daemon detection.
2
3
  //
3
4
  // Pure argument handling and daemon-target selection. The orchestrator
@@ -85,17 +86,23 @@ export function validateHostProfile(value) {
85
86
  const mixed = LEGACY_PROFILE_KEYS.filter((key) => Object.hasOwn(value, key));
86
87
  if (mixed.length) throw profileError(`legacy selection keys cannot be mixed with a host profile (${mixed.join(', ')}).`);
87
88
  const { endpoint, expectedInstanceId, credentialPath } = value;
88
- if (typeof endpoint !== 'string' || endpoint.trim() !== endpoint || endpoint === '') throw profileError('endpoint must be a non-empty HTTP or HTTPS origin.');
89
+ if (typeof endpoint !== 'string' || endpoint.trim() !== endpoint || endpoint === '') throw profileError('endpoint must be a non-empty HTTP or HTTPS base URL.');
89
90
  if (typeof expectedInstanceId !== 'string' || !PROFILE_UUID.test(expectedInstanceId)) throw profileError('expectedInstanceId must be a lowercase UUID.');
90
91
  if (typeof credentialPath !== 'string' || credentialPath === '' || !credentialPath.startsWith('/') || resolve(credentialPath) !== credentialPath) {
91
92
  throw profileError('credentialPath must be a normalized absolute path.');
92
93
  }
93
94
  let url;
94
- try { url = new URL(endpoint); } catch { throw profileError('endpoint must be an HTTP or HTTPS origin.'); }
95
- if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
96
- throw profileError('endpoint must be an HTTP or HTTPS origin without credentials, path, query, or fragment.');
95
+ try { url = new URL(endpoint); } catch { throw profileError('endpoint must be an HTTP or HTTPS base URL.'); }
96
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash) {
97
+ throw profileError('endpoint must be an HTTP or HTTPS base URL without credentials, query, or fragment.');
97
98
  }
98
- return { endpoint: url.origin, expectedInstanceId, credentialPath };
99
+ const endpointBase = serverBase(endpoint);
100
+ if (value.serverUrl !== undefined) {
101
+ const serverUrl = serverBase(value.serverUrl);
102
+ if (endpointBase !== serverUrl + '/daemon') throw profileError('endpoint must select serverUrl/daemon.');
103
+ return { serverUrl, endpoint: endpointBase, expectedInstanceId, credentialPath };
104
+ }
105
+ return { endpoint: endpointBase, expectedInstanceId, credentialPath };
99
106
  }
100
107
 
101
108
  export function resolveProfileSelection({ args, env = {}, home = homedir(), exists, readProfile }) {
@@ -454,10 +461,11 @@ export function searchFreePort(isTaken, { floor = FREE_PORT_FLOOR, reserved = IN
454
461
  export function parseNetworkArgs(argv) {
455
462
  if (!['server', 'client'].includes(argv[0])) return null;
456
463
  const [role, operation] = argv;
457
- const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild'];
464
+ const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild', 'gateway-enable'];
458
465
  if (!operations.includes(operation)) throw new InstallUsageError(`Unsupported ${role} operation: ${operation ?? '(missing)'}`);
459
466
  const allowed = role === 'client' ? ['config'] : ['state-dir'];
460
467
  if (role === 'server' && operation === 'install') allowed.push('mode', 'sources', 'migrate');
468
+ if (role === 'server' && ['install', 'gateway-enable'].includes(operation)) allowed.push('server-url');
461
469
  if (operation === 'access-issue') allowed.push('output');
462
470
  if (['access-replace', 'reset'].includes(operation)) allowed.push('confirm');
463
471
  if (['restore', 'update'].includes(operation)) allowed.push('compatible');
@@ -486,7 +494,7 @@ export function parseNetworkArgs(argv) {
486
494
  } else {
487
495
  const value = inline ?? argv[++i];
488
496
  if (!value || value.startsWith('--')) throw new InstallUsageError(`--${name} requires a value`);
489
- result[key] = name === 'mode' ? value : resolve(value);
497
+ result[key] = name === 'mode' ? value : name === 'server-url' ? serverBase(value) : resolve(value);
490
498
  }
491
499
  }
492
500
  const required = role === 'client' ? [] : ['stateDir'];
package/lib/usage.mjs CHANGED
@@ -29,6 +29,7 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
29
29
  --config PATH complete connection profile for client-only setup
30
30
  --sources PATH explicit full development source policy override
31
31
  --port N daemon port (default 3050 on a fresh installation)
32
+ --server-url URL external gateway URL (Docker install; configure authenticated entry separately)
32
33
  --cowork-port N cowork port (default 3052)
33
34
  --messenger-port N messenger port (default 8420)
34
35
  --compatible required for server updates and legacy state migration
@@ -46,6 +47,7 @@ Fleet is configured but left stopped for operator review.
46
47
 
47
48
  Scoped maintenance:
48
49
  ours-install server status|start|stop|restart|rebuild --state-dir PATH
50
+ ours-install server gateway-enable --state-dir PATH [--server-url URL]
49
51
  ours-install server access-issue --state-dir PATH --output PATH
50
52
  ours-install server access-replace --state-dir PATH --confirm
51
53
  ours-install server backup|restore server|daemon|telegram|cowork|messenger LABEL --state-dir PATH
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "1.2.1-nightly.11",
3
+ "version": "1.2.1-nightly.12",
4
4
  "private": false,
5
5
  "description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
6
6
  "type": "module",