@openagents-org/agent-launcher 0.2.143 → 0.2.146

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,166 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Single source of truth for agent readiness / runtime-failure classification.
5
+ *
6
+ * The Install page, the Agents list, the daemon and the TUI must all speak the
7
+ * same vocabulary about WHY an agent is or isn't usable. Mixing "not installed"
8
+ * (a genuinely missing executable) with "installed but signed out" or "spawn
9
+ * failed" is exactly the bug this module exists to prevent.
10
+ *
11
+ * Hard rule: REASON.NOT_INSTALLED is ONLY for a binary that cannot be resolved
12
+ * at all. An installed-but-signed-out agent is REASON.LOGIN_REQUIRED. A binary
13
+ * that resolved at install time but fails to start at run time is
14
+ * REASON.RUNTIME_MISSING / REASON.SPAWN_FAILED — never NOT_INSTALLED.
15
+ *
16
+ * Everything here is pure and dependency-free so it can be required from the
17
+ * daemon, the adapters AND the Electron launcher (via
18
+ * `@openagents-org/agent-launcher/src/adapters/health-status`).
19
+ */
20
+
21
+ const REASON = {
22
+ READY: 'ready',
23
+ NOT_INSTALLED: 'not_installed', // executable cannot be resolved anywhere
24
+ LOGIN_REQUIRED: 'login_required', // installed, but no usable login / API key
25
+ VERSION_INCOMPATIBLE: 'version_incompatible',
26
+ RUNTIME_MISSING: 'runtime_missing', // resolved at install time, gone at run time
27
+ SPAWN_FAILED: 'spawn_failed', // child_process spawn errored (ENOENT/EACCES/…)
28
+ WORKSPACE_JOIN_FAILED: 'workspace_join_failed',
29
+ HEARTBEAT_FAILED: 'heartbeat_failed',
30
+ SESSION_REVOKED: 'session_revoked',
31
+ ADAPTER_CRASHED: 'adapter_crashed',
32
+ };
33
+
34
+ // Reasons that represent a genuine failure the UI should surface as an error
35
+ // (red dot / X) rather than a benign "needs configuration" state.
36
+ const ERROR_REASONS = new Set([
37
+ REASON.RUNTIME_MISSING,
38
+ REASON.SPAWN_FAILED,
39
+ REASON.WORKSPACE_JOIN_FAILED,
40
+ REASON.HEARTBEAT_FAILED,
41
+ REASON.SESSION_REVOKED,
42
+ REASON.ADAPTER_CRASHED,
43
+ ]);
44
+
45
+ /** True when a reason should render as a hard error (daemon state 'error'). */
46
+ function isErrorReason(reason) {
47
+ return ERROR_REASONS.has(reason);
48
+ }
49
+
50
+ /**
51
+ * Readiness reason from an (installed, ready) pair. The ONLY place that decides
52
+ * "not_installed vs login_required", so the Install page and the Agents list
53
+ * can never disagree.
54
+ */
55
+ function readinessReason(installed, ready) {
56
+ if (!installed) return REASON.NOT_INSTALLED;
57
+ return ready ? REASON.READY : REASON.LOGIN_REQUIRED;
58
+ }
59
+
60
+ /**
61
+ * Whether a resolved binary path must be launched through a shell. On Windows,
62
+ * `.cmd`/`.bat` shims (npm/pnpm/yarn global bins, and amp.cmd) are NOT directly
63
+ * executable via CreateProcess — Node's spawn needs shell:true or it throws
64
+ * EINVAL/ENOENT. Mirrors the amp adapter's own _spawnAmp handling so the
65
+ * launcher probe, the Test-connection path and the daemon all behave the same.
66
+ */
67
+ function shouldUseShellForBinary(bin, platform) {
68
+ const plat = platform || process.platform;
69
+ return plat === 'win32' && /\.(cmd|bat)$/i.test(String(bin || ''));
70
+ }
71
+
72
+ /**
73
+ * Strip secrets from a diagnostic string before it reaches a log, the daemon
74
+ * status file or the UI. Removes tokens / api keys / bearer headers / cookies /
75
+ * URL credentials / long opaque tokens, and caps the length. Never throws.
76
+ */
77
+ function redactDiagnostic(input, maxLen = 300) {
78
+ let s = String(input == null ? '' : input);
79
+ try {
80
+ s = s
81
+ // key=value / key: value for sensitive keys (token, api_key, secret, …)
82
+ .replace(
83
+ /((?:access[_-]?|api[_-]?|auth[_-]?)?(?:tokens?|keys?|secrets?|passwords?|cookies?)\s*[=:]\s*)(\S+)/gi,
84
+ '$1<redacted>',
85
+ )
86
+ // Authorization: Bearer xxxxx
87
+ .replace(/(bearer\s+)[A-Za-z0-9._\-]+/gi, '$1<redacted>')
88
+ // URL userinfo scheme://user:pass@host
89
+ .replace(/(\b[a-z][a-z0-9+.\-]*:\/\/)[^/@\s:]+:[^/@\s]+@/gi, '$1<redacted>@')
90
+ // Known opaque token prefixes
91
+ .replace(/\b(?:sgp_|sk-|ghp_|gho_|xox[baprs]-)[A-Za-z0-9._\-]+/g, '<redacted>')
92
+ // Generic long opaque blobs (>= 40 chars)
93
+ .replace(/\b[A-Za-z0-9_\-]{40,}\b/g, '<redacted>');
94
+ } catch {
95
+ /* keep best-effort */
96
+ }
97
+ s = s.replace(/\s+/g, ' ').trim();
98
+ if (s.length > maxLen) s = s.slice(0, maxLen) + '…';
99
+ return s;
100
+ }
101
+
102
+ /** Pull a numeric HTTP status off a workspace-client error, if present. */
103
+ function httpStatusOf(err) {
104
+ if (!err) return null;
105
+ const sc = err.statusCode != null ? err.statusCode : err.status;
106
+ return typeof sc === 'number' ? sc : null;
107
+ }
108
+
109
+ /** Classify a workspace JOIN failure → { reason, message } (redacted). */
110
+ function classifyJoinError(err) {
111
+ const sc = httpStatusOf(err);
112
+ let detail;
113
+ if (sc === 401 || sc === 403) detail = `authentication rejected (HTTP ${sc})`;
114
+ else if (sc === 404) detail = 'workspace not found (HTTP 404)';
115
+ else if (typeof sc === 'number') detail = `HTTP ${sc}`;
116
+ else detail = redactDiagnostic(err && err.message) || 'network error';
117
+ return {
118
+ reason: REASON.WORKSPACE_JOIN_FAILED,
119
+ message: `Workspace join failed: ${detail}`,
120
+ };
121
+ }
122
+
123
+ /** Classify a workspace HEARTBEAT failure → { reason, message } (redacted). */
124
+ function classifyHeartbeatError(err) {
125
+ const sc = httpStatusOf(err);
126
+ const detail =
127
+ typeof sc === 'number'
128
+ ? `HTTP ${sc}`
129
+ : redactDiagnostic(err && err.message) || 'network error';
130
+ return {
131
+ reason: REASON.HEARTBEAT_FAILED,
132
+ message: `Workspace heartbeat failed: ${detail}`,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Classify a child_process spawn failure (the CLI could not be started) →
138
+ * { reason, message } (redacted). `label` names the agent ("Amp"), `bin` is the
139
+ * resolved path included for diagnostics (it is a filesystem path, not a secret).
140
+ */
141
+ function classifySpawnError(err, opts) {
142
+ const { label = 'Agent', bin = null } = opts || {};
143
+ const code = (err && (err.code || err.errno)) || '';
144
+ const where = bin ? ` [${bin}]` : '';
145
+ let detail;
146
+ if (code === 'ENOENT') detail = `executable not found${where}`;
147
+ else if (code === 'EACCES' || code === 'EPERM')
148
+ detail = `permission denied${where}`;
149
+ else detail = (redactDiagnostic((err && err.message) || code) || 'spawn error') + where;
150
+ return {
151
+ reason: REASON.SPAWN_FAILED,
152
+ message: `${label} process failed to start: ${detail}`,
153
+ };
154
+ }
155
+
156
+ module.exports = {
157
+ REASON,
158
+ isErrorReason,
159
+ readinessReason,
160
+ shouldUseShellForBinary,
161
+ redactDiagnostic,
162
+ httpStatusOf,
163
+ classifyJoinError,
164
+ classifyHeartbeatError,
165
+ classifySpawnError,
166
+ };
@@ -21,7 +21,7 @@ const { execSync, spawn } = require('child_process');
21
21
 
22
22
  const BaseAdapter = require('./base');
23
23
  const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
24
- const { whichBinary, getEnhancedEnv } = require('../paths');
24
+ const { whichBinary, whereBinary } = require('../paths');
25
25
 
26
26
  const IS_WINDOWS = process.platform === 'win32';
27
27
  const HERMES_INSTALL_HINT = IS_WINDOWS
@@ -76,22 +76,13 @@ class HermesAdapter extends BaseAdapter {
76
76
  // Tier 1: PATH (enriched env so we see the dirs the launcher adds; a fresh
77
77
  // install updates the user PATH, which the running daemon won't pick up).
78
78
  // windowsHide stops a console window from flashing.
79
- try {
80
- const env = getEnhancedEnv();
81
- if (IS_WINDOWS) {
82
- // Native Windows is supported (install.ps1). The installer adds
83
- // venv\Scripts to the user PATH; a running daemon won't see that update,
84
- // hence the enriched env + the explicit candidates in Tier 2.
85
- const r = execSync('where hermes.exe 2>nul || where hermes.cmd 2>nul || where hermes 2>nul', {
86
- encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
87
- });
88
- const found = r.split(/\r?\n/)[0].trim();
89
- if (found) return found;
90
- } else {
91
- const found = execSync('which hermes', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
92
- if (found) return found;
93
- }
94
- } catch {}
79
+ // Codepage-safe lookup (whereBinary forces UTF-8 output + verifies existence
80
+ // so a non-ASCII/Chinese username isn't mangled into an ENOENT). Native
81
+ // Windows is supported (install.ps1); the installer adds venv\Scripts to the
82
+ // user PATH, which a running daemon won't see — hence the enriched env
83
+ // (whereBinary's default) + the explicit candidates in Tier 2.
84
+ const viaWhere = whereBinary('hermes');
85
+ if (viaWhere) return viaWhere;
95
86
 
96
87
  // Tier 2: Common install locations. The native Windows installer
97
88
  // (install.ps1) provisions a portable venv and drops hermes.exe under
@@ -14,6 +14,11 @@ const CursorAdapter = require('./cursor');
14
14
  const HermesAdapter = require('./hermes');
15
15
  const GeminiAdapter = require('./gemini');
16
16
  const KimiAdapter = require('./kimi');
17
+ const AiderAdapter = require('./aider');
18
+ const GooseAdapter = require('./goose');
19
+ const CopilotAdapter = require('./copilot');
20
+ const ClineAdapter = require('./cline');
21
+ const AmpAdapter = require('./amp');
17
22
 
18
23
  const ADAPTER_MAP = {
19
24
  openclaw: OpenClawAdapter,
@@ -25,11 +30,16 @@ const ADAPTER_MAP = {
25
30
  hermes: HermesAdapter,
26
31
  gemini: GeminiAdapter,
27
32
  kimi: KimiAdapter,
33
+ aider: AiderAdapter,
34
+ goose: GooseAdapter,
35
+ copilot: CopilotAdapter,
36
+ cline: ClineAdapter,
37
+ amp: AmpAdapter,
28
38
  };
29
39
 
30
40
  /**
31
41
  * Create an adapter instance for the given agent type.
32
- * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi)
42
+ * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp)
33
43
  * @param {object} opts - Adapter constructor options
34
44
  * @returns {BaseAdapter}
35
45
  */
@@ -52,6 +62,11 @@ module.exports = {
52
62
  HermesAdapter,
53
63
  GeminiAdapter,
54
64
  KimiAdapter,
65
+ AiderAdapter,
66
+ GooseAdapter,
67
+ CopilotAdapter,
68
+ ClineAdapter,
69
+ AmpAdapter,
55
70
  createAdapter,
56
71
  ADAPTER_MAP,
57
72
  };
@@ -0,0 +1,264 @@
1
+ /**
2
+ * NanoclawBridge — client side of the local IPC to the native NanoClaw
3
+ * `openagents` channel.
4
+ *
5
+ * The channel (running inside the NanoClaw host) owns a Unix-socket SERVER at
6
+ * `<home>/data/openagents.sock`; this bridge is the CLIENT. We send `inbound`
7
+ * frames (a workspace message addressed to an OpenAgents channel) and receive
8
+ * `outbound` / `status` / `error` frames (the agent container's replies and
9
+ * liveness, correlated by `platformId`). The channel does the native
10
+ * router→session→container routing; we never touch NanoClaw's database.
11
+ *
12
+ * Connection is resilient: a dropped socket triggers exponential-backoff
13
+ * reconnect (the NanoClaw host may restart, or the channel may load late).
14
+ * The workspace token is never sent over this socket.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const net = require('net');
20
+ const { EventEmitter } = require('events');
21
+
22
+ const proto = require('./nanoclaw-protocol');
23
+ const { isPathSafe } = require('./nanoclaw-control');
24
+
25
+ const noop = () => {};
26
+
27
+ // Upper bound on the inbound line buffer. The bridge speaks newline-delimited
28
+ // JSON control frames (small); a peer that streams data without a newline would
29
+ // otherwise grow the buffer unbounded. The trust boundary is the same OS user,
30
+ // so this guards against a malfunctioning/compromised local peer, not a remote
31
+ // attacker. 8 MiB is far above any legitimate frame.
32
+ const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
33
+
34
+ class NanoclawBridge extends EventEmitter {
35
+ /**
36
+ * @param {string} socketPath
37
+ * @param {object} opts
38
+ * @param {string} opts.workspace
39
+ * @param {string} opts.agent
40
+ * @param {()=>(string|null)} [opts.secretProvider] read the current handshake secret (fresh per connect)
41
+ * @param {(m:string)=>void} [opts.log]
42
+ * @param {number} [opts.minBackoffMs]
43
+ * @param {number} [opts.maxBackoffMs]
44
+ */
45
+ constructor(socketPath, opts = {}) {
46
+ super();
47
+ this.socketPath = socketPath;
48
+ this.workspace = opts.workspace || '';
49
+ this.agent = opts.agent || '';
50
+ this._secretProvider = typeof opts.secretProvider === 'function' ? opts.secretProvider : () => null;
51
+ this._log = opts.log || noop;
52
+ this._minBackoff = opts.minBackoffMs || 500;
53
+ this._maxBackoff = opts.maxBackoffMs || 8000;
54
+
55
+ this._sock = null;
56
+ this._buffer = '';
57
+ this._connected = false;
58
+ this._ready = false;
59
+ this._closed = false;
60
+ this._attempt = 0;
61
+ this._reconnectTimer = null;
62
+ }
63
+
64
+ isConnected() {
65
+ return this._connected && !this._closed;
66
+ }
67
+ isReady() {
68
+ return this._ready && !this._closed;
69
+ }
70
+
71
+ /** Begin connecting (idempotent). Resolves on first successful TCP connect. */
72
+ connect() {
73
+ this._closed = false;
74
+ if (this._sock) return;
75
+ this._openOnce();
76
+ }
77
+
78
+ _openOnce() {
79
+ if (this._closed) return;
80
+ this._buffer = '';
81
+
82
+ // Refuse a symlinked socket path (path-escape / redirection guard).
83
+ const safe = isPathSafe(this.socketPath);
84
+ if (!safe.ok) {
85
+ this._log(`bridge refusing unsafe socket path: ${safe.reason}`);
86
+ this.emit('socket-error', Object.assign(new Error(safe.reason), { code: 'UNSAFE_PATH' }));
87
+ this._scheduleReconnect();
88
+ return;
89
+ }
90
+
91
+ // Read the handshake secret fresh every connect (handles host restart /
92
+ // secret rotation, and re-auth on reconnect).
93
+ const secret = this._secretProvider();
94
+ if (!secret) {
95
+ this._log('bridge: handshake secret not available yet — will retry');
96
+ this.emit('auth-pending');
97
+ this._scheduleReconnect();
98
+ return;
99
+ }
100
+
101
+ const sock = net.createConnection(this.socketPath);
102
+ this._sock = sock;
103
+
104
+ sock.on('connect', () => {
105
+ this._connected = true;
106
+ this._attempt = 0;
107
+ this._log(`bridge connected: ${this.socketPath}`);
108
+ // Handshake — identify ourselves + present the local secret. The channel
109
+ // replies 'ready' only on a valid secret + matching protocol.
110
+ try {
111
+ sock.write(proto.buildHello(this.workspace, this.agent, secret));
112
+ } catch (e) {
113
+ this._log(`bridge hello write failed: ${e.message}`);
114
+ }
115
+ this.emit('connect');
116
+ });
117
+
118
+ sock.on('data', (chunk) => {
119
+ this._buffer += chunk.toString('utf8');
120
+ const { frames, rest } = proto.parseFrames(this._buffer);
121
+ this._buffer = rest;
122
+ // Guard against an unbounded line with no frame delimiter — drop the
123
+ // buffer and reset the connection rather than growing memory without limit.
124
+ if (this._buffer.length > MAX_BUFFER_BYTES) {
125
+ this._log(`bridge buffer exceeded ${MAX_BUFFER_BYTES} bytes without a complete frame — resetting connection`);
126
+ this._buffer = '';
127
+ try { sock.destroy(); } catch { /* best-effort */ }
128
+ return;
129
+ }
130
+ for (const frame of frames) this._onFrame(frame);
131
+ });
132
+
133
+ // Always attach an error listener: a SIGKILL'd/closed peer can emit
134
+ // 'error' (EPIPE/ECONNRESET) which, unhandled, crashes the process.
135
+ sock.on('error', (err) => {
136
+ this._log(`bridge socket error: ${err.code || err.message}`);
137
+ this.emit('socket-error', err);
138
+ });
139
+
140
+ sock.on('close', () => {
141
+ const wasReady = this._ready;
142
+ this._connected = false;
143
+ this._ready = false;
144
+ this._sock = null;
145
+ if (wasReady || this._attempt === 0) this.emit('disconnect');
146
+ this._scheduleReconnect();
147
+ });
148
+ }
149
+
150
+ _onFrame(frame) {
151
+ switch (frame.op) {
152
+ case 'ready':
153
+ this._ready = true;
154
+ this._log(`bridge ready (channel=${frame.channelType || 'openagents'}, proto=${frame.protocol})`);
155
+ this.emit('ready', frame);
156
+ break;
157
+ case 'outbound':
158
+ this.emit('outbound', frame);
159
+ break;
160
+ case 'status':
161
+ this.emit('status', frame);
162
+ break;
163
+ case 'error':
164
+ if (frame.code === 'auth_failed' || frame.code === 'incompatible_version' || frame.code === 'single_connection') {
165
+ this._log(`bridge handshake rejected: ${frame.code}`);
166
+ this.emit('auth-failed', frame.code);
167
+ }
168
+ this.emit('error-frame', frame);
169
+ break;
170
+ case 'dropped':
171
+ // A queued outbound the channel could not retain (overflow / expiry).
172
+ this.emit('dropped', frame);
173
+ break;
174
+ case 'pong':
175
+ this.emit('pong', frame);
176
+ break;
177
+ default:
178
+ // Forward-compat: ignore unknown ops.
179
+ break;
180
+ }
181
+ }
182
+
183
+ _scheduleReconnect() {
184
+ if (this._closed) return;
185
+ if (this._reconnectTimer) return;
186
+ this._attempt += 1;
187
+ const delay = Math.min(this._maxBackoff, this._minBackoff * 2 ** Math.min(this._attempt - 1, 6));
188
+ this.emit('reconnecting', this._attempt, delay);
189
+ this._reconnectTimer = setTimeout(() => {
190
+ this._reconnectTimer = null;
191
+ this._openOnce();
192
+ }, delay);
193
+ if (this._reconnectTimer.unref) this._reconnectTimer.unref();
194
+ }
195
+
196
+ /**
197
+ * Write an already-encoded frame string. Returns true if written.
198
+ * Throws if not connected (caller decides whether to surface or wait).
199
+ */
200
+ _write(frameStr) {
201
+ if (!this._sock || !this._connected) {
202
+ const e = new Error('NanoClaw bridge not connected');
203
+ e.code = 'NOT_CONNECTED';
204
+ throw e;
205
+ }
206
+ return this._sock.write(frameStr);
207
+ }
208
+
209
+ /** Inject a workspace message into NanoClaw. `fields` per proto.buildInbound. */
210
+ sendInbound(fields) {
211
+ return this._write(proto.buildInbound(fields));
212
+ }
213
+
214
+ sendCancel(platformId, msgId) {
215
+ try {
216
+ return this._write(proto.buildCancel(platformId, msgId));
217
+ } catch {
218
+ return false; // cancel is best-effort
219
+ }
220
+ }
221
+
222
+ /** Acknowledge a delivered outbound so the channel stops holding/replaying it. */
223
+ sendAck(outId, platformId) {
224
+ if (!outId) return false;
225
+ try {
226
+ return this._write(proto.buildAck(outId, platformId));
227
+ } catch {
228
+ return false; // will be re-driven by the channel's replay on reconnect
229
+ }
230
+ }
231
+
232
+ ping() {
233
+ try {
234
+ return this._write(proto.buildPing());
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /** Stop reconnecting and tear down the socket. */
241
+ close() {
242
+ this._closed = true;
243
+ if (this._reconnectTimer) {
244
+ clearTimeout(this._reconnectTimer);
245
+ this._reconnectTimer = null;
246
+ }
247
+ const sock = this._sock;
248
+ this._sock = null;
249
+ this._connected = false;
250
+ this._ready = false;
251
+ if (sock) {
252
+ try {
253
+ sock.removeAllListeners('close');
254
+ sock.on('error', noop); // swallow teardown EPIPE
255
+ sock.end();
256
+ sock.destroy();
257
+ } catch {
258
+ /* best-effort */
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ module.exports = { NanoclawBridge };