@addai/node 0.30.0 → 0.30.2
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.
|
@@ -4,6 +4,28 @@ export declare const DEBUG_PORT = 9222;
|
|
|
4
4
|
* it would point the wrapper at itself and exec forever. */
|
|
5
5
|
export declare const REAL_BROWSERS: string[];
|
|
6
6
|
export declare const WRAPPER_PATH = "/usr/local/bin/browser";
|
|
7
|
+
/**
|
|
8
|
+
* A line of the wrapper distinctive enough to recognise it by.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ This exists because the wrapper used to destroy the browser it wraps.
|
|
11
|
+
*
|
|
12
|
+
* In the image WRAPPER_PATH is a SYMLINK — `ln -sf /usr/bin/chromium
|
|
13
|
+
* /usr/local/bin/browser`. `cp` follows a symlink destination and writes
|
|
14
|
+
* THROUGH it, so `cp wrapper /usr/local/bin/browser` overwrote /usr/bin/
|
|
15
|
+
* chromium — the real browser — with a script whose last line is
|
|
16
|
+
*
|
|
17
|
+
* exec /usr/bin/chromium --remote-debugging-port=9222 ... "$@"
|
|
18
|
+
*
|
|
19
|
+
* which is now itself. Each hop re-appended the flags and passed the lot on
|
|
20
|
+
* as "$@", so a single launch exec'd itself until the argument list blew up.
|
|
21
|
+
* From the outside: the browser simply never opened, and the flags appeared
|
|
22
|
+
* hundreds of times over in ps.
|
|
23
|
+
*
|
|
24
|
+
* The write is fixed below. This marker is the second line of defence: a
|
|
25
|
+
* candidate that is really this wrapper is never treated as a real browser,
|
|
26
|
+
* so the loop cannot be rebuilt even if something else recreates the shape.
|
|
27
|
+
*/
|
|
28
|
+
export declare const WRAPPER_MARKER = "Written by @addai/node";
|
|
7
29
|
/** The in-container script that signs the browser in. Beside the extension,
|
|
8
30
|
* under the same bind mount, so it arrives by the same route. */
|
|
9
31
|
export declare const SEED_SCRIPT = "/conf/vault/vault-seed.mjs";
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.SEED_SCRIPT = exports.WRAPPER_PATH = exports.REAL_BROWSERS = exports.DEBUG_PORT = void 0;
|
|
36
|
+
exports.SEED_SCRIPT = exports.WRAPPER_MARKER = exports.WRAPPER_PATH = exports.REAL_BROWSERS = exports.DEBUG_PORT = void 0;
|
|
37
37
|
exports.wrapperScript = wrapperScript;
|
|
38
38
|
exports.wrapperIsCurrent = wrapperIsCurrent;
|
|
39
39
|
exports.ensureBrowserWrapper = ensureBrowserWrapper;
|
|
@@ -65,6 +65,28 @@ exports.REAL_BROWSERS = [
|
|
|
65
65
|
'/usr/bin/chromium-browser',
|
|
66
66
|
];
|
|
67
67
|
exports.WRAPPER_PATH = '/usr/local/bin/browser';
|
|
68
|
+
/**
|
|
69
|
+
* A line of the wrapper distinctive enough to recognise it by.
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ This exists because the wrapper used to destroy the browser it wraps.
|
|
72
|
+
*
|
|
73
|
+
* In the image WRAPPER_PATH is a SYMLINK — `ln -sf /usr/bin/chromium
|
|
74
|
+
* /usr/local/bin/browser`. `cp` follows a symlink destination and writes
|
|
75
|
+
* THROUGH it, so `cp wrapper /usr/local/bin/browser` overwrote /usr/bin/
|
|
76
|
+
* chromium — the real browser — with a script whose last line is
|
|
77
|
+
*
|
|
78
|
+
* exec /usr/bin/chromium --remote-debugging-port=9222 ... "$@"
|
|
79
|
+
*
|
|
80
|
+
* which is now itself. Each hop re-appended the flags and passed the lot on
|
|
81
|
+
* as "$@", so a single launch exec'd itself until the argument list blew up.
|
|
82
|
+
* From the outside: the browser simply never opened, and the flags appeared
|
|
83
|
+
* hundreds of times over in ps.
|
|
84
|
+
*
|
|
85
|
+
* The write is fixed below. This marker is the second line of defence: a
|
|
86
|
+
* candidate that is really this wrapper is never treated as a real browser,
|
|
87
|
+
* so the loop cannot be rebuilt even if something else recreates the shape.
|
|
88
|
+
*/
|
|
89
|
+
exports.WRAPPER_MARKER = 'Written by @addai/node';
|
|
68
90
|
/** The in-container script that signs the browser in. Beside the extension,
|
|
69
91
|
* under the same bind mount, so it arrives by the same route. */
|
|
70
92
|
exports.SEED_SCRIPT = '/conf/vault/vault-seed.mjs';
|
|
@@ -112,10 +134,28 @@ function wrapperIsCurrent(content, realBrowser, extensionDir) {
|
|
|
112
134
|
* cheap execs and no change.
|
|
113
135
|
*/
|
|
114
136
|
async function ensureBrowserWrapper(run, confDir, extensionDir) {
|
|
137
|
+
// Each candidate is classified, not just found: one that is really this
|
|
138
|
+
// wrapper (see WRAPPER_MARKER) is a browser an older build overwrote, and
|
|
139
|
+
// pointing a new wrapper at it would rebuild the exec loop.
|
|
115
140
|
const found = await run(['sh', '-c',
|
|
116
|
-
exports.REAL_BROWSERS.
|
|
117
|
-
|
|
141
|
+
`for b in ${exports.REAL_BROWSERS.join(' ')}; do [ -x "$b" ] || continue; ` +
|
|
142
|
+
`if head -c 512 "$b" 2>/dev/null | grep -qF '${exports.WRAPPER_MARKER}'; ` +
|
|
143
|
+
`then echo "wrapped $b"; else echo "real $b"; fi; done`]);
|
|
144
|
+
const lines = found.out.trim().split('\n').map(l => l.trim()).filter(Boolean);
|
|
145
|
+
const real = lines.find(l => l.startsWith('real '))?.slice(5);
|
|
118
146
|
if (!real) {
|
|
147
|
+
const wrapped = lines.find(l => l.startsWith('wrapped '))?.slice(8);
|
|
148
|
+
if (wrapped) {
|
|
149
|
+
// Deliberately not repaired from here. Putting the browser back means
|
|
150
|
+
// reinstalling its package — a network install, on every start, on a
|
|
151
|
+
// machine that is somebody's desktop — and this path is meant to be two
|
|
152
|
+
// cheap execs. Rebuilding the desktop restores it from the image.
|
|
153
|
+
return {
|
|
154
|
+
changed: false, browser: null,
|
|
155
|
+
note: `${wrapped} was overwritten by an older version of this wrapper `
|
|
156
|
+
+ 'and no longer starts a browser. Rebuild this desktop to restore it.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
119
159
|
return { changed: false, browser: null, note: 'this desktop has no browser installed' };
|
|
120
160
|
}
|
|
121
161
|
const current = await run(['sh', '-c', `cat ${exports.WRAPPER_PATH} 2>/dev/null || true`]);
|
|
@@ -127,8 +167,15 @@ async function ensureBrowserWrapper(run, confDir, extensionDir) {
|
|
|
127
167
|
// sudo, not `docker exec -u root`: the image gives the entity passwordless
|
|
128
168
|
// sudo inside its own container, and going through the same exec path as
|
|
129
169
|
// everything else keeps one way of talking to a desktop rather than two.
|
|
170
|
+
// ⚠️ rm FIRST, and it is the whole fix. In the image WRAPPER_PATH is a
|
|
171
|
+
// symlink to the real browser, and `cp` follows a symlink destination —
|
|
172
|
+
// writing through it, over the browser, rather than replacing the link.
|
|
173
|
+
// Unlinking first makes the wrapper a file of its own and leaves whatever
|
|
174
|
+
// it pointed at alone. (`cp --remove-destination` does the same in one
|
|
175
|
+
// call; this shape says why out loud and needs no GNU-only flag.)
|
|
130
176
|
const put = await run(['sh', '-c',
|
|
131
|
-
`sudo
|
|
177
|
+
`sudo rm -f ${exports.WRAPPER_PATH} && sudo cp /conf/learn/browser ${exports.WRAPPER_PATH} `
|
|
178
|
+
+ `&& sudo chmod +x ${exports.WRAPPER_PATH}`]);
|
|
132
179
|
if (!put.ok) {
|
|
133
180
|
return { changed: false, browser: real, note: `could not install the browser wrapper: ${put.out.trim().slice(0, 200)}` };
|
|
134
181
|
}
|
|
@@ -83,6 +83,30 @@ let missedPongs = 0;
|
|
|
83
83
|
* people watching the same screen each need their own connection and their
|
|
84
84
|
* own handshake. x11vnc runs with -shared precisely so it will serve them. */
|
|
85
85
|
const bridges = new Map();
|
|
86
|
+
/**
|
|
87
|
+
* Bytes that arrived while their bridge was still being built.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ There is an await between 'attach' and the socket existing — the row has
|
|
90
|
+
* to be looked up to know which port to dial — and a viewer can send in that
|
|
91
|
+
* window. RFB never did: x11vnc speaks first, so noVNC has nothing to say
|
|
92
|
+
* until the server has already said hello, and the gap was invisible for as
|
|
93
|
+
* long as the screen was the only channel.
|
|
94
|
+
*
|
|
95
|
+
* xpra is the other way round. Its client sends its own hello the moment the
|
|
96
|
+
* socket opens, so on the windows channel the first packet ALWAYS raced the
|
|
97
|
+
* lookup — and losing it was not a dropped packet but a dead session: the data
|
|
98
|
+
* branch found no bridge, reported the viewer gone, and the connection that
|
|
99
|
+
* finished opening a moment later sat there with nothing on it until xpra
|
|
100
|
+
* timed out 20 seconds on:
|
|
101
|
+
*
|
|
102
|
+
* New tcp connection received from '172.17.0.1:58222'
|
|
103
|
+
* 0 packets received
|
|
104
|
+
* 1 packets sent (25 bytes)
|
|
105
|
+
*
|
|
106
|
+
* A viewerId present here means "attaching" — distinct from present in
|
|
107
|
+
* `bridges` (attached) and from neither (gone, and the sender should be told).
|
|
108
|
+
*/
|
|
109
|
+
const pendingWrites = new Map();
|
|
86
110
|
const wakeHooks = new Map();
|
|
87
111
|
function onWake(kind, fn) {
|
|
88
112
|
wakeHooks.set(kind, fn);
|
|
@@ -95,6 +119,9 @@ function dropBridges() {
|
|
|
95
119
|
catch { /* gone */ }
|
|
96
120
|
}
|
|
97
121
|
bridges.clear();
|
|
122
|
+
// The link went down mid-attach. Nothing will ever flush these, and keeping
|
|
123
|
+
// them would hand a reconnecting viewer the last session's first packet.
|
|
124
|
+
pendingWrites.clear();
|
|
98
125
|
}
|
|
99
126
|
/**
|
|
100
127
|
* Start a connection attempt that CANNOT take the daemon down with it.
|
|
@@ -175,11 +202,17 @@ async function connect() {
|
|
|
175
202
|
const gone = (viewerId, reason) => {
|
|
176
203
|
bridges.get(viewerId)?.destroy();
|
|
177
204
|
bridges.delete(viewerId);
|
|
205
|
+
// Anything still waiting for a bridge that is never coming.
|
|
206
|
+
pendingWrites.delete(viewerId);
|
|
178
207
|
if (sock.readyState === ws_1.default.OPEN) {
|
|
179
208
|
sock.send(JSON.stringify({ type: 'gone', viewerId, reason }));
|
|
180
209
|
}
|
|
181
210
|
};
|
|
182
211
|
if (msg.type === 'attach') {
|
|
212
|
+
// Claim the viewer BEFORE the await, so a packet arriving during the
|
|
213
|
+
// lookup is held rather than treated as arriving after a lost bridge.
|
|
214
|
+
if (!pendingWrites.has(msg.viewerId))
|
|
215
|
+
pendingWrites.set(msg.viewerId, []);
|
|
183
216
|
const row = (await (0, manager_1.listDesktops)()).find(d => d.id === msg.desktopId);
|
|
184
217
|
if (!row?.vnc_port) {
|
|
185
218
|
gone(msg.viewerId, 'desktop is not running');
|
|
@@ -223,14 +256,28 @@ async function connect() {
|
|
|
223
256
|
tcp.on('close', () => gone(msg.viewerId, 'the desktop closed the connection'));
|
|
224
257
|
bridges.get(msg.viewerId)?.destroy();
|
|
225
258
|
bridges.set(msg.viewerId, tcp);
|
|
259
|
+
// Whatever arrived while the row was being looked up, in order and
|
|
260
|
+
// before anything newer. net.connect returns a socket that is still
|
|
261
|
+
// connecting, and node queues writes on it until it is — so this needs
|
|
262
|
+
// no wait of its own.
|
|
263
|
+
const queued = pendingWrites.get(msg.viewerId) ?? [];
|
|
264
|
+
pendingWrites.delete(msg.viewerId);
|
|
265
|
+
for (const chunk of queued)
|
|
266
|
+
tcp.write(chunk);
|
|
226
267
|
return;
|
|
227
268
|
}
|
|
228
269
|
if (msg.type === 'data' && msg.b64) {
|
|
229
270
|
const bridge = bridges.get(msg.viewerId);
|
|
230
|
-
// Input for a bridge that no longer exists. Dropping it quietly is how
|
|
231
|
-
// "take control does nothing" happens: the pointer never moves and the
|
|
232
|
-
// viewer has no idea why.
|
|
233
271
|
if (!bridge) {
|
|
272
|
+
// Still attaching: hold it. The first xpra packet always lands here.
|
|
273
|
+
const queue = pendingWrites.get(msg.viewerId);
|
|
274
|
+
if (queue) {
|
|
275
|
+
queue.push(Buffer.from(msg.b64, 'base64'));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Input for a bridge that no longer exists. Dropping it quietly is how
|
|
279
|
+
// "take control does nothing" happens: the pointer never moves and the
|
|
280
|
+
// viewer has no idea why.
|
|
234
281
|
gone(msg.viewerId, 'no longer connected to the desktop');
|
|
235
282
|
return;
|
|
236
283
|
}
|
|
@@ -260,6 +307,8 @@ async function connect() {
|
|
|
260
307
|
if (msg.type === 'detach') {
|
|
261
308
|
bridges.get(msg.viewerId)?.destroy();
|
|
262
309
|
bridges.delete(msg.viewerId);
|
|
310
|
+
// A viewer that left while it was still attaching.
|
|
311
|
+
pendingWrites.delete(msg.viewerId);
|
|
263
312
|
}
|
|
264
313
|
});
|
|
265
314
|
const retry = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addai/node",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.2",
|
|
4
4
|
"description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|