@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,349 @@
1
+ /**
2
+ * NanoClaw bridge protocol — pure, dependency-free helpers.
3
+ *
4
+ * NanoClaw is a CONTAINERIZED agent runtime, not an stdin/stdout CLI and not a
5
+ * direct LLM API. The OpenAgents bridge talks to a thin native NanoClaw
6
+ * `openagents` *channel* (the official extension point) over a local Unix
7
+ * socket (`<nanoclaw>/data/openagents.sock`). This module holds the wire
8
+ * format, id/addressing rules, secret redaction, and error classification —
9
+ * everything that can be unit-tested without any IO.
10
+ *
11
+ * Wire format: one JSON object per line ("\n"-delimited), both directions.
12
+ *
13
+ * OpenAgents adapter (client) → channel (server):
14
+ * { op: "hello", workspace, agent, protocol, secret } // secret REQUIRED
15
+ * { op: "inbound", platformId, threadId, msgId, text, sender, senderId, ts }
16
+ * { op: "ack", outId, platformId } // confirms an outbound was delivered
17
+ * { op: "cancel", platformId, msgId }
18
+ * { op: "ping" }
19
+ *
20
+ * channel (server) → OpenAgents adapter (client):
21
+ * { op: "ready", channelType, protocol } // ONLY after a valid hello
22
+ * { op: "outbound", platformId, threadId, outId, inReplyTo, kind, text, ts }
23
+ * { op: "status", platformId, state: "working"|"idle", ts }
24
+ * { op: "error", platformId?, code?, message } // sanitized by the channel
25
+ * { op: "pong" }
26
+ *
27
+ * Local trust boundary: `data/openagents/bridge.sock` (0600) lives in a 0700
28
+ * dir; the client must present a random per-host `secret` (read from the 0600
29
+ * `data/openagents/secret` file written by the channel) in `hello`. No frame
30
+ * other than `hello`/`ping` is honored before the handshake succeeds, and the
31
+ * server re-authenticates on every (re)connect. The secret never enters the
32
+ * Workspace, the container, or logs.
33
+ *
34
+ * Delivery is AT-LEAST-ONCE with dedup: the channel holds each outbound until
35
+ * the bridge ACKs it; the bridge ACKs only after the message is handed to the
36
+ * Workspace, and persists processed `outId`s so a replay is re-ACKed but not
37
+ * re-displayed. This is NOT unconditional exactly-once (see docs).
38
+ *
39
+ * The workspace TOKEN is deliberately NOT part of the protocol — the adapter
40
+ * does all workspace IO, so no OpenAgents credential is ever duplicated into
41
+ * the NanoClaw process. See [[nanoclaw-facts-and-arch]].
42
+ */
43
+
44
+ 'use strict';
45
+
46
+ const crypto = require('crypto');
47
+
48
+ const PROTOCOL_VERSION = 1;
49
+
50
+ // Channel identity on the NanoClaw side. Each OpenAgents channel maps to a
51
+ // distinct messaging group (channel_type='openagents', platform_id=<below>),
52
+ // which — with session_mode 'shared' — yields one isolated NanoClaw session
53
+ // per OpenAgents channel.
54
+ const CHANNEL_TYPE = 'openagents';
55
+
56
+ /**
57
+ * Stable, collision-resistant platform id for an OpenAgents channel.
58
+ * Distinct OpenAgents channels → distinct platform ids → distinct NanoClaw
59
+ * sessions (isolation). Distinct workspaces never collide on one NanoClaw host.
60
+ * @param {string} workspaceId
61
+ * @param {string} channel OpenAgents channel/session name
62
+ * @returns {string}
63
+ */
64
+ function platformIdFor(workspaceId, channel) {
65
+ const ws = String(workspaceId || 'ws').trim();
66
+ const ch = String(channel || 'general').trim() || 'general';
67
+ return `oa:${ws}:${ch}`;
68
+ }
69
+
70
+ /**
71
+ * Recover the OpenAgents channel from a platform id produced by platformIdFor.
72
+ * Returns null if the platform id isn't ours (defensive — ignore foreign
73
+ * platforms so we never cross-deliver another channel's traffic).
74
+ * @param {string} platformId
75
+ * @param {string} workspaceId
76
+ * @returns {string|null}
77
+ */
78
+ function channelFromPlatformId(platformId, workspaceId) {
79
+ const prefix = `oa:${String(workspaceId || 'ws').trim()}:`;
80
+ if (typeof platformId !== 'string' || !platformId.startsWith(prefix)) return null;
81
+ const ch = platformId.slice(prefix.length);
82
+ return ch || null;
83
+ }
84
+
85
+ /**
86
+ * Deterministic, unique message id for an inbound workspace message. Stable for
87
+ * the same source message (so a reconnect/redelivery does NOT create a second
88
+ * NanoClaw message — idempotency), and unique across messages (dedup). Prefer
89
+ * the workspace-issued id; fall back to a content+timestamp hash.
90
+ * @param {object} msg workspace message ({id, content, sessionId, ...})
91
+ * @param {string} workspaceId
92
+ * @returns {string}
93
+ */
94
+ function makeMessageId(msg, workspaceId) {
95
+ const ws = String(workspaceId || 'ws').trim();
96
+ const raw = msg && (msg.id || msg.messageId || msg.eventId);
97
+ if (raw) return `oa:${ws}:${raw}`;
98
+ const basis = JSON.stringify({
99
+ c: (msg && msg.content) || '',
100
+ s: (msg && (msg.sessionId || msg.channel)) || '',
101
+ t: (msg && (msg.timestamp || msg.ts)) || '',
102
+ });
103
+ const h = crypto.createHash('sha1').update(basis).digest('hex').slice(0, 20);
104
+ return `oa:${ws}:h:${h}`;
105
+ }
106
+
107
+ /**
108
+ * Should this inbound workspace message be forwarded to NanoClaw at all?
109
+ * Loop/echo guard: never forward the agent's OWN output back into NanoClaw
110
+ * (which would create an infinite agent→channel→agent loop), and never forward
111
+ * non-user control/status chatter.
112
+ * @param {object} msg
113
+ * @param {string} agentName this adapter's agent name
114
+ * @returns {boolean}
115
+ */
116
+ function shouldForwardInbound(msg, agentName) {
117
+ if (!msg) return false;
118
+ const type = msg.messageType || msg.type;
119
+ if (type && type !== 'chat' && type !== 'message' && type !== 'text') return false;
120
+ const senderType = msg.senderType || msg.sender_type;
121
+ if (senderType === 'agent') return false; // our own / another agent's output
122
+ const sender = msg.senderName || msg.sender_name || msg.sender;
123
+ if (sender && agentName && String(sender) === String(agentName)) return false;
124
+ const content = (msg.content || msg.text || '').trim();
125
+ return content.length > 0;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Frame build / parse
130
+ // ---------------------------------------------------------------------------
131
+
132
+ function encodeFrame(obj) {
133
+ return JSON.stringify(obj) + '\n';
134
+ }
135
+
136
+ function buildHello(workspace, agent, secret) {
137
+ return encodeFrame({ op: 'hello', workspace, agent, protocol: PROTOCOL_VERSION, secret: secret || '' });
138
+ }
139
+
140
+ function buildAck(outId, platformId) {
141
+ return encodeFrame({ op: 'ack', outId, platformId: platformId || null });
142
+ }
143
+
144
+ function buildInbound({ platformId, threadId = null, msgId, turnId, text, sender = 'user', senderId, ts }) {
145
+ return encodeFrame({
146
+ op: 'inbound',
147
+ platformId,
148
+ threadId: threadId == null ? null : threadId,
149
+ msgId,
150
+ // turnId identifies the OpenAgents delivery turn; the channel echoes it on
151
+ // outbound (best-effort: the last inbound turn on that platform, since
152
+ // NanoClaw does not expose per-message reply correlation to channels).
153
+ turnId: turnId || msgId,
154
+ text,
155
+ sender,
156
+ senderId: senderId || `oa:${sender}`,
157
+ ts: ts || new Date().toISOString(),
158
+ });
159
+ }
160
+
161
+ function buildCancel(platformId, msgId) {
162
+ return encodeFrame({ op: 'cancel', platformId, msgId: msgId || null });
163
+ }
164
+
165
+ function buildPing() {
166
+ return encodeFrame({ op: 'ping' });
167
+ }
168
+
169
+ /**
170
+ * Incremental line-delimited JSON parser. Feed it raw chunks; it returns the
171
+ * parsed frames found so far plus the unconsumed remainder to carry forward.
172
+ * Malformed lines are skipped (forward-compatibility), never thrown.
173
+ * @param {string} buffer accumulated text (prev remainder + new chunk)
174
+ * @returns {{frames: object[], rest: string}}
175
+ */
176
+ function parseFrames(buffer) {
177
+ const frames = [];
178
+ let rest = buffer;
179
+ let idx;
180
+ while ((idx = rest.indexOf('\n')) >= 0) {
181
+ const line = rest.slice(0, idx).trim();
182
+ rest = rest.slice(idx + 1);
183
+ if (!line) continue;
184
+ try {
185
+ const obj = JSON.parse(line);
186
+ if (obj && typeof obj === 'object' && typeof obj.op === 'string') frames.push(obj);
187
+ } catch {
188
+ // ignore non-JSON / partial garbage
189
+ }
190
+ }
191
+ return { frames, rest };
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Secret redaction — never let tokens / keys / cookies / message bodies leak
196
+ // into logs.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ const _SECRET_PATTERNS = [
200
+ [/(Bearer\s+)[A-Za-z0-9._\-]+/gi, '$1[REDACTED]'],
201
+ [/(Authorization"?\s*[:=]\s*"?)[^"\s,&]+/gi, '$1[REDACTED]'],
202
+ [/(Cookie"?\s*[:=]\s*"?)[^"\n]+/gi, '$1[REDACTED]'],
203
+ [/([?&](?:token|api[_-]?key|apikey|key|secret|password|auth[_-]?token)=)[^&\s"]+/gi, '$1[REDACTED]'],
204
+ [/((?:api[_-]?key|apikey|secret|password|token|auth[_-]?token)"?\s*[:=]\s*"?)[^"\s,}]+/gi, '$1[REDACTED]'],
205
+ [/\bsk-[A-Za-z0-9_\-]{12,}\b/g, 'sk-[REDACTED]'],
206
+ [/\bsk-ant-[A-Za-z0-9_\-]{12,}\b/g, 'sk-ant-[REDACTED]'],
207
+ [/\bgh[oprsu]_[A-Za-z0-9]{20,}\b/g, 'gh_[REDACTED]'],
208
+ ];
209
+
210
+ /**
211
+ * Redact secrets from an arbitrary string. Optionally scrub a list of exact
212
+ * secret values (e.g. the live workspace token) before pattern-based passes.
213
+ * @param {string} str
214
+ * @param {string[]} [extraSecrets]
215
+ * @returns {string}
216
+ */
217
+ function redactSecrets(str, extraSecrets = []) {
218
+ if (str == null) return '';
219
+ let s = String(str);
220
+ for (const secret of extraSecrets) {
221
+ if (secret && String(secret).length >= 6) {
222
+ s = s.split(String(secret)).join('[REDACTED]');
223
+ }
224
+ }
225
+ for (const [re, repl] of _SECRET_PATTERNS) s = s.replace(re, repl);
226
+ return s;
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Error classification — distinct, user-facing categories. Detailed cause is
231
+ // returned separately (already redactable) for logs; userMessage is safe to
232
+ // show a non-technical user.
233
+ // ---------------------------------------------------------------------------
234
+
235
+ const ERR = {
236
+ NOT_INSTALLED: 'not_installed',
237
+ DOCKER_UNAVAILABLE: 'docker_unavailable',
238
+ HOST_NOT_RUNNING: 'host_not_running',
239
+ AGENT_GROUP_MISSING: 'agent_group_missing',
240
+ CHANNEL_NOT_LOADED: 'channel_not_loaded',
241
+ WIRING_MISSING: 'wiring_missing',
242
+ CREDS_MISSING: 'creds_missing',
243
+ AUTH_FAILED: 'auth_failed',
244
+ INCOMPATIBLE_VERSION: 'incompatible_version',
245
+ SINGLE_CONNECTION: 'single_connection',
246
+ DELIVERY_OVERFLOW: 'delivery_overflow',
247
+ DELIVERY_EXPIRED: 'delivery_expired',
248
+ DELIVERY_CORRUPT: 'delivery_corrupt',
249
+ CONTAINER_START_FAILED: 'container_start_failed',
250
+ SEND_FAILED: 'send_failed',
251
+ TIMEOUT: 'timeout',
252
+ DISCONNECTED: 'disconnected',
253
+ RECONNECT_FAILED: 'reconnect_failed',
254
+ DUPLICATE: 'duplicate',
255
+ RUNTIME_CRASHED: 'runtime_crashed',
256
+ UNKNOWN: 'unknown',
257
+ };
258
+
259
+ const _ERR_MESSAGES = {
260
+ [ERR.NOT_INSTALLED]:
261
+ 'NanoClaw is not installed or could not be found. Set NANOCLAW_HOME to your NanoClaw checkout (or put `ncl` on your PATH).',
262
+ [ERR.DOCKER_UNAVAILABLE]:
263
+ 'Docker is not available. NanoClaw runs each agent group in a container — start Docker Desktop / the Docker daemon (on Windows use WSL2).',
264
+ [ERR.HOST_NOT_RUNNING]:
265
+ 'The NanoClaw host service is not running. Start it (e.g. `./nanoclaw.sh start`, launchd, or systemd) and try again.',
266
+ [ERR.AGENT_GROUP_MISSING]:
267
+ 'The configured NanoClaw Agent Group was not found. Pick an existing group (NANOCLAW_AGENT_GROUP) or create one in NanoClaw.',
268
+ [ERR.CHANNEL_NOT_LOADED]:
269
+ 'The NanoClaw `openagents` channel is not loaded. Install it (/add-openagents) and restart the NanoClaw host.',
270
+ [ERR.WIRING_MISSING]:
271
+ 'This OpenAgents channel is not wired to the Agent Group yet. Approve the wiring in NanoClaw (creating wirings requires approval).',
272
+ [ERR.CREDS_MISSING]:
273
+ 'NanoClaw is missing its provider credentials. Configure the agent group provider (e.g. Anthropic) in NanoClaw.',
274
+ [ERR.AUTH_FAILED]:
275
+ 'Could not authenticate to the NanoClaw openagents channel (local secret missing or mismatched). Restart the NanoClaw host so a fresh secret is issued.',
276
+ [ERR.INCOMPATIBLE_VERSION]:
277
+ 'This NanoClaw version is not compatible with the OpenAgents channel. Use a verified NanoClaw version, or upgrade the OpenAgents integration.',
278
+ [ERR.SINGLE_CONNECTION]:
279
+ 'Another OpenAgents connector is already attached to this NanoClaw host. Only one connector per host is allowed; disconnect the other first.',
280
+ [ERR.DELIVERY_OVERFLOW]:
281
+ 'A queued NanoClaw reply was dropped because the local outbox is full — it may not be recoverable.',
282
+ [ERR.DELIVERY_EXPIRED]:
283
+ 'A queued NanoClaw reply expired before it could be delivered — it may not be recoverable.',
284
+ [ERR.DELIVERY_CORRUPT]:
285
+ 'A corrupt NanoClaw outbox record was found and quarantined — a queued reply may not be recoverable.',
286
+ [ERR.CONTAINER_START_FAILED]:
287
+ 'NanoClaw could not start the agent container. Check Docker and the NanoClaw host logs.',
288
+ [ERR.SEND_FAILED]: 'Could not deliver the message to NanoClaw. Retrying / check the host.',
289
+ [ERR.TIMEOUT]: 'NanoClaw did not reply in time. The agent may still be working — try again shortly.',
290
+ [ERR.DISCONNECTED]: 'Lost the connection to NanoClaw. Reconnecting…',
291
+ [ERR.RECONNECT_FAILED]: 'Could not reconnect to NanoClaw after several attempts.',
292
+ [ERR.DUPLICATE]: 'Duplicate message ignored.',
293
+ [ERR.RUNTIME_CRASHED]: 'The NanoClaw runtime exited unexpectedly.',
294
+ [ERR.UNKNOWN]: 'An unexpected error occurred talking to NanoClaw.',
295
+ };
296
+
297
+ /**
298
+ * Classify a raw error/condition into a stable code + a safe user message.
299
+ * @param {Error|string|{code?:string}} err
300
+ * @param {{hint?:string}} [ctx]
301
+ * @returns {{code:string, userMessage:string, detail:string}}
302
+ */
303
+ function classifyError(err, ctx = {}) {
304
+ const rawMsg = err == null ? '' : (err.message || String(err));
305
+ const lower = rawMsg.toLowerCase();
306
+ let code = (err && err.code && _ERR_MESSAGES[err.code]) ? err.code : null;
307
+
308
+ if (!code) {
309
+ if (/enoent|not found|no such file|cannot find|nanoclaw_home/.test(lower) && /ncl|nanoclaw|sock|home/.test(lower)) {
310
+ code = ERR.NOT_INSTALLED;
311
+ } else if (/docker/.test(lower) && /(not|cannot|unavailable|refused|daemon)/.test(lower)) {
312
+ code = ERR.DOCKER_UNAVAILABLE;
313
+ } else if (/econnrefused|host closed|ncl\.sock|control socket|not running/.test(lower)) {
314
+ code = ERR.HOST_NOT_RUNNING;
315
+ } else if (/agent group|agent_group/.test(lower) && /(missing|not found|unknown)/.test(lower)) {
316
+ code = ERR.AGENT_GROUP_MISSING;
317
+ } else if (/epipe|econnreset|socket hang up|disconnect|closed/.test(lower)) {
318
+ code = ERR.DISCONNECTED;
319
+ } else if (/timed out|timeout|etimedout/.test(lower)) {
320
+ code = ERR.TIMEOUT;
321
+ } else {
322
+ code = ERR.UNKNOWN;
323
+ }
324
+ }
325
+ return {
326
+ code,
327
+ userMessage: _ERR_MESSAGES[code] || _ERR_MESSAGES[ERR.UNKNOWN],
328
+ detail: redactSecrets(rawMsg, ctx.secrets || []),
329
+ };
330
+ }
331
+
332
+ module.exports = {
333
+ PROTOCOL_VERSION,
334
+ CHANNEL_TYPE,
335
+ ERR,
336
+ platformIdFor,
337
+ channelFromPlatformId,
338
+ makeMessageId,
339
+ shouldForwardInbound,
340
+ encodeFrame,
341
+ buildHello,
342
+ buildAck,
343
+ buildInbound,
344
+ buildCancel,
345
+ buildPing,
346
+ parseFrames,
347
+ redactSecrets,
348
+ classifyError,
349
+ };