@ours.network/install 1.2.1-nightly.30 → 1.2.1-nightly.31

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
@@ -384,6 +384,39 @@ when proxying. A compatible release must contain these capabilities:
384
384
  `telegram.gateway-listener-v1`. Older releases are rejected without enabling the
385
385
  new listener; no package version is silently substituted.
386
386
 
387
+ ### Notifications
388
+
389
+ When the selected release includes `@ours.network/notifications`, gateway
390
+ installations run it inside the Messenger container, under one supervisor with
391
+ Messenger: a signal stops both, and either one exiting stops the container so
392
+ its normal restart recovers both. It publishes no host port. The gateway routes
393
+ `<serverUrl>/notifications/` to it after the same daemon credential check as
394
+ Telegram, and the service's adapter checks that credential again before acting
395
+ as the installation owner. Browser cookies, origins and fetch metadata are
396
+ removed. Messenger produces message notifications over loopback.
397
+
398
+ `prepare` creates the private configuration once in
399
+ `storage/state/notifications/config.json`: the owner token, separate Fleet and
400
+ Messenger producer tokens, and VAPID keys. Later runs only validate it; rotating
401
+ the owner token or VAPID keys would orphan every browser subscription, so a
402
+ differing configuration is refused rather than rewritten. Back it up with the
403
+ rest of the server state. A local client setup binds the Fleet producer token
404
+ to this server in `~/.ours-client/notifications-producer.json`; Fleet then sends
405
+ agent completions through `<serverUrl>/notifications/`. Remote clients and
406
+ native installations do not produce notifications. The gateway discovery
407
+ document is unchanged, so existing clients keep accepting the server. A
408
+ retained source policy without the package keeps working without notifications.
409
+
410
+ An existing complete installation gains them with its normal update, which
411
+ prepares the credentials after activating the new runtime and hands the Fleet
412
+ producer credential to the local client, then a web-only Fleet restart (agents
413
+ keep running):
414
+
415
+ ```sh
416
+ ours-install all update --mode docker --state-dir /private/ours --identity-name "Your Name" --integrations codex,fleet --fleet-settings /private/fleet.json --compatible
417
+ ours-fleet web restart
418
+ ```
419
+
387
420
  ### Authenticated external entry
388
421
 
389
422
  Messenger has no application authentication. Loopback access trusts local users;
@@ -591,4 +624,4 @@ Existing installation records keep their ports and identities. A listener acquir
591
624
  by another process after preflight still causes startup to fail safely; no foreign
592
625
  process is stopped and no retained installation is silently moved.
593
626
 
594
- Workspace setup pins Fleet `1.2.0-nightly.38` with its recorded registry integrity. This release includes first-time Fleet setup through the host API for the account App, direct App access to the workspace services with the device credential, separate `ours-fleet setup-tunnel` and `ours-fleet link-device` commands, host prerequisite checks before the one-time workspace proof (`setup-tunnel --check`), `setup-tunnel --resume` to finish a setup that stopped after the host saved its private pending setup record (not when the proof's answer was lost, or after the setup window expired with an unconfirmed non-default port), and persists the available Fleet loopback port used by the workspace tunnel. It also accepts a setup-generated configuration left behind after the host enrollment state was removed, runs the host Codex when it is at least as new as the packaged one, offers only the Codex models the signed-in account can use, and removes the temporary setup contact after confirmation. Fleet serves no pages and opens no browser: every screen is in the App. Existing installation identities and configured ports remain retained; a retained-port collision fails safely.
627
+ Workspace setup pins Fleet `1.2.0-nightly.39` with its recorded registry integrity. This release includes first-time Fleet setup through the host API for the account App, direct App access to the workspace services with the device credential, separate `ours-fleet setup-tunnel` and `ours-fleet link-device` commands, host prerequisite checks before the one-time workspace proof (`setup-tunnel --check`), `setup-tunnel --resume` to finish a setup that stopped after the host saved its private pending setup record (not when the proof's answer was lost, or after the setup window expired with an unconfirmed non-default port), and persists the available Fleet loopback port used by the workspace tunnel. It also accepts a setup-generated configuration left behind after the host enrollment state was removed, runs the host Codex when it is at least as new as the packaged one, offers only the Codex models the signed-in account can use, and removes the temporary setup contact after confirmation. Fleet serves no pages and opens no browser: every screen is in the App. Fleet also serves the workspace notification routes, produces notifications through the server gateway with the imported producer credential, and defaults agent wakes to `monitor.interrupt: after_tool` (Hermes: no interrupt). Existing installation identities and configured ports remain retained; a retained-port collision fails safely.
package/assets/Dockerfile CHANGED
@@ -27,7 +27,7 @@ COPY --chmod=644 scripts/runtime/diagnostics.mjs /opt/ours/docker/diagnostics.mj
27
27
  COPY --chmod=644 scripts/runtime/legacy-import.mjs /opt/ours/docker/legacy-import.mjs
28
28
  COPY --chmod=644 scripts/runtime/entrypoint.sh /opt/ours/docker/entrypoint.sh
29
29
  COPY --chmod=644 scripts/runtime/healthcheck.mjs /opt/ours/docker/healthcheck.mjs
30
- COPY --chmod=644 scripts/runtime/start-telegram.sh scripts/runtime/health-telegram.sh scripts/runtime/start-cowork.sh scripts/runtime/health-cowork.sh scripts/runtime/start-messenger.sh scripts/runtime/health-messenger.mjs /opt/ours/docker/
30
+ COPY --chmod=644 scripts/runtime/start-telegram.sh scripts/runtime/health-telegram.sh scripts/runtime/start-cowork.sh scripts/runtime/health-cowork.sh scripts/runtime/start-messenger.sh scripts/runtime/health-messenger.mjs scripts/runtime/notifications-gateway.mjs scripts/runtime/supervise.mjs /opt/ours/docker/
31
31
  RUN chmod 755 /opt/ours/maintenance /opt/ours/docker
32
32
  FROM node:24 AS maintenance
33
33
  LABEL network.ours.build-context="1"
@@ -164,8 +164,11 @@ services:
164
164
  OURS_MESSENGER_PORT: "8420"
165
165
  OURS_MESSENGER_PUBLIC_ORIGIN: "http://127.0.0.1:${OURS_MESSENGER_PORT:-8420}"
166
166
  OURS_MESSENGER_STATE_DIR: /var/lib/ours-messenger
167
+ # Started beside Messenger when the runtime ships @ours.network/notifications.
168
+ OURS_NOTIFICATIONS_CONFIG: /var/lib/ours-notifications/config.json
167
169
  volumes:
168
170
  - {type: volume, source: server-storage, target: /var/lib/ours-messenger, volume: {nocopy: true, subpath: state/messenger}}
171
+ - {type: volume, source: server-storage, target: /var/lib/ours-notifications, volume: {nocopy: true, subpath: state/notifications}}
169
172
  - {type: volume, source: server-storage, target: /credentials/messenger, read_only: true, volume: {nocopy: true, subpath: state/credentials/messenger}}
170
173
  ports:
171
174
  - {target: 8420, published: "${OURS_MESSENGER_PORT:-8420}", host_ip: 127.0.0.1}
@@ -13,9 +13,10 @@
13
13
  "@ours.network/codex": "1.2.0-nightly.10",
14
14
  "@ours.network/cowork": "1.3.3-nightly.20261001.6d49d3a",
15
15
  "@ours.network/daemon": "3.8.1-nightly.7",
16
- "@ours.network/fleet": "1.2.0-nightly.38",
16
+ "@ours.network/fleet": "1.2.0-nightly.39",
17
17
  "@ours.network/mcp": "1.2.0-nightly.10",
18
18
  "@ours.network/messenger-server": "1.0.31-nightly.14",
19
+ "@ours.network/notifications": "0.3.1-nightly.1",
19
20
  "@ours.network/sdk": "3.8.1-nightly.15",
20
21
  "@ours.network/tg-connector": "1.0.1-nightly.9"
21
22
  }
@@ -1000,9 +1001,9 @@
1000
1001
  }
1001
1002
  },
1002
1003
  "node_modules/@ours.network/fleet": {
1003
- "version": "1.2.0-nightly.38",
1004
- "resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.38.tgz",
1005
- "integrity": "sha512-glCDY/WfILgTJfG7G3ao5iEtCyWtyPOzl/o+D1EVphUuKQYZIW7Xk8sxyT2/D8FADRqvJSBbebWseWbgmlU6Ow==",
1004
+ "version": "1.2.0-nightly.39",
1005
+ "resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.39.tgz",
1006
+ "integrity": "sha512-ixcky/b22x2slW+NmPz2BVQVLFV+jkTFzRGFKpDLug2mugk6xIJiVyY50o8G5c+JXAjB+EN9pzIIrTH21Y48BA==",
1006
1007
  "license": "FSL-1.1-Apache-2.0",
1007
1008
  "dependencies": {
1008
1009
  "@agentclientprotocol/sdk": "^1.3.0",
@@ -1064,6 +1065,22 @@
1064
1065
  "node": ">=22"
1065
1066
  }
1066
1067
  },
1068
+ "node_modules/@ours.network/notifications": {
1069
+ "version": "0.3.1-nightly.1",
1070
+ "resolved": "https://registry.npmjs.org/@ours.network/notifications/-/notifications-0.3.1-nightly.1.tgz",
1071
+ "integrity": "sha512-rIkrtixoHqBfgCKDDCEYFl4W2Zzh0hOw3axX2+GppQ9gYkujKrk9M2KLGoYhu8RwwTMRl497jgMcTBe7UAHCgA==",
1072
+ "license": "FSL-1.1-Apache-2.0",
1073
+ "dependencies": {
1074
+ "web-push": "3.6.7",
1075
+ "ws": "8.22.0"
1076
+ },
1077
+ "bin": {
1078
+ "ours-notifications": "dist/cli.js"
1079
+ },
1080
+ "engines": {
1081
+ "node": ">=22"
1082
+ }
1083
+ },
1067
1084
  "node_modules/@ours.network/sdk": {
1068
1085
  "version": "3.8.1-nightly.15",
1069
1086
  "resolved": "https://registry.npmjs.org/@ours.network/sdk/-/sdk-3.8.1-nightly.15.tgz",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "channel": "nightly",
4
- "installerVersion": "1.2.1-nightly.30",
4
+ "installerVersion": "1.2.1-nightly.31",
5
5
  "packages": {
6
6
  "@ours.network/sdk": {
7
7
  "version": "3.8.1-nightly.15",
@@ -23,9 +23,13 @@
23
23
  "version": "1.0.31-nightly.14",
24
24
  "integrity": "sha512-0lMoIadihMQqN0sdW20DF4a94Nc6bx0lnaCckSnn5VqOmvqELNdG3kRZ1Heh9VwkPEu6qNM27AR7tWpTbzvJCg=="
25
25
  },
26
+ "@ours.network/notifications": {
27
+ "version": "0.3.1-nightly.1",
28
+ "integrity": "sha512-rIkrtixoHqBfgCKDDCEYFl4W2Zzh0hOw3axX2+GppQ9gYkujKrk9M2KLGoYhu8RwwTMRl497jgMcTBe7UAHCgA=="
29
+ },
26
30
  "@ours.network/fleet": {
27
- "version": "1.2.0-nightly.38",
28
- "integrity": "sha512-glCDY/WfILgTJfG7G3ao5iEtCyWtyPOzl/o+D1EVphUuKQYZIW7Xk8sxyT2/D8FADRqvJSBbebWseWbgmlU6Ow=="
31
+ "version": "1.2.0-nightly.39",
32
+ "integrity": "sha512-ixcky/b22x2slW+NmPz2BVQVLFV+jkTFzRGFKpDLug2mugk6xIJiVyY50o8G5c+JXAjB+EN9pzIIrTH21Y48BA=="
29
33
  },
30
34
  "@ours.network/mcp": {
31
35
  "version": "1.2.0-nightly.10",
@@ -7,7 +7,8 @@ export const ROOT = resolve(process.env.OURS_BUILD_ROOT || '/opt/ours');
7
7
  export const SOURCE_ROOT = resolve(process.env.OURS_SOURCE_ROOT || '/src');
8
8
  export const OUT = join(ROOT, 'docker/vendor');
9
9
  export const CONFIG = JSON.parse(readFileSync(join(ROOT, 'sources.json'), 'utf8'));
10
- export const SELECTED = new Set((process.env.OURS_BUILD_PACKAGES || 'sdk,cli,daemon,tg-connector,cowork,messenger-server').split(',').map(name => '@ours.network/' + name));
10
+ // Notifications joined the server set later; retained selections without it still build.
11
+ export const SELECTED = new Set((process.env.OURS_BUILD_PACKAGES || ['sdk,cli,daemon,tg-connector,cowork,messenger-server', ...(CONFIG.packages?.['@ours.network/notifications'] ? ['notifications'] : [])].join(',')).split(',').map(name => '@ours.network/' + name));
11
12
  export const inheritedLock = process.env.OURS_INSTALLER_LOCK_FD === '3' ? [3] : [];
12
13
  export const run = (args, cwd) => execFileSync(args[0], args.slice(1), { cwd, stdio: ['inherit', 'inherit', 'inherit', ...inheritedLock] });
13
14
  export const capture = (args, cwd, env = process.env) => execFileSync(args[0], args.slice(1), { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit', ...inheritedLock] });
@@ -8,7 +8,7 @@ import { semanticRecordEqual } from './provenance-compare.mjs';
8
8
  export const BASE_RECORDS = ['package-lock.json', 'dependency-tree.json'];
9
9
  export const CONTEXT = 'build-context.json';
10
10
  const admitted = new WeakMap();
11
- const packages = new Set(['sdk', 'cli', 'daemon', 'mcp', 'tg-connector', 'cowork', 'messenger-server', 'fleet', 'codex', 'claude-code', 'install'].map(n => '@ours.network/' + n));
11
+ const packages = new Set(['sdk', 'cli', 'daemon', 'mcp', 'tg-connector', 'cowork', 'messenger-server', 'notifications', 'fleet', 'codex', 'claude-code', 'install'].map(n => '@ours.network/' + n));
12
12
  const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding);
13
13
  const fail = text => { throw new Error(`Build context: ${text}`); };
14
14
  const keys = (v, expected) => v && !Array.isArray(v) && typeof v === 'object' && Object.keys(v).sort().join('\0') === [...expected].sort().join('\0');
@@ -21,7 +21,7 @@ export function releaseBinding(policy) {
21
21
  const pattern = release.channel === 'nightly' ? nightly : stable;
22
22
  if (typeof release.installerVersion !== 'string' || !pattern.test(release.installerVersion)) fail('invalid installer version/channel');
23
23
  if (release.scope !== undefined && release.scope !== 'host-cli') fail('invalid release scope');
24
- const required = release.scope === 'host-cli' ? ['@ours.network/cli', '@ours.network/sdk'] : [...names, ...(release.packages?.['@ours.network/daemon'] ? ['@ours.network/daemon'] : [])];
24
+ const required = release.scope === 'host-cli' ? ['@ours.network/cli', '@ours.network/sdk'] : [...names, ...['@ours.network/daemon', '@ours.network/notifications'].filter(name => release.packages?.[name])];
25
25
  if (!release.packages || JSON.stringify(Object.keys(release.packages).sort()) !== JSON.stringify([...required].sort())) fail('release must select exactly the required ours packages');
26
26
  if (release.scope === 'host-cli' && Object.hasOwn(release, 'hostCli')) fail('nested host CLI selection');
27
27
  for (const [name, entry] of Object.entries(release.packages)) {
@@ -1,7 +1,7 @@
1
1
  import { readBuildRecords, initializeBuildMarker } from '../maintenance/build-context.mjs';
2
2
  import { redactDiagnostic } from './diagnostics.mjs';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { randomBytes } from 'node:crypto';
4
+ import { generateKeyPairSync, randomBytes } from 'node:crypto';
5
5
  import {
6
6
  existsSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync,
7
7
  realpathSync, renameSync, unlinkSync, writeFileSync, readdirSync, chmodSync, chownSync,
@@ -81,6 +81,61 @@ function composeConfig(domain) {
81
81
  return null;
82
82
  }
83
83
 
84
+ const NOTIFICATIONS_PORT = 49677;
85
+ const NOTIFICATION_FILES = {
86
+ config: '/storage/state/notifications/config.json',
87
+ messenger: '/storage/state/credentials/messenger/notifications-producer',
88
+ // Read through the Messenger service by local client setup.
89
+ fleet: '/storage/state/notifications/fleet-producer',
90
+ };
91
+
92
+ function privateText(path, value) {
93
+ if (existingPrivate(path, false) && readFileSync(path, 'utf8') === value) return;
94
+ const temp = `${path}.setup-${randomBytes(8).toString('hex')}`;
95
+ try { writeFileSync(temp, value, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); renameSync(temp, path); }
96
+ finally { try { unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
97
+ }
98
+
99
+ function notificationConfig() {
100
+ const token = () => randomBytes(32).toString('base64url');
101
+ const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
102
+ const pub = publicKey.export({ format: 'jwk' }), key = privateKey.export({ format: 'jwk' });
103
+ const raw = Buffer.concat([Buffer.from([4]), Buffer.from(pub.x, 'base64url'), Buffer.from(pub.y, 'base64url')]);
104
+ return {
105
+ host: '0.0.0.0', port: NOTIFICATIONS_PORT, stateFile: '/var/lib/ours-notifications/state.json',
106
+ users: [{ userId: 'owner', token: token() }],
107
+ producers: [{ userId: 'owner', source: 'fleet', token: token() }, { userId: 'owner', source: 'messenger', token: token() }],
108
+ vapid: { subject: 'https://ours.network', publicKey: raw.toString('base64url'), privateKey: key.d },
109
+ };
110
+ }
111
+
112
+ /** Exact retained shape; any other content is operator state the installer will not rewrite. */
113
+ function validNotificationConfig(config) {
114
+ const tokens = [...(config.users ?? []), ...(config.producers ?? [])].map(entry => entry?.token);
115
+ return config.host === '0.0.0.0' && config.port === NOTIFICATIONS_PORT && config.stateFile === '/var/lib/ours-notifications/state.json'
116
+ && Array.isArray(config.users) && config.users.length === 1 && config.users[0].userId === 'owner'
117
+ && Array.isArray(config.producers) && config.producers.length === 2
118
+ && ['fleet', 'messenger'].every(source => config.producers.some(p => p?.source === source && p.userId === 'owner'))
119
+ && tokens.every(t => typeof t === 'string' && t.length >= 32) && new Set(tokens).size === tokens.length
120
+ && typeof config.vapid?.subject === 'string' && /^[A-Za-z0-9_-]{87}$/.test(config.vapid.publicKey ?? '') && /^[A-Za-z0-9_-]{43}$/.test(config.vapid.privateKey ?? '');
121
+ }
122
+
123
+ /**
124
+ * Notification secrets are created once and then only validated: rotating the
125
+ * owner token or VAPID keys would orphan every browser subscription. Producer
126
+ * delivery files are derived from the retained configuration on every run.
127
+ */
128
+ function prepareNotifications() {
129
+ // Messenger mounts this directory whether or not this runtime ships the service.
130
+ ensureDirectory('/storage/state/notifications');
131
+ if (!existsSync('/opt/ours/node_modules/@ours.network/notifications/package.json')) return;
132
+ initializeBuildMarker('/storage/state/notifications/.ours-provenance', readBuildRecords('/opt/ours'));
133
+ if (!existingPrivate(NOTIFICATION_FILES.config, false)) atomicJson(NOTIFICATION_FILES.config, notificationConfig());
134
+ const config = jsonObject(NOTIFICATION_FILES.config);
135
+ if (!validNotificationConfig(config)) fail('Existing notification configuration differs; use the supported maintenance workflow');
136
+ for (const source of ['messenger', 'fleet']) privateText(NOTIFICATION_FILES[source], config.producers.find(p => p.source === source).token);
137
+ }
138
+
84
139
  function prepare() {
85
140
  const uid = Number(process.env.OURS_UID), gid = Number(process.env.OURS_GID);
86
141
  if (![uid, gid].every((n) => Number.isSafeInteger(n) && n > 0)) fail('Configure non-root numeric OURS_UID and OURS_GID');
@@ -125,6 +180,7 @@ function prepare() {
125
180
  const config = jsonObject(coworkPath);
126
181
  if (config.version !== 1 || config.stateDir !== cowork.stateDir || config.rest?.enabled !== true || config.rest?.host !== '0.0.0.0') fail('Existing cowork configuration conflicts with Compose');
127
182
  }
183
+ prepareNotifications();
128
184
  console.log('OURS persistent volumes are ready');
129
185
  }
130
186
 
@@ -1,6 +1,7 @@
1
- try {
2
- const response = await fetch('http://127.0.0.1:8420/api/healthz', {
3
- signal: AbortSignal.timeout(3000), redirect: 'error',
4
- });
5
- process.exitCode = response.ok ? 0 : 1;
6
- } catch { process.exitCode = 1; }
1
+ import { existsSync } from 'node:fs';
2
+
3
+ const healthy = async url => {
4
+ try { return (await fetch(url, { signal: AbortSignal.timeout(3000), redirect: 'error' })).ok; } catch { return false; }
5
+ };
6
+ const notifications = process.env.OURS_NOTIFICATIONS_CONFIG && existsSync('/opt/ours/node_modules/@ours.network/notifications/package.json') && existsSync(process.env.OURS_NOTIFICATIONS_CONFIG);
7
+ process.exitCode = await healthy('http://127.0.0.1:8420/api/healthz') && (!notifications || await healthy('http://127.0.0.1:49677/healthz')) ? 0 : 1;
@@ -0,0 +1,82 @@
1
+ import { createServer, request } from 'node:http';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { readFileSync, statSync } from 'node:fs';
4
+
5
+ /** Browser context has no authority at the notification service. */
6
+ const BROWSER = ['cookie', 'origin', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user'];
7
+ const SERVER_CREDENTIAL = 'x-ours-api-token', PRODUCER = 'x-ours-notifications-producer';
8
+
9
+ /** Ask the daemon, exactly like the gateway's auth_request, whether a server API credential is valid. */
10
+ export function daemonCredentialCheck(daemonUrl) {
11
+ return token => new Promise(resolve => {
12
+ const req = request(new URL('/identities', daemonUrl), { method: 'GET', headers: { [SERVER_CREDENTIAL]: token }, timeout: 5000 }, res => {
13
+ res.resume(); resolve(res.statusCode >= 200 && res.statusCode < 300);
14
+ });
15
+ req.on('timeout', () => req.destroy());
16
+ req.on('error', () => resolve(false));
17
+ req.end();
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Front the notification service with the server's own authentication.
23
+ * A request carrying a server API credential is checked with the daemon; only then
24
+ * does it act as this installation's owner (or, for /api/v1/send, as the named
25
+ * producer). A request without one keeps its own Authorization, which the service
26
+ * checks against its scoped tokens. Nothing is injected for an unauthenticated caller.
27
+ */
28
+ export function createNotificationsAdapter({ service, userToken, verifyServerCredential }) {
29
+ const authorize = async (req) => {
30
+ for (const name of BROWSER) delete req.headers[name];
31
+ const credential = req.headers[SERVER_CREDENTIAL], producer = req.headers[PRODUCER];
32
+ delete req.headers[SERVER_CREDENTIAL]; delete req.headers[PRODUCER];
33
+ if (credential === undefined) return producer === undefined;
34
+ if (typeof credential !== 'string' || !credential || !await verifyServerCredential(credential)) return false;
35
+ const path = new URL(req.url ?? '/', 'http://localhost').pathname;
36
+ if (producer !== undefined && !(req.method === 'POST' && path === '/api/v1/send')) return false;
37
+ req.headers.authorization = `Bearer ${producer ?? userToken}`;
38
+ return true;
39
+ };
40
+ const server = createServer(async (req, res) => {
41
+ if (req.url === '/healthz' && req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); return; }
42
+ if (!await authorize(req)) { res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end('{"error":"authentication required"}'); return; }
43
+ service.server.emit('request', req, res);
44
+ });
45
+ // Upgraded connections leave the HTTP server's connection tracking, so shutdown ends them itself.
46
+ const upgraded = new Set();
47
+ server.on('upgrade', async (req, socket, head) => {
48
+ upgraded.add(socket); socket.once('close', () => upgraded.delete(socket));
49
+ if (!await authorize(req)) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; }
50
+ service.server.emit('upgrade', req, socket, head);
51
+ });
52
+ return { server, async close() {
53
+ // Stop accepting first; the service then ends its presence sockets and saves state.
54
+ const listening = new Promise(resolve => server.close(() => resolve()));
55
+ server.closeAllConnections();
56
+ await service.close();
57
+ for (const socket of upgraded) socket.destroy();
58
+ await listening;
59
+ } };
60
+ }
61
+
62
+ /** The installer-generated private configuration also names the owner token this adapter injects. */
63
+ export function readPrivateConfig(path) {
64
+ const stat = statSync(path);
65
+ if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid()) throw new Error('Notification configuration must be a private owner file');
66
+ const config = JSON.parse(readFileSync(path, 'utf8'));
67
+ if (!Array.isArray(config.users) || config.users.length !== 1 || typeof config.users[0]?.token !== 'string') throw new Error('Notification configuration must select one owner');
68
+ return config;
69
+ }
70
+
71
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
72
+ const config = readPrivateConfig(process.env.OURS_NOTIFICATIONS_CONFIG ?? '/var/lib/ours-notifications/config.json');
73
+ const { createNotificationService } = await import('/opt/ours/node_modules/@ours.network/notifications/dist/server.js');
74
+ const adapter = createNotificationsAdapter({
75
+ service: createNotificationService(config),
76
+ userToken: config.users[0].token,
77
+ verifyServerCredential: daemonCredentialCheck(process.env.OURS_DAEMON_URL ?? 'http://daemon:3050'),
78
+ });
79
+ await new Promise((resolve, reject) => { adapter.server.once('error', reject); adapter.server.listen(config.port, config.host, resolve); });
80
+ console.log('Notification service ready');
81
+ for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, async () => { await adapter.close(); process.exit(0); });
82
+ }
@@ -1,6 +1,27 @@
1
1
  #!/bin/sh
2
+ set -e
2
3
  umask 077
3
- exec 3</var/lib/ours-messenger
4
- flock -n 3
5
- node /opt/ours/docker/check-client.mjs messenger
6
- exec node /opt/ours/node_modules/@ours.network/messenger-server/dist/cli.js serve
4
+ runtime="${OURS_RUNTIME_ROOT:-/opt/ours}"
5
+ state="${OURS_MESSENGER_STATE_DIR:-/var/lib/ours-messenger}"
6
+ exec 3<"$state"
7
+ flock -n 3 || { echo "OURS Messenger state is in use by another writer" >&2; exit 1; }
8
+ node "$runtime/docker/check-client.mjs" messenger
9
+ messenger="[\"node\",\"$runtime/node_modules/@ours.network/messenger-server/dist/cli.js\",\"serve\"]"
10
+ # Notifications run beside Messenger when this runtime ships the service and
11
+ # its private configuration was prepared. Messenger's producer reaches it on loopback.
12
+ config="${OURS_NOTIFICATIONS_CONFIG:-}"
13
+ if [ -n "$config" ] && [ -f "$runtime/node_modules/@ours.network/notifications/package.json" ] && [ -f "$config" ]; then
14
+ notifications="$(dirname "$config")"
15
+ exec 4<"$notifications"
16
+ # Every writer of this state starts here and holds lock 4 for its lifetime;
17
+ # the kernel releases it when the process dies, even on SIGKILL. Holding it
18
+ # proves no other writer is alive, so a lock file left by an abrupt stop is stale.
19
+ flock -n 4 || { echo "OURS notification state is in use by another writer" >&2; exit 1; }
20
+ rm -f "$notifications/state.json.lock"
21
+ OURS_NOTIFICATIONS_ORIGIN="http://127.0.0.1:49677"
22
+ OURS_NOTIFICATIONS_PRODUCER_TOKEN="$(cat "${OURS_NOTIFICATIONS_PRODUCER_FILE:-/credentials/messenger/notifications-producer}")"
23
+ export OURS_NOTIFICATIONS_ORIGIN OURS_NOTIFICATIONS_PRODUCER_TOKEN
24
+ # Only Messenger receives its producer credential.
25
+ exec node "$runtime/docker/supervise.mjs" "[$messenger,[\"env\",\"-u\",\"OURS_NOTIFICATIONS_PRODUCER_TOKEN\",\"node\",\"$runtime/docker/notifications-gateway.mjs\"]]"
26
+ fi
27
+ exec node "$runtime/node_modules/@ours.network/messenger-server/dist/cli.js" serve
@@ -0,0 +1,35 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Run the given commands as one unit: signals reach every child, and when any
5
+ * child exits (or cannot start) the others are stopped and the container exits
6
+ * with its status, so a failure is visible and recovers through the container's
7
+ * own restart. A child ignoring SIGTERM is killed after a bounded grace period,
8
+ * within Compose's stop grace period.
9
+ */
10
+ const commands = JSON.parse(process.argv[2] ?? '[]');
11
+ if (!Array.isArray(commands) || !commands.length || !commands.every(c => Array.isArray(c) && c.length && c.every(a => typeof a === 'string'))) {
12
+ console.error('usage: supervise.mjs JSON-ARRAY-OF-COMMANDS'); process.exit(2);
13
+ }
14
+ const killAfter = Number(process.env.OURS_SUPERVISE_KILL_AFTER_MS ?? 20_000);
15
+ let stopping = false, status = 0, remaining = commands.length;
16
+ const children = [];
17
+ const running = child => child.exitCode === null && child.signalCode === null && !child.failed;
18
+ const stop = signal => {
19
+ for (const child of children) if (running(child)) child.kill(signal);
20
+ if (signal !== 'SIGKILL') setTimeout(() => stop('SIGKILL'), killAfter);
21
+ };
22
+ const finished = (child, code, reason) => {
23
+ if (child.done) return;
24
+ child.done = true;
25
+ if (!stopping) { stopping = true; status = code; console.error(`Supervised process ${child.spawnfile} ${reason}; stopping the others`); stop('SIGTERM'); }
26
+ if (--remaining === 0) process.exit(status);
27
+ };
28
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { if (!stopping) { stopping = true; stop(signal); } });
29
+ // Inherited descriptors 3 and 4 are the state-directory locks; every child holds them too.
30
+ for (const [file, ...args] of commands) {
31
+ const child = spawn(file, args, { stdio: ['inherit', 'inherit', 'inherit', 'inherit', 'inherit'] });
32
+ children.push(child);
33
+ child.on('error', error => { child.failed = true; finished(child, 1, `could not start (${error.code ?? 'error'})`); });
34
+ child.on('exit', (code, signal) => finished(child, code ?? 1, `exited (${signal ?? code})`));
35
+ }
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.30",
5
+ "installerVersion": "1.2.1-nightly.31",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
8
  "version": "3.8.1-nightly.15",
@@ -24,9 +24,13 @@
24
24
  "version": "1.0.31-nightly.14",
25
25
  "integrity": "sha512-0lMoIadihMQqN0sdW20DF4a94Nc6bx0lnaCckSnn5VqOmvqELNdG3kRZ1Heh9VwkPEu6qNM27AR7tWpTbzvJCg=="
26
26
  },
27
+ "@ours.network/notifications": {
28
+ "version": "0.3.1-nightly.1",
29
+ "integrity": "sha512-rIkrtixoHqBfgCKDDCEYFl4W2Zzh0hOw3axX2+GppQ9gYkujKrk9M2KLGoYhu8RwwTMRl497jgMcTBe7UAHCgA=="
30
+ },
27
31
  "@ours.network/fleet": {
28
- "version": "1.2.0-nightly.38",
29
- "integrity": "sha512-glCDY/WfILgTJfG7G3ao5iEtCyWtyPOzl/o+D1EVphUuKQYZIW7Xk8sxyT2/D8FADRqvJSBbebWseWbgmlU6Ow=="
32
+ "version": "1.2.0-nightly.39",
33
+ "integrity": "sha512-ixcky/b22x2slW+NmPz2BVQVLFV+jkTFzRGFKpDLug2mugk6xIJiVyY50o8G5c+JXAjB+EN9pzIIrTH21Y48BA=="
30
34
  },
31
35
  "@ours.network/mcp": {
32
36
  "version": "1.2.0-nightly.10",
@@ -77,9 +81,13 @@
77
81
  "type": "npm",
78
82
  "version": "1.0.31-nightly.14"
79
83
  },
84
+ "@ours.network/notifications": {
85
+ "type": "npm",
86
+ "version": "0.3.1-nightly.1"
87
+ },
80
88
  "@ours.network/fleet": {
81
89
  "type": "npm",
82
- "version": "1.2.0-nightly.38"
90
+ "version": "1.2.0-nightly.39"
83
91
  },
84
92
  "@ours.network/mcp": {
85
93
  "type": "npm",
@@ -52,6 +52,8 @@ export async function serverBuildTransition(record, args, effects) {
52
52
  save(transition);
53
53
  }
54
54
  await stage('Verify retained state and identities', () => effects.validateServerBuildState(record));
55
+ // Repeatable: an updated runtime may need volume state it introduced, such as notification credentials.
56
+ if (record.gateway) await stage('Prepare stored state for the updated runtime', () => effects.prepareServerVolumes(record));
55
57
  await stage('Restore previously running services and check readiness', () => effects.serverLifecycle(record, 'start', runningServices));
56
58
  if (record.gateway && ['daemon', 'cowork', 'gateway'].every(name => runningServices.includes(name))) await stage('Verify authenticated gateway management', () => effects.verifyGateway(record));
57
59
  const completed = { ...record, sourcePolicyHash: candidate.sourcePolicyHash };
package/lib/effects.mjs CHANGED
@@ -855,7 +855,7 @@ export function networkEffects(effects) {
855
855
  await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
856
856
  }
857
857
  if (runtimeOnly) return;
858
- await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
858
+ await effects.prepareServerVolumes(record);
859
859
  } else {
860
860
  if (!existsSync(join(record.workDir, '.packages-ready'))) {
861
861
  const sourceRoot = join(record.root, `build-${randomUUID()}`);
@@ -881,6 +881,10 @@ export function networkEffects(effects) {
881
881
  if (!existsSync(cowork)) writePrivateNew(cowork, JSON.stringify({ version: 1, stateDir: installationPaths(record).cowork, rest: { enabled: true, host: '127.0.0.1', port: record.coworkPort } }));
882
882
  }
883
883
  },
884
+ async prepareServerVolumes(record) {
885
+ if (record.mode !== 'docker') return;
886
+ await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
887
+ },
884
888
  async prepareServerBuild(record, args) {
885
889
  validateInstallation(record, record.root);
886
890
  if (record.schema !== 2 || record.layoutConversion || !['update', 'rebuild'].includes(args.operation)) {
@@ -1297,7 +1301,7 @@ export function networkEffects(effects) {
1297
1301
  if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
1298
1302
  throw new Error(`Gateway discovery answered HTTP ${discovery.status}; enable the gateway. Direct daemon fallback is not supported.`);
1299
1303
  },
1300
- importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup = false, refresh = false }) {
1304
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup = false, refresh = false, notificationsProducerPath }) {
1301
1305
  profile = validateGatewayClientProfile(profile);
1302
1306
  const root = join(home, '.ours-client');
1303
1307
  const configPath = join(root, 'profile.json');
@@ -1314,14 +1318,28 @@ export function networkEffects(effects) {
1314
1318
  : readFileSync(sourcesPath);
1315
1319
  const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1316
1320
  if (fleetSettings) JSON.parse(fleetSettings.toString());
1321
+ let producer = null;
1322
+ if (notificationsProducerPath) {
1323
+ assertPrivateRegularFile(notificationsProducerPath, 'notification producer credential');
1324
+ producer = readFileSync(notificationsProducerPath, 'utf8');
1325
+ let bound; try { bound = JSON.parse(producer); } catch { bound = null; }
1326
+ if (bound?.schema !== 1 || bound.serverUrl !== profile.serverUrl || bound.expectedInstanceId !== profile.expectedInstanceId || !/^[A-Za-z0-9_-]{32,256}$/.test(bound.token ?? ''))
1327
+ throw new Error('Notification producer credential does not match the selected server');
1328
+ }
1317
1329
  ensurePrivateDirectory(root);
1330
+ // Written before the profile it belongs to is published or changed.
1331
+ let notificationsProducerChanged = false;
1332
+ if (producer !== null) {
1333
+ const producerPath = join(root, 'notifications-producer.json');
1334
+ if (!existsSync(producerPath) || readFileSync(producerPath, 'utf8') !== producer) { atomicWriteConfig(producerPath, producer); notificationsProducerChanged = true; }
1335
+ }
1318
1336
  if (current && !refresh) {
1319
1337
  assertPrivateRegularFile(credentialPath, 'managed credential');
1320
1338
  if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
1321
1339
  const saved = { ...current, ...profile, credentialPath };
1322
1340
  if (current.endpoint !== saved.endpoint || current.serverUrl !== saved.serverUrl)
1323
1341
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1324
- return { configPath, profile: validateGatewayClientProfile(saved), settings: current.installer };
1342
+ return { configPath, profile: validateGatewayClientProfile(saved), settings: current.installer, notificationsProducerChanged };
1325
1343
  }
1326
1344
  const settings = { sourcesPath: join(root, 'sources.json'), integrations, ...(disableFleetAgentsSetup ? { disableFleetAgentsSetup: true } : {}) };
1327
1345
  if (fleetSettings) settings.fleetSettingsPath = join(root, 'fleet-settings.json');
@@ -1331,7 +1349,7 @@ export function networkEffects(effects) {
1331
1349
  atomicWriteConfig(credentialPath, credential);
1332
1350
  const saved = { ...profile, credentialPath, installer: settings };
1333
1351
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1334
- return { configPath, profile: validateHostProfile(saved), settings };
1352
+ return { configPath, profile: validateHostProfile(saved), settings, notificationsProducerChanged };
1335
1353
  },
1336
1354
  async qualifyInstalledGatewayClient(profile) {
1337
1355
  if (!profile.installer?.integrations?.includes('fleet')) return;
package/lib/gateway.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  /** One public URL; fixed same-origin service paths are never supplied by a peer. */
2
2
  export const GATEWAY_SERVICES = Object.freeze({daemon:'/daemon',cowork:'/cowork',telegram:'/tg-connector',messenger:'/messenger'});
3
+ /** Notifications run inside the Messenger container when its runtime ships the service. */
4
+ export const NOTIFICATIONS_PORT=49677;
3
5
  export function serverBase(value) {
4
6
  if(typeof value!=='string'||/[\s\\?#]/.test(value)||!/^https?:\/\//.test(value))throw new Error('Server URL must be an HTTP or HTTPS base URL');
5
7
  const url=new URL(value);
@@ -135,6 +137,20 @@ http {
135
137
  proxy_set_header X-Ours-Api-Token $http_x_ours_api_token;
136
138
  }
137
139
  ${route('messenger','/messenger')}
140
+ location = ${basePath}/notifications { return 308 ${basePath}/notifications/; }
141
+ # The service's own adapter authenticates again and holds the credential it injects.
142
+ location ${basePath}/notifications/ {
143
+ auth_request /_server_auth;
144
+ set $notifications http://messenger:${NOTIFICATIONS_PORT};
145
+ rewrite ^${basePath}/notifications/(.*)$ /$1 break;
146
+ proxy_pass $notifications;
147
+ proxy_set_header Host $http_host;
148
+ proxy_set_header Upgrade $http_upgrade;
149
+ proxy_set_header Connection $connection_upgrade;
150
+ proxy_http_version 1.1;
151
+ proxy_buffering off;
152
+ proxy_read_timeout 300s;
153
+ }
138
154
  location / { return 404; }
139
155
  }
140
156
  }
@@ -28,6 +28,7 @@ import { executeLegacyMigration } from './legacy-migration.mjs';
28
28
 
29
29
  import { basename, dirname, join, resolve } from 'node:path';
30
30
  import { parseNetworkArgs, validateHostProfile, parseInstallArgs, resolveTarget, resolveProfileSelection, profileEnv, InstallUsageError } from './target.mjs';
31
+ import { NOTIFICATIONS_PRODUCER_FILE } from './server-onboarding.mjs';
31
32
  import { clientPackageNames, selectSourcePackages, validateInstallation, SERVER_SERVICES, planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
32
33
  import {
33
34
  COMPONENTS,
@@ -1506,7 +1507,9 @@ export async function runClientCommand(command, effects) {
1506
1507
  }
1507
1508
  effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
1508
1509
  if (profile.serverUrl && integrations.includes('fleet')) await effects.qualifyGatewayClient({ profile, sourcesPath, sources: resolvedSources, integrations, refresh: !!command.preset });
1509
- const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup, refresh: !!command.preset });
1510
+ // A prepared local profile may carry the Fleet notification producer credential beside it.
1511
+ const notificationsProducerPath = configPath && configPath !== managedPath && effects.exists?.(join(dirname(configPath), NOTIFICATIONS_PRODUCER_FILE)) ? join(dirname(configPath), NOTIFICATIONS_PRODUCER_FILE) : undefined;
1512
+ const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup, refresh: !!command.preset, notificationsProducerPath });
1510
1513
  let phase = 'Validate saved server connection';
1511
1514
  try {
1512
1515
  await effects.verifyHostProfile(imported.configPath);
@@ -1532,6 +1535,9 @@ export async function runClientCommand(command, effects) {
1532
1535
  for (const name of incomplete) explainClientFailure(effects, name, summary.find(row => row.key === name));
1533
1536
  clientRetry(effects, imported, integrations);
1534
1537
  } else effects.out(ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
1538
+ // Fleet's web service reads its producer credential when it starts; agents keep running across a web restart.
1539
+ if (imported.notificationsProducerChanged && integrations.includes('fleet'))
1540
+ effects.out(info('Notifications are ready for Fleet. Run `ours-fleet web restart` so a running Fleet web console starts producing them.'));
1535
1541
  if (!incomplete.length) effects.out(progress(4, 4, 'Client setup complete', 'All selected integrations are configured.'));
1536
1542
  return incomplete.length ? EXIT_REFUSED : EXIT_OK;
1537
1543
  } catch (error) {
package/lib/plan.mjs CHANGED
@@ -281,6 +281,9 @@ export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedI
281
281
  }
282
282
 
283
283
  export const SERVER_PACKAGES = ['sdk', 'cli', 'daemon', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
284
+ /** Selected when the policy names it; retained policies from before it existed stay valid. */
285
+ export const NOTIFICATIONS_PACKAGE = '@ours.network/notifications';
286
+ const serverPackages = manifest => [...SERVER_PACKAGES, ...(manifest?.packages?.[NOTIFICATIONS_PACKAGE] ? [NOTIFICATIONS_PACKAGE] : [])];
284
287
  export const SERVER_DEPENDENCIES = {
285
288
  daemon: [], telegram: ['daemon'], cowork: ['daemon'], messenger: ['daemon'],
286
289
  };
@@ -296,7 +299,7 @@ export function maintenanceServices(record, domain) {
296
299
 
297
300
  /** Validate exact supplied selections, without rewriting the source authority. */
298
301
  export function selectSourcePackages(manifest, role, clients = []) {
299
- const names = role === 'server' ? SERVER_PACKAGES : clients.map(n => `@ours.network/${n}`);
302
+ const names = role === 'server' ? serverPackages(manifest) : clients.map(n => `@ours.network/${n}`);
300
303
  const result = {};
301
304
  for (const name of names) {
302
305
  const selected = manifest?.packages?.[name];
@@ -314,7 +317,7 @@ export function selectSourcePackages(manifest, role, clients = []) {
314
317
  /** Resolve a packaged compatibility policy into a role-filtered exact selection. */
315
318
  export async function resolveSourcePolicy(manifest, role, clients = [], resolveNpm) {
316
319
  const release = releaseBinding(manifest);
317
- const names = role === 'server' ? SERVER_PACKAGES : clients.map(name => `@ours.network/${name}`);
320
+ const names = role === 'server' ? serverPackages(manifest) : clients.map(name => `@ours.network/${name}`);
318
321
  const packages = {};
319
322
  const sourceNames = new Set();
320
323
  for (const name of names) {
@@ -5,6 +5,8 @@ import { join, resolve, isAbsolute } from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { installationPaths } from './plan.mjs';
7
7
  import { validateHostProfile } from './target.mjs';
8
+ /** Read by Fleet beside the profile credential; bound to one server installation. */
9
+ export const NOTIFICATIONS_PRODUCER_FILE = 'notifications-producer.json';
8
10
 
9
11
  export function validateIdentityName(name) {
10
12
  if (typeof name !== 'string' || [...name].length < 1 || [...name].length > 64 || name !== name.normalize('NFC')
@@ -241,6 +243,14 @@ else { await request('identity/profile', selection.human); process.stdout.write(
241
243
  installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
242
244
  };
243
245
  writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
246
+ // Fleet on this host produces agent notifications through the server gateway.
247
+ // A runtime without the notification service prepares no producer credential.
248
+ const issued = record.gateway ? await compose(record, ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'cat', 'messenger', '/var/lib/ours-notifications/fleet-producer'], { sensitive: true, allowCodes: [1] }) : null;
249
+ if (issued && (issued.code ?? 0) === 0) {
250
+ const token = String(issued.stdout ?? '');
251
+ if (!/^[A-Za-z0-9_-]{32,256}$/.test(token)) throw new Error('Notification producer credential is invalid; repeat server install');
252
+ writeFileSync(join(stage, NOTIFICATIONS_PRODUCER_FILE), JSON.stringify({ schema: 1, serverUrl, expectedInstanceId: record.instanceId, token }) + '\n', { flag: 'wx', mode: 0o600 });
253
+ }
244
254
  // Publish the complete pair in one rename. Managed-default activation and
245
255
  // package/source selection remain the normal client installer's job.
246
256
  renameSync(stage, published);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "1.2.1-nightly.30",
3
+ "version": "1.2.1-nightly.31",
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",