@addai/node 0.29.1 → 0.30.0

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.
@@ -1,151 +1,151 @@
1
- // The CDP recorder. Runs INSIDE the desktop container, next to the browser.
2
- //
3
- // node /conf/learn/recorder.mjs
4
- //
5
- // Writes NDJSON on stdout, one event per line, `t` in ms since it attached.
6
- // The daemon reads that stream directly — no file, no polling — and batches it
7
- // into the lesson row.
8
- //
9
- // Nothing is installed to make this work: the image already has node 22, whose
10
- // global WebSocket is enough for CDP, and Chrome, which the browser wrapper has
11
- // already given a debug port. If the port is not there (a browser opened before
12
- // the wrapper existed) it says so in one line and exits 3, and the lesson goes
13
- // on without the browser track rather than failing.
14
- import { injectedSource } from './injected.mjs';
15
-
16
- const PORT = Number(process.env.ADDAI_LEARN_PORT || 9222);
17
- const t0 = Date.now();
18
-
19
- const emit = (o) => {
20
- try { process.stdout.write(JSON.stringify({ t: Date.now() - t0, ...o }) + '\n'); }
21
- catch { /* the daemon went away; nothing to do about it from here */ }
22
- };
23
-
24
- async function version() {
25
- // A browser that is starting up refuses for a moment. Three tries over ~3s
26
- // covers the case where a lesson begins in the same breath as the browser.
27
- for (let i = 0; i < 3; i++) {
28
- try {
29
- const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
30
- if (r.ok) return await r.json();
31
- } catch { /* not up yet */ }
32
- await new Promise((r) => setTimeout(r, 1000));
33
- }
34
- return null;
35
- }
36
-
37
- function client(url) {
38
- const ws = new WebSocket(url);
39
- let next = 1;
40
- const pending = new Map();
41
- const handlers = [];
42
-
43
- const send = (method, params = {}, sessionId) =>
44
- new Promise((resolve) => {
45
- const id = next++;
46
- pending.set(id, resolve);
47
- const msg = { id, method, params };
48
- if (sessionId) msg.sessionId = sessionId;
49
- try { ws.send(JSON.stringify(msg)); } catch { pending.delete(id); resolve(null); }
50
- });
51
-
52
- ws.addEventListener('message', (ev) => {
53
- let m;
54
- try { m = JSON.parse(ev.data); } catch { return; }
55
- if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? null); pending.delete(m.id); return; }
56
- for (const h of handlers) h(m);
57
- });
58
-
59
- return {
60
- ws,
61
- send,
62
- on: (fn) => handlers.push(fn),
63
- ready: new Promise((resolve, reject) => {
64
- ws.addEventListener('open', resolve);
65
- ws.addEventListener('error', () => reject(new Error('cdp socket refused')));
66
- }),
67
- };
68
- }
69
-
70
- async function main() {
71
- const v = await version();
72
- if (!v || !v.webSocketDebuggerUrl) {
73
- emit({ kind: 'no_port' });
74
- process.exit(3);
75
- }
76
-
77
- const cdp = client(v.webSocketDebuggerUrl);
78
- await cdp.ready;
79
-
80
- const source = injectedSource();
81
- const attached = new Set();
82
-
83
- const arm = async (sessionId) => {
84
- if (attached.has(sessionId)) return;
85
- attached.add(sessionId);
86
- await cdp.send('Runtime.enable', {}, sessionId);
87
- await cdp.send('Page.enable', {}, sessionId);
88
- // The binding is how the page talks back. Added before the script that
89
- // calls it, or the first click on an already-open page is lost.
90
- await cdp.send('Runtime.addBinding', { name: '__addaiLearn' }, sessionId);
91
- await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId);
92
- // …and once for the document that is already loaded, which the line above
93
- // does not cover.
94
- await cdp.send('Runtime.evaluate', { expression: source }, sessionId);
95
- };
96
-
97
- cdp.on(async (m) => {
98
- const s = m.sessionId;
99
- if (m.method === 'Target.targetCreated' && m.params?.targetInfo?.type === 'page') {
100
- await cdp.send('Target.attachToTarget', { targetId: m.params.targetInfo.targetId, flatten: true });
101
- return;
102
- }
103
- if (m.method === 'Target.attachedToTarget') {
104
- const info = m.params?.targetInfo ?? {};
105
- if (info.type !== 'page') return;
106
- emit({ kind: 'tab_open', url: info.url ?? null });
107
- await arm(m.params.sessionId);
108
- return;
109
- }
110
- if (m.method === 'Target.detachedFromTarget') {
111
- attached.delete(m.params?.sessionId);
112
- emit({ kind: 'tab_close' });
113
- return;
114
- }
115
- if (m.method === 'Runtime.bindingCalled' && m.params?.name === '__addaiLearn') {
116
- let ev;
117
- try { ev = JSON.parse(m.params.payload); } catch { return; }
118
- emit(ev);
119
- return;
120
- }
121
- if (m.method === 'Page.frameNavigated' && !m.params?.frame?.parentId) {
122
- emit({ kind: 'url', url: m.params.frame.url ?? null, title: null });
123
- // A navigation replaces the execution context; re-arming is cheap and
124
- // idempotent, and skipping it loses every click on the new page.
125
- if (s) { attached.delete(s); await arm(s); }
126
- return;
127
- }
128
- if (m.method === 'Page.loadEventFired' && s) {
129
- const r = await cdp.send('Runtime.evaluate',
130
- { expression: 'document.title', returnByValue: true }, s);
131
- emit({ kind: 'load', title: r?.result?.value ?? null });
132
- return;
133
- }
134
- if (m.method === 'Page.javascriptDialogOpening') {
135
- emit({ kind: 'dialog', dtype: m.params?.type ?? null, message: m.params?.message ?? null });
136
- }
137
- });
138
-
139
- await cdp.send('Target.setDiscoverTargets', { discover: true });
140
- emit({ kind: 'watching' });
141
-
142
- const bye = () => { try { cdp.ws.close(); } catch { /* already gone */ } process.exit(0); };
143
- process.on('SIGTERM', bye);
144
- process.on('SIGINT', bye);
145
- cdp.ws.addEventListener('close', () => process.exit(0));
146
- }
147
-
148
- main().catch((e) => {
149
- emit({ kind: 'recorder_error', message: String(e && e.message ? e.message : e) });
150
- process.exit(1);
151
- });
1
+ // The CDP recorder. Runs INSIDE the desktop container, next to the browser.
2
+ //
3
+ // node /conf/learn/recorder.mjs
4
+ //
5
+ // Writes NDJSON on stdout, one event per line, `t` in ms since it attached.
6
+ // The daemon reads that stream directly — no file, no polling — and batches it
7
+ // into the lesson row.
8
+ //
9
+ // Nothing is installed to make this work: the image already has node 22, whose
10
+ // global WebSocket is enough for CDP, and Chrome, which the browser wrapper has
11
+ // already given a debug port. If the port is not there (a browser opened before
12
+ // the wrapper existed) it says so in one line and exits 3, and the lesson goes
13
+ // on without the browser track rather than failing.
14
+ import { injectedSource } from './injected.mjs';
15
+
16
+ const PORT = Number(process.env.ADDAI_LEARN_PORT || 9222);
17
+ const t0 = Date.now();
18
+
19
+ const emit = (o) => {
20
+ try { process.stdout.write(JSON.stringify({ t: Date.now() - t0, ...o }) + '\n'); }
21
+ catch { /* the daemon went away; nothing to do about it from here */ }
22
+ };
23
+
24
+ async function version() {
25
+ // A browser that is starting up refuses for a moment. Three tries over ~3s
26
+ // covers the case where a lesson begins in the same breath as the browser.
27
+ for (let i = 0; i < 3; i++) {
28
+ try {
29
+ const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
30
+ if (r.ok) return await r.json();
31
+ } catch { /* not up yet */ }
32
+ await new Promise((r) => setTimeout(r, 1000));
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function client(url) {
38
+ const ws = new WebSocket(url);
39
+ let next = 1;
40
+ const pending = new Map();
41
+ const handlers = [];
42
+
43
+ const send = (method, params = {}, sessionId) =>
44
+ new Promise((resolve) => {
45
+ const id = next++;
46
+ pending.set(id, resolve);
47
+ const msg = { id, method, params };
48
+ if (sessionId) msg.sessionId = sessionId;
49
+ try { ws.send(JSON.stringify(msg)); } catch { pending.delete(id); resolve(null); }
50
+ });
51
+
52
+ ws.addEventListener('message', (ev) => {
53
+ let m;
54
+ try { m = JSON.parse(ev.data); } catch { return; }
55
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? null); pending.delete(m.id); return; }
56
+ for (const h of handlers) h(m);
57
+ });
58
+
59
+ return {
60
+ ws,
61
+ send,
62
+ on: (fn) => handlers.push(fn),
63
+ ready: new Promise((resolve, reject) => {
64
+ ws.addEventListener('open', resolve);
65
+ ws.addEventListener('error', () => reject(new Error('cdp socket refused')));
66
+ }),
67
+ };
68
+ }
69
+
70
+ async function main() {
71
+ const v = await version();
72
+ if (!v || !v.webSocketDebuggerUrl) {
73
+ emit({ kind: 'no_port' });
74
+ process.exit(3);
75
+ }
76
+
77
+ const cdp = client(v.webSocketDebuggerUrl);
78
+ await cdp.ready;
79
+
80
+ const source = injectedSource();
81
+ const attached = new Set();
82
+
83
+ const arm = async (sessionId) => {
84
+ if (attached.has(sessionId)) return;
85
+ attached.add(sessionId);
86
+ await cdp.send('Runtime.enable', {}, sessionId);
87
+ await cdp.send('Page.enable', {}, sessionId);
88
+ // The binding is how the page talks back. Added before the script that
89
+ // calls it, or the first click on an already-open page is lost.
90
+ await cdp.send('Runtime.addBinding', { name: '__addaiLearn' }, sessionId);
91
+ await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId);
92
+ // …and once for the document that is already loaded, which the line above
93
+ // does not cover.
94
+ await cdp.send('Runtime.evaluate', { expression: source }, sessionId);
95
+ };
96
+
97
+ cdp.on(async (m) => {
98
+ const s = m.sessionId;
99
+ if (m.method === 'Target.targetCreated' && m.params?.targetInfo?.type === 'page') {
100
+ await cdp.send('Target.attachToTarget', { targetId: m.params.targetInfo.targetId, flatten: true });
101
+ return;
102
+ }
103
+ if (m.method === 'Target.attachedToTarget') {
104
+ const info = m.params?.targetInfo ?? {};
105
+ if (info.type !== 'page') return;
106
+ emit({ kind: 'tab_open', url: info.url ?? null });
107
+ await arm(m.params.sessionId);
108
+ return;
109
+ }
110
+ if (m.method === 'Target.detachedFromTarget') {
111
+ attached.delete(m.params?.sessionId);
112
+ emit({ kind: 'tab_close' });
113
+ return;
114
+ }
115
+ if (m.method === 'Runtime.bindingCalled' && m.params?.name === '__addaiLearn') {
116
+ let ev;
117
+ try { ev = JSON.parse(m.params.payload); } catch { return; }
118
+ emit(ev);
119
+ return;
120
+ }
121
+ if (m.method === 'Page.frameNavigated' && !m.params?.frame?.parentId) {
122
+ emit({ kind: 'url', url: m.params.frame.url ?? null, title: null });
123
+ // A navigation replaces the execution context; re-arming is cheap and
124
+ // idempotent, and skipping it loses every click on the new page.
125
+ if (s) { attached.delete(s); await arm(s); }
126
+ return;
127
+ }
128
+ if (m.method === 'Page.loadEventFired' && s) {
129
+ const r = await cdp.send('Runtime.evaluate',
130
+ { expression: 'document.title', returnByValue: true }, s);
131
+ emit({ kind: 'load', title: r?.result?.value ?? null });
132
+ return;
133
+ }
134
+ if (m.method === 'Page.javascriptDialogOpening') {
135
+ emit({ kind: 'dialog', dtype: m.params?.type ?? null, message: m.params?.message ?? null });
136
+ }
137
+ });
138
+
139
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
140
+ emit({ kind: 'watching' });
141
+
142
+ const bye = () => { try { cdp.ws.close(); } catch { /* already gone */ } process.exit(0); };
143
+ process.on('SIGTERM', bye);
144
+ process.on('SIGINT', bye);
145
+ cdp.ws.addEventListener('close', () => process.exit(0));
146
+ }
147
+
148
+ main().catch((e) => {
149
+ emit({ kind: 'recorder_error', message: String(e && e.message ? e.message : e) });
150
+ process.exit(1);
151
+ });
@@ -17,8 +17,21 @@ export declare function setStatus(id: string, fields: {
17
17
  * but not recorded means "password check failed" at the RFB handshake. */
18
18
  vnc_password?: string | null;
19
19
  }): Promise<void>;
20
- /** A free loopback port for this desktop's VNC. Asking the OS for port 0 and
21
- * reading back what it bound is the only race-free way to pick one. */
20
+ /** A free loopback port for this desktop and, at a fixed offset from it, a
21
+ * free one for its xpra channel too.
22
+ *
23
+ * ⚠️ BOTH are checked, because only ONE is stored.
24
+ *
25
+ * The row carries a single port and the xpra port is computed from it (see
26
+ * XPRA_PORT_OFFSET), so a base whose partner is already taken would give a
27
+ * desktop whose windows channel quietly dials some other service on the
28
+ * machine. Binding the second proves it free at the moment of choosing.
29
+ *
30
+ * Nothing holds either port between here and `docker run` — that is the same
31
+ * race the single-port version always had, and sequential allocation by the
32
+ * OS makes it vanishingly unlikely. Several attempts rather than one because
33
+ * a busy machine can hand back a base whose partner happens to be in use,
34
+ * and retrying costs nothing next to failing to create the desktop. */
22
35
  export declare function allocateVncPort(): Promise<number>;
23
36
  export declare function ensureDirs(desktopId: string): {
24
37
  confDir: string;
@@ -111,18 +111,50 @@ async function setStatus(id, fields) {
111
111
  console.error('[desktops] status update failed:', err.message);
112
112
  }
113
113
  }
114
- /** A free loopback port for this desktop's VNC. Asking the OS for port 0 and
115
- * reading back what it bound is the only race-free way to pick one. */
116
- function allocateVncPort() {
114
+ /** Bind a loopback port and read back what was actually bound. Port 0 asks the
115
+ * OS to choose, which is the only race-free way to pick a free one. */
116
+ function reserve(port) {
117
117
  return new Promise((resolve, reject) => {
118
118
  const srv = net.createServer();
119
119
  srv.once('error', reject);
120
- srv.listen(0, '127.0.0.1', () => {
121
- const port = srv.address().port;
122
- srv.close(() => resolve(port));
120
+ srv.listen(port, '127.0.0.1', () => {
121
+ const got = srv.address().port;
122
+ srv.close(() => resolve(got));
123
123
  });
124
124
  });
125
125
  }
126
+ /** A free loopback port for this desktop — and, at a fixed offset from it, a
127
+ * free one for its xpra channel too.
128
+ *
129
+ * ⚠️ BOTH are checked, because only ONE is stored.
130
+ *
131
+ * The row carries a single port and the xpra port is computed from it (see
132
+ * XPRA_PORT_OFFSET), so a base whose partner is already taken would give a
133
+ * desktop whose windows channel quietly dials some other service on the
134
+ * machine. Binding the second proves it free at the moment of choosing.
135
+ *
136
+ * Nothing holds either port between here and `docker run` — that is the same
137
+ * race the single-port version always had, and sequential allocation by the
138
+ * OS makes it vanishingly unlikely. Several attempts rather than one because
139
+ * a busy machine can hand back a base whose partner happens to be in use,
140
+ * and retrying costs nothing next to failing to create the desktop. */
141
+ async function allocateVncPort() {
142
+ let lastErr = null;
143
+ for (let attempt = 0; attempt < 8; attempt += 1) {
144
+ const base = await reserve(0);
145
+ try {
146
+ await reserve(base + spec_1.XPRA_PORT_OFFSET);
147
+ return base;
148
+ }
149
+ catch (err) {
150
+ lastErr = err;
151
+ }
152
+ }
153
+ // Every attempt found the partner taken. Saying so beats handing back a port
154
+ // whose second half belongs to something else.
155
+ throw new Error('could not find a free port pair for this desktop after 8 tries: '
156
+ + (lastErr?.message ?? 'unknown'));
157
+ }
126
158
  function ensureDirs(desktopId) {
127
159
  const confDir = (0, spec_1.confDirFor)(desktopId);
128
160
  const workDir = (0, spec_1.workDirFor)(desktopId);
@@ -29,6 +29,15 @@ function buildCreateArgs(row, opts) {
29
29
  // Loopback ONLY. Binding 0.0.0.0 would expose a logged-in desktop to the
30
30
  // whole LAN; the relay reaches it from the daemon on the same host.
31
31
  '-p', `127.0.0.1:${opts.vncPort}:${spec_1.VNC_PORT_IN_CONTAINER}`,
32
+ // The same desktop's windows, one at a time, over xpra. Published at a
33
+ // fixed offset from the port above because the row stores only that one —
34
+ // see XPRA_PORT_OFFSET, and allocateVncPort, which reserves the pair.
35
+ //
36
+ // Harmless on an image built before xpra existed: nothing listens inside,
37
+ // the publish still succeeds, and a viewer asking for the windows channel
38
+ // gets a refused connection that the bridge reports as "lost the desktop
39
+ // connection" rather than leaving them on a frozen frame.
40
+ '-p', `127.0.0.1:${opts.vncPort + spec_1.XPRA_PORT_OFFSET}:${spec_1.XPRA_PORT_IN_CONTAINER}`,
32
41
  '-v', `${opts.confDir}:${spec_1.CONF_ROOT}`,
33
42
  ];
34
43
  if (opts.workDir)
@@ -49,14 +49,17 @@ exports.stopRelayClient = stopRelayClient;
49
49
  // whose socket is down; this is the fast path, not the only path.
50
50
  //
51
51
  // Two kinds of traffic share it:
52
- // - RAW RFB bytes for desktop viewers. The container serves RFB directly
53
- // (no websockify), so noVNC in the browser speaks RFB across it unchanged.
52
+ // - RAW bytes for desktop viewers, on whichever channel they asked for: RFB
53
+ // for the whole screen, xpra for its windows one at a time. The bridge
54
+ // never parses either — it is a byte pipe, and that is what lets a second
55
+ // protocol ride it without this file learning anything about it.
54
56
  // - `wake` control frames, which cost nothing when idle and turn a ten
55
57
  // second wait into a round trip.
56
58
  const ws_1 = __importDefault(require("ws"));
57
59
  const net = __importStar(require("net"));
58
60
  const store_1 = require("../store");
59
61
  const manager_1 = require("./manager");
62
+ const spec_1 = require("./spec");
60
63
  // The deployed relay. Overridable so a node can be pointed at a local one.
61
64
  const RELAY_URL = process.env.AINODE_RELAY_URL
62
65
  ?? 'wss://desktop-relay-29522465016.europe-west2.run.app';
@@ -182,9 +185,30 @@ async function connect() {
182
185
  gone(msg.viewerId, 'desktop is not running');
183
186
  return;
184
187
  }
185
- // Loopback only - the container published its RFB on 127.0.0.1 and this
186
- // process is the only thing on the machine that reaches it.
187
- const tcp = net.connect(row.vnc_port, '127.0.0.1');
188
+ // WHICH stream of that desktop.
189
+ //
190
+ // 'screen' is the whole framebuffer over RFB, and is what every viewer
191
+ // asked for before this existed — so an absent or unrecognised channel
192
+ // means that. An older relay sends no channel at all and gets exactly
193
+ // the behaviour it always got.
194
+ //
195
+ // 'windows' is xpra on the container's second display, delivering each
196
+ // application window on its own. The bridge below does not care which:
197
+ // it is a byte pipe, and carries xpra's protocol as blindly as RFB.
198
+ //
199
+ // ⚠️ The xpra port is DERIVED, not stored. entity_node_desktops has one
200
+ // port column, and adding a second would mean a migration plus a fleet
201
+ // that disagrees with its own schema for the hour it takes to update.
202
+ // The container publishes 5900 and 14500, and the daemon maps the row's
203
+ // port to the first — so the second sits at a fixed offset from it.
204
+ // provider.ts is the other half of this bargain; if the published ports
205
+ // ever stop being a pair, these two lines are what know about it.
206
+ const port = msg.channel === 'windows'
207
+ ? row.vnc_port + spec_1.XPRA_PORT_OFFSET
208
+ : row.vnc_port;
209
+ // Loopback only - the container published its ports on 127.0.0.1 and
210
+ // this process is the only thing on the machine that reaches them.
211
+ const tcp = net.connect(port, '127.0.0.1');
188
212
  tcp.on('data', chunk => {
189
213
  if (sock.readyState === ws_1.default.OPEN) {
190
214
  sock.send(JSON.stringify({
@@ -34,6 +34,29 @@ export declare const CONF_ROOT = "/conf";
34
34
  * websockify in the container too would mean a WebSocket handshake tunnelled
35
35
  * inside a WebSocket, which no client can read. */
36
36
  export declare const VNC_PORT_IN_CONTAINER = 5900;
37
+ /** The container's xpra port, also fixed by the image.
38
+ *
39
+ * The same machine delivered a second way: 5900 is the whole framebuffer,
40
+ * this is its application windows one at a time. Raw for the same reason —
41
+ * xpra's own protocol carried across the relay's WebSocket, not tunnelled
42
+ * inside a second one. */
43
+ export declare const XPRA_PORT_IN_CONTAINER = 14500;
44
+ /** How far a desktop's host xpra port sits from its host VNC port.
45
+ *
46
+ * ⚠️ DERIVED rather than stored, and that is a deliberate trade.
47
+ *
48
+ * entity_node_desktops has one port column. A second would mean a migration,
49
+ * a reconciler that fills it, and — because the fleet auto-updates hourly —
50
+ * an hour in which some daemons write the new column and others do not, for
51
+ * rows both are reading. A fixed offset needs none of that: the pair is
52
+ * reserved together and both ends compute the same second number from the
53
+ * one value the row already carries.
54
+ *
55
+ * The cost is that the two ports must be allocated together and published
56
+ * together, or the offset points at something else's port. allocateVncPort
57
+ * and buildCreateArgs are the two halves of that bargain here;
58
+ * relay-client.ts is the third, on the far side of the wire. */
59
+ export declare const XPRA_PORT_OFFSET = 100;
37
60
  /** Host directory bind-mounted at CONF_ROOT for one desktop. */
38
61
  export declare function confDirFor(desktopId: string): string;
39
62
  /** Host directory holding the desktop's persistent /work volume. */
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.VNC_PORT_IN_CONTAINER = exports.CONF_ROOT = exports.WORK_ROOT = void 0;
36
+ exports.XPRA_PORT_OFFSET = exports.XPRA_PORT_IN_CONTAINER = exports.VNC_PORT_IN_CONTAINER = exports.CONF_ROOT = exports.WORK_ROOT = void 0;
37
37
  exports.confDirFor = confDirFor;
38
38
  exports.workDirFor = workDirFor;
39
39
  // Shapes and paths shared by every desktop module. No I/O here.
@@ -52,6 +52,29 @@ exports.CONF_ROOT = '/conf';
52
52
  * websockify in the container too would mean a WebSocket handshake tunnelled
53
53
  * inside a WebSocket, which no client can read. */
54
54
  exports.VNC_PORT_IN_CONTAINER = 5900;
55
+ /** The container's xpra port, also fixed by the image.
56
+ *
57
+ * The same machine delivered a second way: 5900 is the whole framebuffer,
58
+ * this is its application windows one at a time. Raw for the same reason —
59
+ * xpra's own protocol carried across the relay's WebSocket, not tunnelled
60
+ * inside a second one. */
61
+ exports.XPRA_PORT_IN_CONTAINER = 14500;
62
+ /** How far a desktop's host xpra port sits from its host VNC port.
63
+ *
64
+ * ⚠️ DERIVED rather than stored, and that is a deliberate trade.
65
+ *
66
+ * entity_node_desktops has one port column. A second would mean a migration,
67
+ * a reconciler that fills it, and — because the fleet auto-updates hourly —
68
+ * an hour in which some daemons write the new column and others do not, for
69
+ * rows both are reading. A fixed offset needs none of that: the pair is
70
+ * reserved together and both ends compute the same second number from the
71
+ * one value the row already carries.
72
+ *
73
+ * The cost is that the two ports must be allocated together and published
74
+ * together, or the offset points at something else's port. allocateVncPort
75
+ * and buildCreateArgs are the two halves of that bargain here;
76
+ * relay-client.ts is the third, on the far side of the wire. */
77
+ exports.XPRA_PORT_OFFSET = 100;
55
78
  /** Host directory bind-mounted at CONF_ROOT for one desktop. */
56
79
  function confDirFor(desktopId) {
57
80
  return path.join(paths_1.RUNTIME_HOME, 'desktops', desktopId, 'conf');