@openagents-org/agent-launcher 0.2.142 → 0.2.145

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.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * NanoClaw control-plane client + environment detection.
3
+ *
4
+ * Management/detection uses NanoClaw's official host control socket
5
+ * `<home>/data/ncl.sock` (the same surface the `ncl` binary speaks). One
6
+ * line-delimited JSON RequestFrame `{id,command,args}` per connection; the
7
+ * host replies with one ResponseFrame `{id,ok,data}` / `{id,ok,false,error}`
8
+ * and closes. We use ONLY `open`-access read commands (`*-list`, `*-get`) —
9
+ * NanoClaw gates create/update/delete behind human approval, so the bridge
10
+ * never silently mutates groups, messaging groups, or wirings.
11
+ *
12
+ * See [[nanoclaw-facts-and-arch]] for the verified contract.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const net = require('net');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const { execFile, execFileSync } = require('child_process');
22
+
23
+ const { redactSecrets } = require('./nanoclaw-protocol');
24
+
25
+ const IS_WINDOWS = process.platform === 'win32';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Path helpers (DATA_DIR = <home>/data, per NanoClaw src/config.ts)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function dataDir(home) {
32
+ return path.join(home, 'data');
33
+ }
34
+ function nclSocketPath(home) {
35
+ return path.join(home, 'data', 'ncl.sock');
36
+ }
37
+ /**
38
+ * Dedicated local-IPC dir for the `openagents` channel. Kept SEPARATE from
39
+ * NanoClaw's `data/` so we can lock it to 0700 without touching NanoClaw's own
40
+ * files. Holds the bridge socket (0600) and the handshake secret (0600).
41
+ */
42
+ function bridgeSocketDir(home) {
43
+ return path.join(home, 'data', 'openagents');
44
+ }
45
+ /** Local IPC socket owned by the native `openagents` channel we ship. */
46
+ function bridgeSocketPath(home) {
47
+ return path.join(bridgeSocketDir(home), 'bridge.sock');
48
+ }
49
+ /** File holding the random per-host handshake secret (channel writes, bridge reads). */
50
+ function bridgeSecretPath(home) {
51
+ return path.join(bridgeSocketDir(home), 'secret');
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Local-IPC security helpers (shared by the bridge; the channel mirrors these
56
+ // in openagents.ts). All best-effort + cross-platform: Unix enforces via file
57
+ // mode + ownership, every platform enforces via the random secret.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** lstat without following symlinks; null if missing. */
61
+ function _lstat(p) {
62
+ try {
63
+ return fs.lstatSync(p);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Is `p` safe to use as a local-trust path? It must NOT be a symlink (blocks
71
+ * path-escape / redirection). Returns {ok, reason}.
72
+ */
73
+ function isPathSafe(p) {
74
+ const st = _lstat(p);
75
+ if (st && st.isSymbolicLink()) return { ok: false, reason: 'path is a symlink' };
76
+ return { ok: true };
77
+ }
78
+
79
+ /**
80
+ * Ensure the IPC dir exists, is a real directory we own, is not a symlink, and
81
+ * is not group/other-accessible. Creates it 0700 if missing. Best-effort on
82
+ * Windows (mode bits are advisory there — the secret is the real guard).
83
+ * @returns {{ok:boolean, reason?:string}}
84
+ */
85
+ function ensureSecureDir(dir) {
86
+ const st = _lstat(dir);
87
+ if (st && st.isSymbolicLink()) return { ok: false, reason: 'ipc dir is a symlink' };
88
+ if (st && !st.isDirectory()) return { ok: false, reason: 'ipc dir path is not a directory' };
89
+ if (!st) {
90
+ try {
91
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
92
+ } catch (e) {
93
+ return { ok: false, reason: `mkdir failed: ${e.message}` };
94
+ }
95
+ }
96
+ try {
97
+ fs.chmodSync(dir, 0o700);
98
+ } catch {
99
+ /* best-effort (Windows) */
100
+ }
101
+ if (!IS_WINDOWS) {
102
+ const after = _lstat(dir);
103
+ if (after) {
104
+ if (typeof process.getuid === 'function' && after.uid !== process.getuid()) {
105
+ return { ok: false, reason: 'ipc dir is owned by another user' };
106
+ }
107
+ if (after.mode & 0o077) return { ok: false, reason: 'ipc dir is group/other-accessible' };
108
+ }
109
+ }
110
+ return { ok: true };
111
+ }
112
+
113
+ /**
114
+ * Read the handshake secret. The file must be a regular file (not a symlink)
115
+ * and, on Unix, not group/other-readable. Returns the secret string or null.
116
+ */
117
+ function readBridgeSecret(home) {
118
+ const p = bridgeSecretPath(home);
119
+ const st = _lstat(p);
120
+ if (!st || st.isSymbolicLink() || !st.isFile()) return null;
121
+ if (!IS_WINDOWS && st.mode & 0o077) return null; // refuse a world/group-readable secret
122
+ try {
123
+ const s = fs.readFileSync(p, 'utf-8').trim();
124
+ return s || null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Unlink `p` ONLY if it is an actual socket file — never a regular file,
132
+ * directory, or symlink. Prevents stale-cleanup from destroying user data.
133
+ * @returns {{removed:boolean, reason?:string}}
134
+ */
135
+ function safeUnlinkSocket(p) {
136
+ const st = _lstat(p);
137
+ if (!st) return { removed: false };
138
+ if (st.isSymbolicLink()) return { removed: false, reason: 'refusing to unlink a symlink' };
139
+ if (!st.isSocket()) return { removed: false, reason: 'refusing to unlink a non-socket file' };
140
+ try {
141
+ fs.unlinkSync(p);
142
+ return { removed: true };
143
+ } catch (e) {
144
+ return { removed: false, reason: e.message };
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Detection
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /** Does `dir` look like a NanoClaw checkout? */
153
+ function looksLikeNanoclaw(dir) {
154
+ try {
155
+ if (!dir || !fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return false;
156
+ const pkgPath = path.join(dir, 'package.json');
157
+ if (fs.existsSync(pkgPath)) {
158
+ try {
159
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
160
+ if (pkg && pkg.name === 'nanoclaw') return true;
161
+ } catch {
162
+ /* fall through */
163
+ }
164
+ }
165
+ // Structural fallback: bin/ncl + src/index.ts (+ src/channels)
166
+ return (
167
+ fs.existsSync(path.join(dir, 'bin', 'ncl')) &&
168
+ fs.existsSync(path.join(dir, 'src', 'index.ts')) &&
169
+ fs.existsSync(path.join(dir, 'src', 'channels'))
170
+ );
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /** Follow `ncl` on PATH back to the checkout root (bin/ncl → <root>). */
177
+ function homeFromNclBinary() {
178
+ try {
179
+ const which = IS_WINDOWS ? 'where' : 'which';
180
+ const out = execFileSync(which, ['ncl'], {
181
+ encoding: 'utf-8',
182
+ timeout: 5000,
183
+ stdio: ['ignore', 'pipe', 'ignore'],
184
+ })
185
+ .split(/\r?\n/)[0]
186
+ .trim();
187
+ if (!out) return null;
188
+ let real = out;
189
+ try {
190
+ real = fs.realpathSync(out);
191
+ } catch {
192
+ /* use as-is */
193
+ }
194
+ // bin/ncl → dirname(bin) → root
195
+ const root = path.dirname(path.dirname(real));
196
+ return looksLikeNanoclaw(root) ? root : null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Resolve the NanoClaw checkout directory.
204
+ * Priority: $NANOCLAW_HOME → `ncl` on PATH → common locations.
205
+ * @returns {{home:string, source:string}|null}
206
+ */
207
+ function findNanoclawHome(env = process.env) {
208
+ const explicit = env.NANOCLAW_HOME && env.NANOCLAW_HOME.trim();
209
+ if (explicit) {
210
+ const resolved = path.resolve(explicit);
211
+ if (looksLikeNanoclaw(resolved)) return { home: resolved, source: 'NANOCLAW_HOME' };
212
+ // Honour the user's intent even if structure check is imperfect, but flag it.
213
+ return { home: resolved, source: 'NANOCLAW_HOME', unverified: true };
214
+ }
215
+
216
+ const fromBin = homeFromNclBinary();
217
+ if (fromBin) return { home: fromBin, source: 'ncl-on-path' };
218
+
219
+ const home = env.HOME || env.USERPROFILE || os.homedir();
220
+ const candidates = [
221
+ path.join(home, 'nanoclaw'),
222
+ path.join(home, '.nanoclaw'),
223
+ path.join(home, 'src', 'nanoclaw'),
224
+ path.join(home, 'code', 'nanoclaw'),
225
+ path.join(home, 'projects', 'nanoclaw'),
226
+ path.join(home, 'dev', 'nanoclaw'),
227
+ ];
228
+ for (const c of candidates) {
229
+ if (looksLikeNanoclaw(c)) return { home: c, source: 'discovered' };
230
+ }
231
+ return null;
232
+ }
233
+
234
+ function whichSafe(bin) {
235
+ try {
236
+ const which = IS_WINDOWS ? 'where' : 'which';
237
+ const out = execFileSync(which, [bin], {
238
+ encoding: 'utf-8',
239
+ timeout: 5000,
240
+ stdio: ['ignore', 'pipe', 'ignore'],
241
+ })
242
+ .split(/\r?\n/)[0]
243
+ .trim();
244
+ return out || null;
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ function checkNode() {
251
+ const bin = whichSafe('node');
252
+ if (!bin) return { present: false };
253
+ try {
254
+ const v = execFileSync(bin, ['--version'], { encoding: 'utf-8', timeout: 5000 }).trim();
255
+ return { present: true, version: v };
256
+ } catch {
257
+ return { present: true };
258
+ }
259
+ }
260
+
261
+ function checkPackageManager() {
262
+ const pnpm = whichSafe('pnpm');
263
+ if (pnpm) return { present: true, manager: 'pnpm', path: pnpm };
264
+ const npm = whichSafe('npm');
265
+ if (npm) return { present: true, manager: 'npm', path: npm };
266
+ return { present: false };
267
+ }
268
+
269
+ /**
270
+ * Check Docker: installed AND daemon reachable. `docker info` exit 0 ⇒ running.
271
+ * `runner` is injectable for tests.
272
+ * @returns {Promise<{installed:boolean, running:boolean, detail:string}>}
273
+ */
274
+ function checkDocker(runner) {
275
+ const run =
276
+ runner ||
277
+ ((cmd, args, cb) =>
278
+ execFile(cmd, args, { timeout: 8000, encoding: 'utf-8' }, (err, stdout, stderr) =>
279
+ cb(err, stdout, stderr),
280
+ ));
281
+ return new Promise((resolve) => {
282
+ const dockerBin = whichSafe('docker');
283
+ if (!dockerBin) {
284
+ resolve({ installed: false, running: false, detail: 'docker binary not found' });
285
+ return;
286
+ }
287
+ run('docker', ['info', '--format', '{{.ServerVersion}}'], (err, stdout, stderr) => {
288
+ if (err) {
289
+ resolve({
290
+ installed: true,
291
+ running: false,
292
+ detail: redactSecrets(((stderr || '') + (err.message || '')).slice(0, 200)),
293
+ });
294
+ return;
295
+ }
296
+ resolve({ installed: true, running: true, detail: String(stdout || '').trim() });
297
+ });
298
+ });
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Control socket client
303
+ // ---------------------------------------------------------------------------
304
+
305
+ class NclControlError extends Error {
306
+ constructor(message, code) {
307
+ super(message);
308
+ this.name = 'NclControlError';
309
+ this.nclCode = code || 'transport-error';
310
+ }
311
+ }
312
+
313
+ class NclControl {
314
+ /**
315
+ * @param {string} socketPath path to data/ncl.sock
316
+ * @param {{timeoutMs?:number}} [opts]
317
+ */
318
+ constructor(socketPath, opts = {}) {
319
+ this.socketPath = socketPath;
320
+ this.timeoutMs = opts.timeoutMs || 8000;
321
+ this._seq = 0;
322
+ }
323
+
324
+ _nextId() {
325
+ this._seq += 1;
326
+ return `oa-${Date.now().toString(36)}-${this._seq}`;
327
+ }
328
+
329
+ /**
330
+ * Send one command, await one response. Resolves with `data`, rejects with
331
+ * an NclControlError carrying `.nclCode` on host-reported failure.
332
+ * @param {string} command e.g. 'groups-list'
333
+ * @param {object} [args]
334
+ * @returns {Promise<unknown>}
335
+ */
336
+ request(command, args = {}) {
337
+ const id = this._nextId();
338
+ const frame = JSON.stringify({ id, command, args }) + '\n';
339
+ return new Promise((resolve, reject) => {
340
+ let settled = false;
341
+ let buffer = '';
342
+ const client = net.createConnection(this.socketPath);
343
+
344
+ const done = (fn, val) => {
345
+ if (settled) return;
346
+ settled = true;
347
+ clearTimeout(timer);
348
+ try {
349
+ client.end();
350
+ } catch {
351
+ /* best-effort */
352
+ }
353
+ try {
354
+ client.destroy();
355
+ } catch {
356
+ /* best-effort */
357
+ }
358
+ fn(val);
359
+ };
360
+
361
+ const timer = setTimeout(
362
+ () => done(reject, new NclControlError(`ncl control request timed out: ${command}`, 'timeout')),
363
+ this.timeoutMs,
364
+ );
365
+
366
+ client.on('connect', () => client.write(frame));
367
+ client.on('data', (chunk) => {
368
+ buffer += chunk.toString('utf8');
369
+ const idx = buffer.indexOf('\n');
370
+ if (idx < 0) {
371
+ // Bound the buffer: a peer streaming without a newline would otherwise
372
+ // grow memory unbounded. Control responses are small JSON lines.
373
+ if (buffer.length > 8 * 1024 * 1024) {
374
+ done(reject, new NclControlError('ncl control response exceeded size limit without a newline', 'transport-error'));
375
+ }
376
+ return;
377
+ }
378
+ const line = buffer.slice(0, idx);
379
+ let resp;
380
+ try {
381
+ resp = JSON.parse(line);
382
+ } catch (e) {
383
+ done(reject, new NclControlError(`malformed ncl response: ${e.message}`, 'transport-error'));
384
+ return;
385
+ }
386
+ if (resp && resp.ok) {
387
+ done(resolve, resp.data);
388
+ } else {
389
+ const err = (resp && resp.error) || {};
390
+ done(reject, new NclControlError(redactSecrets(err.message || 'ncl error'), err.code || 'handler-error'));
391
+ }
392
+ });
393
+ client.on('error', (err) => {
394
+ const code = err && err.code === 'ENOENT' ? 'host-not-running' : err && err.code === 'ECONNREFUSED' ? 'host-not-running' : 'transport-error';
395
+ done(reject, new NclControlError(redactSecrets(err.message || 'socket error'), code));
396
+ });
397
+ client.on('close', () => {
398
+ if (!settled) done(reject, new NclControlError('host closed connection before responding', 'transport-error'));
399
+ });
400
+ });
401
+ }
402
+
403
+ /** Liveness probe — `groups-list` is an `open` command, safe to call. */
404
+ async ping() {
405
+ await this.request('groups-list', {});
406
+ return true;
407
+ }
408
+
409
+ _asArray(data) {
410
+ if (Array.isArray(data)) return data;
411
+ if (data && Array.isArray(data.rows)) return data.rows;
412
+ if (data && Array.isArray(data.items)) return data.items;
413
+ return data == null ? [] : [data];
414
+ }
415
+
416
+ async listGroups() {
417
+ return this._asArray(await this.request('groups-list', {}));
418
+ }
419
+ async getGroup(id) {
420
+ return this.request('groups-get', { id });
421
+ }
422
+ async listMessagingGroups(filters = {}) {
423
+ return this._asArray(await this.request('messaging-groups-list', filters));
424
+ }
425
+ async listWirings(filters = {}) {
426
+ return this._asArray(await this.request('wirings-list', filters));
427
+ }
428
+ async listSessions(filters = {}) {
429
+ return this._asArray(await this.request('sessions-list', filters));
430
+ }
431
+ }
432
+
433
+ /**
434
+ * One-shot aggregate environment probe used at adapter start and for the
435
+ * launcher/workspace status surface.
436
+ * @returns {Promise<object>}
437
+ */
438
+ async function detectEnvironment(env = process.env, opts = {}) {
439
+ const homeInfo = findNanoclawHome(env);
440
+ const node = checkNode();
441
+ const pm = checkPackageManager();
442
+ const docker = await checkDocker(opts.dockerRunner);
443
+
444
+ const result = {
445
+ installed: !!homeInfo,
446
+ home: homeInfo ? homeInfo.home : null,
447
+ homeSource: homeInfo ? homeInfo.source : null,
448
+ node,
449
+ packageManager: pm,
450
+ docker,
451
+ hostRunning: false,
452
+ nclSocket: null,
453
+ bridgeSocket: null,
454
+ };
455
+
456
+ if (homeInfo) {
457
+ result.nclSocket = nclSocketPath(homeInfo.home);
458
+ result.bridgeSocket = bridgeSocketPath(homeInfo.home);
459
+ // Host running iff the control socket exists AND answers.
460
+ if (fs.existsSync(result.nclSocket)) {
461
+ try {
462
+ const ctl = new NclControl(result.nclSocket, { timeoutMs: opts.pingTimeoutMs || 4000 });
463
+ await ctl.ping();
464
+ result.hostRunning = true;
465
+ } catch {
466
+ result.hostRunning = false;
467
+ }
468
+ }
469
+ }
470
+ return result;
471
+ }
472
+
473
+ module.exports = {
474
+ dataDir,
475
+ nclSocketPath,
476
+ bridgeSocketDir,
477
+ bridgeSocketPath,
478
+ bridgeSecretPath,
479
+ isPathSafe,
480
+ ensureSecureDir,
481
+ readBridgeSecret,
482
+ safeUnlinkSocket,
483
+ looksLikeNanoclaw,
484
+ findNanoclawHome,
485
+ checkNode,
486
+ checkPackageManager,
487
+ checkDocker,
488
+ detectEnvironment,
489
+ NclControl,
490
+ NclControlError,
491
+ };