@openagents-org/agent-launcher 0.2.143 → 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,411 @@
1
+ /**
2
+ * Install / verify the native NanoClaw `openagents` channel.
3
+ *
4
+ * The channel source ships with this package (nanoclaw-channel/openagents.ts).
5
+ * Installing it means: copy it to `<home>/src/channels/openagents.ts` and add a
6
+ * marker-delimited `import './openagents.js';` block to `src/channels/index.ts`
7
+ * so it self-registers on host startup — the same thing NanoClaw's own
8
+ * `/add-<channel>` skills do. We never touch the database.
9
+ *
10
+ * This is an EXPLICIT, opt-in action (not run automatically on every adapter
11
+ * start) because it modifies the user's NanoClaw checkout and needs a host
12
+ * restart. It is COMPATIBILITY-GATED and ROLLBACK-SAFE:
13
+ * - refuses unknown / incompatible NanoClaw versions (no modification);
14
+ * - verifies the expected dirs + the `ChannelAdapter` interface + the barrel
15
+ * before touching anything;
16
+ * - writes atomically and restores the barrel on any failure;
17
+ * - never overwrites a user's own `openagents.ts` (only files we own);
18
+ * - edits the barrel via a precise marker block, never fuzzy text replace;
19
+ * - validates the barrel after editing.
20
+ *
21
+ * If NanoClaw later exposes a formal channel-install entry point, prefer it.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const crypto = require('crypto');
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const { execFileSync } = require('child_process');
30
+
31
+ const { looksLikeNanoclaw } = require('./nanoclaw-control');
32
+
33
+ // The EXACT NanoClaw revision this channel was verified against. Auto-install is
34
+ // gated on this precise commit (NOT a semver range): a different commit — even
35
+ // with the same version number — is treated as unverified.
36
+ const VERIFIED = {
37
+ remote: 'https://github.com/nanocoai/nanoclaw',
38
+ version: '2.1.19',
39
+ commit: '625264ba4b9de0a466d10debb267ca9ad688f4c0',
40
+ // The verified commit is the cloned `main` HEAD reporting version 2.1.19.
41
+ // It is NOT confirmed to be a tagged release.
42
+ tag: null,
43
+ // Fingerprint of the ChannelAdapter method signatures in adapter.ts at the
44
+ // verified commit. Extra protection / basis for a future tag+fingerprint
45
+ // allowlist; the auto-install gate remains the exact commit.
46
+ interfaceFingerprint: '6bde65465812d49d',
47
+ };
48
+ // Commits we will auto-install into without an explicit force. Currently just
49
+ // the verified commit; a future release can add {commit, fingerprint} entries.
50
+ const AUTO_INSTALL_COMMITS = new Set([VERIFIED.commit]);
51
+
52
+ // A line present in the shipped channel header — used to recognise a file we own.
53
+ const OURS_MARKER = 'OPENAGENTS-CHANNEL v1';
54
+ const BARREL_BEGIN = '// >>> openagents channel (OpenAgents Workspace bridge) — managed, do not edit';
55
+ const BARREL_IMPORT = "import './openagents.js';";
56
+ const BARREL_END = '// <<< openagents channel';
57
+ const BARREL_BLOCK = `${BARREL_BEGIN}\n${BARREL_IMPORT}\n${BARREL_END}\n`;
58
+
59
+ function channelSourcePath() {
60
+ return path.join(__dirname, '..', '..', 'nanoclaw-channel', 'openagents.ts');
61
+ }
62
+ function channelDestPath(home) {
63
+ return path.join(home, 'src', 'channels', 'openagents.ts');
64
+ }
65
+ function barrelPath(home) {
66
+ return path.join(home, 'src', 'channels', 'index.ts');
67
+ }
68
+
69
+ function readFileSafe(p) {
70
+ try {
71
+ return fs.readFileSync(p, 'utf-8');
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function getNanoclawVersion(home) {
78
+ const raw = readFileSafe(path.join(home, 'package.json'));
79
+ if (!raw) return null;
80
+ try {
81
+ const v = JSON.parse(raw).version;
82
+ return typeof v === 'string' ? v : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** Resolve the checkout's git commit SHA (null if not a git repo / unknown). */
89
+ function getNanoclawCommit(home, opts = {}) {
90
+ if (Object.prototype.hasOwnProperty.call(opts, 'commitOverride')) {
91
+ const c = opts.commitOverride;
92
+ return c && /^[0-9a-f]{40}$/.test(c) ? c : null;
93
+ }
94
+ try {
95
+ const out = execFileSync('git', ['-C', home, 'rev-parse', 'HEAD'], {
96
+ encoding: 'utf-8',
97
+ timeout: 5000,
98
+ stdio: ['ignore', 'pipe', 'ignore'],
99
+ });
100
+ const sha = String(out || '').trim();
101
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** Fingerprint the ChannelAdapter method signatures in adapter.ts. */
108
+ function computeInterfaceFingerprint(adapterTs) {
109
+ if (adapterTs == null) return null;
110
+ const sig = (
111
+ adapterTs.match(/(onInbound|onMetadata|onInboundEvent|onAction|deliver|setup|teardown|isConnected|setTyping)\s*\(/g) ||
112
+ []
113
+ )
114
+ .sort()
115
+ .join('|');
116
+ return crypto.createHash('sha256').update(sig).digest('hex').slice(0, 16);
117
+ }
118
+
119
+ /**
120
+ * Structural / interface pre-flight — a HARD gate that even `force` cannot
121
+ * bypass (installing into a checkout with a moved channel API would produce a
122
+ * broken host). Returns {ok, reason, fingerprint}.
123
+ */
124
+ function checkStructure(home) {
125
+ const adapterTs = readFileSafe(path.join(home, 'src', 'channels', 'adapter.ts'));
126
+ const registryTs = readFileSafe(path.join(home, 'src', 'channels', 'channel-registry.ts'));
127
+ const configTs = readFileSafe(path.join(home, 'src', 'config.ts'));
128
+ const barrel = readFileSafe(barrelPath(home));
129
+ const missing = [];
130
+ if (adapterTs == null) missing.push('src/channels/adapter.ts');
131
+ if (registryTs == null) missing.push('src/channels/channel-registry.ts');
132
+ if (configTs == null) missing.push('src/config.ts');
133
+ if (barrel == null) missing.push('src/channels/index.ts');
134
+ if (missing.length) return { ok: false, reason: `missing expected files: ${missing.join(', ')}`, fingerprint: null };
135
+
136
+ const fingerprint = computeInterfaceFingerprint(adapterTs);
137
+ if (!(/interface\s+ChannelAdapter/.test(adapterTs) && /onInbound\s*\(/.test(adapterTs) && /deliver\s*\(/.test(adapterTs))) {
138
+ return { ok: false, reason: 'ChannelAdapter interface shape not recognised', fingerprint };
139
+ }
140
+ if (!/registerChannelAdapter/.test(registryTs)) return { ok: false, reason: 'registerChannelAdapter not found', fingerprint };
141
+ if (!/DATA_DIR/.test(configTs)) return { ok: false, reason: 'DATA_DIR export not found in config.ts', fingerprint };
142
+ if (!/import\s+['"]\.\//.test(barrel)) return { ok: false, reason: 'index.ts does not look like the channel barrel', fingerprint };
143
+ return { ok: true, reason: null, fingerprint };
144
+ }
145
+
146
+ /**
147
+ * Pre-flight compatibility. Auto-install (`ok:true`, code `verified`) requires
148
+ * BOTH a sound structure AND the EXACT verified commit. A different commit —
149
+ * even with the same version number — is `commit-mismatch`; an undeterminable
150
+ * commit is `unknown`; a moved interface is `incompatible`. None of these
151
+ * auto-install; the checkout is left untouched.
152
+ * @param {string} home
153
+ * @param {{versionOverride?:string, commitOverride?:string|null}} [opts]
154
+ */
155
+ function checkCompatibility(home, opts = {}) {
156
+ if (!home || !looksLikeNanoclaw(home)) {
157
+ return { ok: false, code: 'unknown', version: null, commit: null, structureOk: false, reason: `not a NanoClaw checkout: ${home}` };
158
+ }
159
+ const version = opts.versionOverride || getNanoclawVersion(home);
160
+ const structure = checkStructure(home);
161
+ const commit = getNanoclawCommit(home, opts);
162
+ const fingerprintMatch = structure.fingerprint === VERIFIED.interfaceFingerprint;
163
+ const base = { version, commit, structureOk: structure.ok, fingerprint: structure.fingerprint, fingerprintMatch };
164
+
165
+ if (!structure.ok) return { ok: false, code: 'incompatible', reason: structure.reason, ...base };
166
+ if (commit && AUTO_INSTALL_COMMITS.has(commit)) {
167
+ return { ok: true, code: 'verified', reason: null, ...base };
168
+ }
169
+ if (!commit) {
170
+ return {
171
+ ok: false,
172
+ code: 'unknown',
173
+ reason: `could not determine the NanoClaw commit (not a git checkout?); auto-install requires the verified commit ${VERIFIED.commit}`,
174
+ ...base,
175
+ };
176
+ }
177
+ return {
178
+ ok: false,
179
+ code: 'commit-mismatch',
180
+ reason: `NanoClaw commit ${commit} is not the verified commit ${VERIFIED.commit}${fingerprintMatch ? ' (interface fingerprint matches, but auto-install still requires the exact commit)' : ''}`,
181
+ ...base,
182
+ };
183
+ }
184
+
185
+ /** Is the channel both present (a file we own) and wired into the barrel? */
186
+ function isChannelInstalled(home) {
187
+ const dest = readFileSafe(channelDestPath(home));
188
+ if (dest == null) return false;
189
+ const barrel = readFileSafe(barrelPath(home));
190
+ return !!barrel && barrel.includes(BARREL_IMPORT);
191
+ }
192
+
193
+ /** Does a dest file exist that we did NOT write (a user's own channel)? */
194
+ function _destIsForeign(home) {
195
+ const dest = readFileSafe(channelDestPath(home));
196
+ if (dest == null) return false; // no file → not foreign
197
+ return !dest.includes(OURS_MARKER);
198
+ }
199
+
200
+ /** A NanoClaw channels barrel is a list of `import './x.js';` lines (+ comments). */
201
+ function _validateBarrel(content) {
202
+ for (const rawLine of content.split('\n')) {
203
+ const line = rawLine.trim();
204
+ if (!line || line.startsWith('//')) continue;
205
+ if (!/^import\s+['"][^'"]+['"];?$/.test(line)) {
206
+ return { ok: false, reason: `unexpected barrel line: ${line.slice(0, 60)}` };
207
+ }
208
+ }
209
+ return { ok: true };
210
+ }
211
+
212
+ /**
213
+ * Copy the channel and add the barrel import. Compatibility-gated, idempotent,
214
+ * rollback-safe.
215
+ * @param {string} home
216
+ * @param {{force?:boolean, versionOverride?:string}} [opts]
217
+ * @returns {{ok:boolean, code?:string, changed:boolean, needsRestart:boolean, error?:string, dest?:string, version?:string}}
218
+ */
219
+ function installChannel(home, opts = {}) {
220
+ const compat = checkCompatibility(home, opts);
221
+ const forced = opts.force === true; // OFF by default; admin/CLI only, never a Workspace user
222
+
223
+ // Structure is a HARD gate — never bypassable, even with force (installing
224
+ // into a checkout with a moved channel API would just break the host).
225
+ if (!compat.structureOk) {
226
+ return {
227
+ ok: false,
228
+ code: compat.code || 'incompatible',
229
+ changed: false,
230
+ needsRestart: false,
231
+ error: compat.reason,
232
+ version: compat.version,
233
+ commit: compat.commit,
234
+ };
235
+ }
236
+ // Commit gate: auto-install only into the verified commit, unless forced.
237
+ if (!compat.ok && !forced) {
238
+ return {
239
+ ok: false,
240
+ code: compat.code,
241
+ changed: false,
242
+ needsRestart: false,
243
+ error: compat.reason,
244
+ version: compat.version,
245
+ commit: compat.commit,
246
+ hint: 'unverified NanoClaw commit — pass force:true (admin/CLI only) to override',
247
+ };
248
+ }
249
+ if (!compat.ok && forced) {
250
+ // Redacted diagnostic — no secrets, no full paths, commit truncated.
251
+ const diag = {
252
+ code: compat.code,
253
+ version: compat.version,
254
+ commit: compat.commit ? compat.commit.slice(0, 12) : null,
255
+ fingerprintMatch: compat.fingerprintMatch,
256
+ };
257
+ (typeof opts.logger === 'function' ? opts.logger : (m, d) => console.warn(m, d))(
258
+ 'NanoClaw channel: FORCED install into an unverified commit',
259
+ diag,
260
+ );
261
+ }
262
+
263
+ const src = channelSourcePath();
264
+ if (!fs.existsSync(src)) {
265
+ return { ok: false, code: 'unknown', changed: false, needsRestart: false, error: `channel source missing: ${src}` };
266
+ }
267
+ if (!opts.force && _destIsForeign(home)) {
268
+ return {
269
+ ok: false,
270
+ code: 'foreign-file',
271
+ changed: false,
272
+ needsRestart: false,
273
+ error: 'refusing to overwrite an existing src/channels/openagents.ts not written by OpenAgents (use force to override)',
274
+ dest: channelDestPath(home),
275
+ };
276
+ }
277
+
278
+ const dest = channelDestPath(home);
279
+ let changed = false;
280
+
281
+ // 1) Copy the channel file atomically (temp + rename); skip if identical.
282
+ let needWrite = opts.force || !fs.existsSync(dest);
283
+ if (!needWrite) needWrite = readFileSafe(dest) !== readFileSafe(src);
284
+ if (needWrite) {
285
+ const tmp = `${dest}.tmp-${process.pid}`;
286
+ try {
287
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
288
+ fs.copyFileSync(src, tmp);
289
+ fs.renameSync(tmp, dest);
290
+ changed = true;
291
+ } catch (e) {
292
+ try {
293
+ fs.unlinkSync(tmp);
294
+ } catch {
295
+ /* best-effort */
296
+ }
297
+ return { ok: false, code: compat.code, changed, needsRestart: false, error: `copy failed: ${e.message}`, dest };
298
+ }
299
+ }
300
+
301
+ // 2) Add the barrel import block (marker-delimited; backup + restore on error).
302
+ const barrelFile = barrelPath(home);
303
+ const original = readFileSafe(barrelFile);
304
+ if (original == null) {
305
+ return { ok: false, code: compat.code, changed, needsRestart: changed, error: 'barrel disappeared', dest };
306
+ }
307
+ if (!original.includes(BARREL_IMPORT)) {
308
+ const sep = original.length && !original.endsWith('\n') ? '\n' : '';
309
+ const updated = `${original}${sep}${BARREL_BLOCK}`;
310
+ const check = _validateBarrel(updated);
311
+ if (!check.ok) {
312
+ return { ok: false, code: compat.code, changed, needsRestart: changed, error: `barrel validation failed: ${check.reason}`, dest };
313
+ }
314
+ const tmp = `${barrelFile}.tmp-${process.pid}`;
315
+ try {
316
+ fs.writeFileSync(tmp, updated);
317
+ fs.renameSync(tmp, barrelFile);
318
+ // Post-edit re-read validation: our import present + original preserved.
319
+ const after = readFileSafe(barrelFile);
320
+ if (!after || !after.includes(BARREL_IMPORT) || !after.includes(original.trim().split('\n')[0])) {
321
+ fs.writeFileSync(barrelFile, original); // restore
322
+ return { ok: false, code: compat.code, changed, needsRestart: changed, error: 'post-edit barrel validation failed; restored', dest };
323
+ }
324
+ changed = true;
325
+ } catch (e) {
326
+ try {
327
+ fs.unlinkSync(tmp);
328
+ } catch {
329
+ /* best-effort */
330
+ }
331
+ try {
332
+ fs.writeFileSync(barrelFile, original); // restore
333
+ } catch {
334
+ /* best-effort */
335
+ }
336
+ return { ok: false, code: compat.code, changed, needsRestart: changed, error: `barrel patch failed (restored): ${e.message}`, dest };
337
+ }
338
+ }
339
+
340
+ return {
341
+ ok: true,
342
+ code: forced && !compat.ok ? 'forced' : compat.code,
343
+ changed,
344
+ needsRestart: changed,
345
+ dest,
346
+ version: compat.version,
347
+ commit: compat.commit,
348
+ forced: forced && !compat.ok,
349
+ };
350
+ }
351
+
352
+ /** Remove ONLY what we injected: our channel file (if ours) + our barrel block. */
353
+ function uninstallChannel(home) {
354
+ let changed = false;
355
+ const dest = channelDestPath(home);
356
+ const destContent = readFileSafe(dest);
357
+ if (destContent != null && destContent.includes(OURS_MARKER)) {
358
+ try {
359
+ fs.unlinkSync(dest);
360
+ changed = true;
361
+ } catch {
362
+ /* best-effort */
363
+ }
364
+ }
365
+ const barrelFile = barrelPath(home);
366
+ const barrel = readFileSafe(barrelFile);
367
+ if (barrel != null && barrel.includes(BARREL_IMPORT)) {
368
+ // Remove our marker block precisely; fall back to removing just our import line.
369
+ let updated = barrel.replace(
370
+ new RegExp(`${_escapeRe(BARREL_BEGIN)}\\n${_escapeRe(BARREL_IMPORT)}\\n${_escapeRe(BARREL_END)}\\n?`, 'g'),
371
+ '',
372
+ );
373
+ if (updated === barrel) {
374
+ updated = barrel
375
+ .split('\n')
376
+ .filter((l) => l.trim() !== BARREL_IMPORT && l.trim() !== BARREL_BEGIN && l.trim() !== BARREL_END)
377
+ .join('\n');
378
+ }
379
+ if (updated !== barrel) {
380
+ try {
381
+ fs.writeFileSync(barrelFile, updated);
382
+ changed = true;
383
+ } catch {
384
+ /* best-effort */
385
+ }
386
+ }
387
+ }
388
+ return { ok: true, changed };
389
+ }
390
+
391
+ function _escapeRe(s) {
392
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
393
+ }
394
+
395
+ module.exports = {
396
+ VERIFIED,
397
+ AUTO_INSTALL_COMMITS,
398
+ OURS_MARKER,
399
+ channelSourcePath,
400
+ channelDestPath,
401
+ barrelPath,
402
+ getNanoclawVersion,
403
+ getNanoclawCommit,
404
+ computeInterfaceFingerprint,
405
+ checkStructure,
406
+ checkCompatibility,
407
+ isChannelInstalled,
408
+ installChannel,
409
+ uninstallChannel,
410
+ BARREL_IMPORT,
411
+ };