@openagents-org/agent-launcher 0.2.173 → 0.2.175
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.
- package/icons/commandcode.svg +3 -0
- package/package.json +1 -1
- package/registry.json +259 -35
- package/src/adapters/base.js +19 -0
- package/src/adapters/codex.js +41 -1
- package/src/adapters/commandcode-stream.js +405 -0
- package/src/adapters/commandcode.js +853 -0
- package/src/adapters/hermes.js +56 -0
- package/src/adapters/index.js +4 -1
- package/src/adapters/kimi-stream.js +360 -0
- package/src/adapters/kimi.js +724 -13
- package/src/adapters/openclaw.js +28 -2
- package/src/adapters/opencode.js +86 -0
- package/src/adapters/pi-stream.js +28 -0
- package/src/adapters/pi.js +21 -2
- package/src/adapters/workspace-prompt.js +42 -8
- package/src/cli.js +182 -6
- package/src/config.js +13 -2
- package/src/daemon.js +192 -59
- package/src/env.js +22 -2
- package/src/index.js +13 -0
- package/src/installer.js +69 -1
- package/src/paths.js +315 -11
- package/src/probe.js +29 -8
- package/src/registry.js +4 -1
package/src/daemon.js
CHANGED
|
@@ -55,10 +55,11 @@ class Daemon {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// ── Agent smoke tests (probes) ─────────────────────────────────────────
|
|
58
|
-
// A probe runs one tiny end-to-end prompt through an
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
58
|
+
// A probe runs one tiny end-to-end prompt through an AGENT (see probe.js)
|
|
59
|
+
// — the check that catches "connected but never answers". Results are
|
|
60
|
+
// keyed by agent name (a probe belongs to a configured agent, never to a
|
|
61
|
+
// bare agent type), persisted so a daemon restart doesn't re-spend LLM
|
|
62
|
+
// calls, and reported per agent on the node heartbeat's roster.
|
|
62
63
|
|
|
63
64
|
_probesFile() {
|
|
64
65
|
return path.join(os.homedir(), '.openagents', 'probes.json');
|
|
@@ -67,24 +68,32 @@ class Daemon {
|
|
|
67
68
|
_loadProbes() {
|
|
68
69
|
try {
|
|
69
70
|
const data = JSON.parse(fs.readFileSync(this._probesFile(), 'utf-8'));
|
|
70
|
-
|
|
71
|
+
// v2 shape: { agents: { <name>: result } }. The v1 file was keyed by
|
|
72
|
+
// type — discard it rather than misattribute results to agents.
|
|
73
|
+
if (data && typeof data.agents === 'object') return data.agents;
|
|
74
|
+
return {};
|
|
71
75
|
} catch { return {}; }
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
_saveProbes() {
|
|
75
79
|
try {
|
|
76
80
|
fs.mkdirSync(path.dirname(this._probesFile()), { recursive: true });
|
|
77
|
-
fs.writeFileSync(this._probesFile(), JSON.stringify(this._probes, null, 2), 'utf-8');
|
|
81
|
+
fs.writeFileSync(this._probesFile(), JSON.stringify({ agents: this._probes }, null, 2), 'utf-8');
|
|
78
82
|
} catch {}
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
/**
|
|
82
|
-
* Probe one
|
|
83
|
-
* pattern as _refreshRuntimes; a probe can block for its
|
|
86
|
+
* Probe one configured agent via a child `agn probe <type> --json` (same
|
|
87
|
+
* off-event-loop pattern as _refreshRuntimes; a probe can block for its
|
|
88
|
+
* full timeout). The check exercises the agent's type runtime with the
|
|
89
|
+
* same env the agent runs with; the result is stored under the agent name.
|
|
84
90
|
*/
|
|
85
|
-
async
|
|
86
|
-
if (this._probeInFlight.has(
|
|
87
|
-
this.
|
|
91
|
+
async _probeAgent(name) {
|
|
92
|
+
if (this._probeInFlight.has(name)) return this._probes[name] || null;
|
|
93
|
+
const agent = this.config.getAgent(name);
|
|
94
|
+
if (!agent) return null;
|
|
95
|
+
const type = agent.type || 'openclaw';
|
|
96
|
+
this._probeInFlight.add(name);
|
|
88
97
|
try {
|
|
89
98
|
const r = await this._runAgn(['probe', type, '--json']);
|
|
90
99
|
let parsed = null;
|
|
@@ -96,37 +105,30 @@ class Daemon {
|
|
|
96
105
|
guidance: [], at: new Date().toISOString(),
|
|
97
106
|
};
|
|
98
107
|
}
|
|
99
|
-
|
|
108
|
+
parsed.agent = name;
|
|
109
|
+
this._probes[name] = parsed;
|
|
100
110
|
this._saveProbes();
|
|
101
111
|
return parsed;
|
|
102
112
|
} finally {
|
|
103
|
-
this._probeInFlight.delete(
|
|
113
|
+
this._probeInFlight.delete(name);
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
/**
|
|
108
|
-
* Periodic sweep: probe each
|
|
109
|
-
*
|
|
110
|
-
*
|
|
118
|
+
* Periodic sweep: probe each configured agent whose last result is missing
|
|
119
|
+
* or stale. Sequential on purpose — probes cost a (tiny) model call each;
|
|
120
|
+
* there is no hurry.
|
|
111
121
|
*/
|
|
112
|
-
async
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const last = this._probes[type];
|
|
122
|
+
async _probeConfiguredAgents(maxAgeMs) {
|
|
123
|
+
for (const a of this.config.getAgents()) {
|
|
124
|
+
const last = this._probes[a.name];
|
|
116
125
|
const age = last && last.at ? Date.now() - Date.parse(last.at) : Infinity;
|
|
117
126
|
if (age < maxAgeMs) continue;
|
|
118
|
-
try { await this.
|
|
127
|
+
try { await this._probeAgent(a.name); } catch {}
|
|
119
128
|
}
|
|
120
129
|
this._nodeHeartbeat();
|
|
121
130
|
}
|
|
122
131
|
|
|
123
|
-
/** Runtimes annotated with each type's last probe, for the heartbeat. */
|
|
124
|
-
_runtimesWithProbes() {
|
|
125
|
-
return (this._runtimes || []).map((r) =>
|
|
126
|
-
this._probes[r.type] ? { ...r, probe: this._probes[r.type] } : r
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
132
|
/**
|
|
131
133
|
* Heartbeat this device (node) to EVERY workspace it is paired to,
|
|
132
134
|
* independent of any agent, so each workspace's "connected devices" view
|
|
@@ -144,10 +146,41 @@ class Daemon {
|
|
|
144
146
|
const nodeCfg = require('./node-config');
|
|
145
147
|
const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
|
|
146
148
|
if (!pairings.length) return; // no node connected
|
|
147
|
-
const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this.
|
|
149
|
+
const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimes || [], fs: this._buildFs() };
|
|
148
150
|
await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Restart any adapter whose workspace credential has rotated on disk.
|
|
155
|
+
*
|
|
156
|
+
* Re-pairing a device (launcher, CLI, or the workspace's own reconnect flow)
|
|
157
|
+
* writes a fresh token to node.json / daemon.yaml and the server revokes the
|
|
158
|
+
* old one. An adapter launched before that keeps its stale token in memory
|
|
159
|
+
* and every join/poll/heartbeat 401s until someone manually restarts it —
|
|
160
|
+
* the dashboard meanwhile shows the agent stuck on "Spinning up…". This
|
|
161
|
+
* watch closes that gap: credentials are re-resolved from disk (pairing
|
|
162
|
+
* first, saved network as fallback — same order as launch) and a changed
|
|
163
|
+
* token triggers an in-process restart, which picks the new credential up.
|
|
164
|
+
*/
|
|
165
|
+
_reconcileAdapterCredentials() {
|
|
166
|
+
if (this._shuttingDown) return;
|
|
167
|
+
for (const [name, info] of Object.entries(this._processes || {})) {
|
|
168
|
+
if (!info || !info.networkRef || info._credRestartPending) continue;
|
|
169
|
+
if (!this._adapters || !this._adapters[name]) continue;
|
|
170
|
+
let fresh = null;
|
|
171
|
+
try { fresh = this._resolveAgentNetwork(info.networkRef); } catch { /* keep running on the current token */ }
|
|
172
|
+
if (!fresh || !fresh.token || fresh.token === info.credentialToken) continue;
|
|
173
|
+
// Flag on the CURRENT info object: restartAgent replaces it with a fresh
|
|
174
|
+
// one on relaunch, so the flag cannot wedge the watch permanently.
|
|
175
|
+
info._credRestartPending = true;
|
|
176
|
+
this._log(`${name}: workspace credential for '${info.networkRef}' rotated (device re-paired) — restarting to pick up the new token`);
|
|
177
|
+
this.restartAgent(name).catch((e) => {
|
|
178
|
+
info._credRestartPending = false;
|
|
179
|
+
this._log(`${name}: credential-rotation restart failed: ${e.message}`);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
151
184
|
/** The WorkspaceClient for one pairing, created on first use and reused. */
|
|
152
185
|
_nodeClientFor(n) {
|
|
153
186
|
let client = this._nodeClients.get(n.workspace_id);
|
|
@@ -235,6 +268,7 @@ class Daemon {
|
|
|
235
268
|
model: model || null,
|
|
236
269
|
workingDir: a.path || null,
|
|
237
270
|
apiKeyMasked: apiKey ? maskApiKey(apiKey) : null,
|
|
271
|
+
probe: this._probes[a.name] || null,
|
|
238
272
|
});
|
|
239
273
|
}
|
|
240
274
|
} catch {
|
|
@@ -308,7 +342,35 @@ class Daemon {
|
|
|
308
342
|
* all of create/install/connect/start/stop/remove reuses the exact code path a
|
|
309
343
|
* local `agn` invocation would — then report the outcome back to the workspace.
|
|
310
344
|
*/
|
|
311
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Apply a workspace-sent config map, ALLOWLISTED to the agent type's own
|
|
347
|
+
* registry env_config keys. Arbitrary env is refused — a workspace admin
|
|
348
|
+
* must not be able to push NODE_OPTIONS-class variables onto a device.
|
|
349
|
+
* (Credential contract: config flows down transiently, secrets persist
|
|
350
|
+
* only here, in the device's env store.)
|
|
351
|
+
*/
|
|
352
|
+
async _applyConfigMap(type, config) {
|
|
353
|
+
if (!config || typeof config !== 'object') return;
|
|
354
|
+
let allowed = null;
|
|
355
|
+
try {
|
|
356
|
+
const entry = this.registry.getEntry(type);
|
|
357
|
+
allowed = new Set(
|
|
358
|
+
((entry && entry.env_config) || []).map((f) => f.name).filter(Boolean)
|
|
359
|
+
);
|
|
360
|
+
} catch {
|
|
361
|
+
return; // no registry entry — refuse everything rather than guess
|
|
362
|
+
}
|
|
363
|
+
for (const [key, value] of Object.entries(config)) {
|
|
364
|
+
if (!allowed.has(key)) {
|
|
365
|
+
this._log(`config for '${type}': refused non-env_config key '${key}'`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (value === null || value === undefined) continue;
|
|
369
|
+
await this._runAgn(['env', type, '--set', `${key}=${String(value)}`]);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async _runNodeCommand(n, cmd) {
|
|
312
374
|
const action = cmd.action;
|
|
313
375
|
const args = cmd.args || {};
|
|
314
376
|
const name = (args.name || '').trim();
|
|
@@ -338,19 +400,27 @@ class Daemon {
|
|
|
338
400
|
const r1 = await this._runAgn(['create', name, '--type', type, '--install', '--path', workingDir]);
|
|
339
401
|
if (r1.code !== 0) throw new Error(r1.stderr || r1.stdout || 'create failed');
|
|
340
402
|
// Optional credentials for API-key agents (generic → provider mapping
|
|
341
|
-
// happens in env resolution).
|
|
342
|
-
|
|
403
|
+
// happens in env resolution). `useDeviceCredentials` means the device
|
|
404
|
+
// already holds them — nothing to write.
|
|
405
|
+
if (args.apiKey && !args.useDeviceCredentials)
|
|
406
|
+
await this._runAgn(['env', type, '--set', `LLM_API_KEY=${args.apiKey}`]);
|
|
343
407
|
if (args.model) await this._setModelEnv(type, args.model);
|
|
344
408
|
if (args.baseUrl) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
|
|
345
|
-
|
|
346
|
-
|
|
409
|
+
await this._applyConfigMap(type, args.config);
|
|
410
|
+
// Attach to this node's workspace BY SLUG: the daemon already knows
|
|
411
|
+
// which workspace this command came from, so there is no token to
|
|
412
|
+
// pass and no /v1/token/resolve round-trip to fail (the outage class
|
|
413
|
+
// that used to break remote installs).
|
|
414
|
+
const r2 = await this._runAgn([
|
|
415
|
+
'connect', name, '--workspace', n.workspace_slug || n.workspace_id,
|
|
416
|
+
]);
|
|
347
417
|
if (r2.code !== 0) throw new Error(r2.stderr || r2.stdout || 'connect failed');
|
|
348
418
|
ok = true;
|
|
349
419
|
message = `Agent '${name}' created`;
|
|
350
420
|
// First-connect smoke test, in the background (a probe can take its
|
|
351
421
|
// full timeout — never hold the create result hostage). The outcome
|
|
352
|
-
// reaches the workspace via the heartbeat's
|
|
353
|
-
this.
|
|
422
|
+
// reaches the workspace via the heartbeat's agents[].probe.
|
|
423
|
+
this._probeAgent(name).then(() => this._nodeHeartbeat()).catch(() => {});
|
|
354
424
|
} else if (action === 'start_agent') {
|
|
355
425
|
const r = await this._runAgn(['start', name]);
|
|
356
426
|
ok = r.code === 0;
|
|
@@ -368,36 +438,42 @@ class Daemon {
|
|
|
368
438
|
// restart it so the change takes effect. Working-dir changes require a
|
|
369
439
|
// recreate (agn has no in-place path change) at the new path.
|
|
370
440
|
const type = (args.type || '').trim();
|
|
371
|
-
if (args.apiKey
|
|
441
|
+
if (args.apiKey && !args.useDeviceCredentials)
|
|
442
|
+
await this._runAgn(['env', type, '--set', `LLM_API_KEY=${args.apiKey}`]);
|
|
372
443
|
if (args.model !== undefined) await this._setModelEnv(type, args.model);
|
|
373
444
|
if (args.baseUrl !== undefined) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
|
|
445
|
+
await this._applyConfigMap(type, args.config);
|
|
374
446
|
const newDir = (args.workingDir || '').trim();
|
|
375
447
|
if (newDir && newDir !== (args.currentWorkingDir || '')) {
|
|
376
448
|
try { fs.mkdirSync(newDir, { recursive: true }); } catch {}
|
|
377
449
|
await this._runAgn(['remove', name]);
|
|
378
450
|
const rc = await this._runAgn(['create', name, '--type', type, '--install', '--path', newDir]);
|
|
379
451
|
if (rc.code !== 0) throw new Error(rc.stderr || rc.stdout || 'recreate failed');
|
|
380
|
-
const rc2 = await this._runAgn([
|
|
452
|
+
const rc2 = await this._runAgn([
|
|
453
|
+
'connect', name, '--workspace', n.workspace_slug || n.workspace_id,
|
|
454
|
+
]);
|
|
381
455
|
if (rc2.code !== 0) throw new Error(rc2.stderr || rc2.stdout || 'connect failed');
|
|
382
456
|
} else {
|
|
383
|
-
|
|
384
|
-
|
|
457
|
+
// Restart in-process. `agn stop` + `agn start` both round-trip
|
|
458
|
+
// through daemon.cmd, and writeCommand() OVERWRITES that file — the
|
|
459
|
+
// start could clobber the unread stop (agent keeps the stale env) or
|
|
460
|
+
// interleave with the poll so the agent stops and never restarts.
|
|
461
|
+
await this.restartAgent(name);
|
|
385
462
|
}
|
|
386
463
|
ok = true;
|
|
387
464
|
message = `Agent '${name}' reconfigured`;
|
|
465
|
+
// A reconfigure changes credentials/model — re-verify in the
|
|
466
|
+
// background so the workspace sees the new state without a manual test.
|
|
467
|
+
this._probeAgent(name).then(() => this._nodeHeartbeat()).catch(() => {});
|
|
388
468
|
} else if (action === 'probe_agent') {
|
|
389
|
-
// Smoke-test on demand ("
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
throw new Error(`Agent '${name}' is not managed by this workspace`);
|
|
396
|
-
}
|
|
397
|
-
type = agent.type || 'openclaw';
|
|
469
|
+
// Smoke-test on demand ("Re-test" in the workspace). Probes belong to
|
|
470
|
+
// configured agents (by name); the result rides agents[].probe.
|
|
471
|
+
if (!name) throw new Error('Missing agent name');
|
|
472
|
+
const agent = this.config.getAgent(name);
|
|
473
|
+
if (!agent || !this._agentOnNodeWorkspace(agent, n)) {
|
|
474
|
+
throw new Error(`Agent '${name}' is not managed by this workspace`);
|
|
398
475
|
}
|
|
399
|
-
|
|
400
|
-
const probe = await this._probeType(type);
|
|
476
|
+
const probe = await this._probeAgent(name);
|
|
401
477
|
ok = !!(probe && probe.ok);
|
|
402
478
|
message = (probe && probe.message) || (ok ? 'Smoke test passed' : 'Smoke test failed');
|
|
403
479
|
data = { probe };
|
|
@@ -522,6 +598,16 @@ class Daemon {
|
|
|
522
598
|
this._nodeHeartbeat();
|
|
523
599
|
this._nodeHeartbeatInterval = setInterval(() => this._nodeHeartbeat(), 10000);
|
|
524
600
|
|
|
601
|
+
// Credential-rotation watch. Re-pairing a device rotates the workspace
|
|
602
|
+
// token (and revokes the old one) while adapters holding the old token
|
|
603
|
+
// keep 401-ing forever — the "agent stuck spinning up after re-pair"
|
|
604
|
+
// failure. Pairings are re-read from disk on every pass, so a re-pair done
|
|
605
|
+
// by the launcher, the CLI or a remote command is picked up within a tick.
|
|
606
|
+
this._credReconcileInterval = setInterval(
|
|
607
|
+
() => this._reconcileAdapterCredentials(),
|
|
608
|
+
15000,
|
|
609
|
+
);
|
|
610
|
+
|
|
525
611
|
// Detect installed/logged-in agent runtimes for the Add-agent gallery. Runs
|
|
526
612
|
// in a child process (off the event loop), refreshed periodically.
|
|
527
613
|
this._refreshRuntimes();
|
|
@@ -531,14 +617,17 @@ class Daemon {
|
|
|
531
617
|
// tiny model call, so: only types with agents, only when the last result
|
|
532
618
|
// is a day old, and a few minutes after startup so install/login churn
|
|
533
619
|
// settles first). Persisted results keep restarts from re-spending calls.
|
|
534
|
-
|
|
620
|
+
// Hourly per-agent sweep — a stale/broken key should surface within the
|
|
621
|
+
// hour, not the day. Create/reconfigure probe immediately, so the sweep
|
|
622
|
+
// only tops up agents whose last result has aged out.
|
|
623
|
+
const PROBE_MAX_AGE_MS = 3600 * 1000;
|
|
535
624
|
this._probeStartupTimer = setTimeout(
|
|
536
|
-
() => this.
|
|
625
|
+
() => this._probeConfiguredAgents(PROBE_MAX_AGE_MS).catch(() => {}),
|
|
537
626
|
3 * 60 * 1000,
|
|
538
627
|
);
|
|
539
628
|
this._probeInterval = setInterval(
|
|
540
|
-
() => this.
|
|
541
|
-
|
|
629
|
+
() => this._probeConfiguredAgents(PROBE_MAX_AGE_MS).catch(() => {}),
|
|
630
|
+
3600 * 1000,
|
|
542
631
|
);
|
|
543
632
|
|
|
544
633
|
// Watch config file for hot-reload
|
|
@@ -567,6 +656,7 @@ class Daemon {
|
|
|
567
656
|
if (this._statusInterval) clearInterval(this._statusInterval);
|
|
568
657
|
if (this._cmdInterval) clearInterval(this._cmdInterval);
|
|
569
658
|
if (this._nodeHeartbeatInterval) clearInterval(this._nodeHeartbeatInterval);
|
|
659
|
+
if (this._credReconcileInterval) clearInterval(this._credReconcileInterval);
|
|
570
660
|
if (this._runtimesInterval) clearInterval(this._runtimesInterval);
|
|
571
661
|
if (this._probeStartupTimer) clearTimeout(this._probeStartupTimer);
|
|
572
662
|
if (this._probeInterval) clearInterval(this._probeInterval);
|
|
@@ -817,6 +907,35 @@ class Daemon {
|
|
|
817
907
|
// Internal — agent launch
|
|
818
908
|
// ---------------------------------------------------------------------------
|
|
819
909
|
|
|
910
|
+
/**
|
|
911
|
+
* The workspace record an agent joins with. The device pairing (node.json)
|
|
912
|
+
* is the authoritative credential — it is refreshed by every re-pair —
|
|
913
|
+
* with the saved network entry (daemon.yaml) as the manual-connection
|
|
914
|
+
* fallback. Fields from the network entry win for display (name), the
|
|
915
|
+
* pairing wins for credentials (token/endpoint).
|
|
916
|
+
*/
|
|
917
|
+
_resolveAgentNetwork(ref) {
|
|
918
|
+
const network =
|
|
919
|
+
this.config.getNetworks().find((n) => n.slug === ref || n.id === ref) ||
|
|
920
|
+
null;
|
|
921
|
+
let pairing = null;
|
|
922
|
+
try {
|
|
923
|
+
pairing = require('./node-config')
|
|
924
|
+
.listPairings()
|
|
925
|
+
.find((p) => p.workspace_slug === ref || p.workspace_id === ref) || null;
|
|
926
|
+
} catch {}
|
|
927
|
+
if (!network && !pairing) return null;
|
|
928
|
+
return {
|
|
929
|
+
id: (network && network.id) || (pairing && pairing.workspace_id) || null,
|
|
930
|
+
slug: (network && network.slug) || (pairing && pairing.workspace_slug) || ref,
|
|
931
|
+
name:
|
|
932
|
+
(network && network.name) || (pairing && pairing.workspace_name) || ref,
|
|
933
|
+
endpoint:
|
|
934
|
+
(pairing && pairing.endpoint) || (network && network.endpoint) || null,
|
|
935
|
+
token: (pairing && pairing.token) || (network && network.token) || null,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
820
939
|
_launchAgent(agentCfg) {
|
|
821
940
|
const name = agentCfg.name;
|
|
822
941
|
const type = agentCfg.type || 'openclaw';
|
|
@@ -847,12 +966,26 @@ class Daemon {
|
|
|
847
966
|
// Workspace-connected agents use the adapter loop (poll + CLI per message).
|
|
848
967
|
// Local-only agents use the spawn loop (long-running child process).
|
|
849
968
|
const network = agentCfg.network
|
|
850
|
-
? this.
|
|
851
|
-
(n) => n.slug === agentCfg.network || n.id === agentCfg.network
|
|
852
|
-
)
|
|
969
|
+
? this._resolveAgentNetwork(agentCfg.network)
|
|
853
970
|
: null;
|
|
854
971
|
|
|
855
|
-
if (network) {
|
|
972
|
+
if (network && !network.token) {
|
|
973
|
+
// A known workspace with no credential anywhere: the device was
|
|
974
|
+
// unpaired (or a future token-free entry has no pairing behind it).
|
|
975
|
+
// Say so instead of letting the join fail with a bare 401.
|
|
976
|
+
info.state = 'error';
|
|
977
|
+
info.network = agentCfg.network;
|
|
978
|
+
info.lastError =
|
|
979
|
+
'workspace credentials missing — re-pair this device with the workspace';
|
|
980
|
+
this._writeStatus();
|
|
981
|
+
this._log(
|
|
982
|
+
`${name} cannot join '${agentCfg.network}': no pairing and no saved token — re-pair the device`
|
|
983
|
+
);
|
|
984
|
+
} else if (network) {
|
|
985
|
+
// Remember which credential this adapter runs with, so the rotation
|
|
986
|
+
// watch can restart it when a re-pair hands the workspace a new token.
|
|
987
|
+
info.networkRef = agentCfg.network;
|
|
988
|
+
info.credentialToken = network.token;
|
|
856
989
|
this._adapterLoop(name, agentCfg, info, network);
|
|
857
990
|
} else {
|
|
858
991
|
// No workspace connected — agent is running locally
|
package/src/env.js
CHANGED
|
@@ -81,7 +81,7 @@ class EnvManager {
|
|
|
81
81
|
for (const rule of rules) {
|
|
82
82
|
const src = rule.from || '';
|
|
83
83
|
const dst = rule.to || '';
|
|
84
|
-
|
|
84
|
+
let srcVal = saved[src];
|
|
85
85
|
if (!srcVal || !dst) continue;
|
|
86
86
|
|
|
87
87
|
// Conditional rules based on base URL
|
|
@@ -92,6 +92,7 @@ class EnvManager {
|
|
|
92
92
|
if (baseUrl.includes(rule.unless_base_url_contains.toLowerCase())) continue;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
if (dst === 'ANTHROPIC_BASE_URL') srcVal = normalizeAnthropicBase(srcVal);
|
|
95
96
|
resolved[dst] = srcVal;
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -102,10 +103,29 @@ class EnvManager {
|
|
|
102
103
|
* Get the full effective env for an agent: saved + resolved.
|
|
103
104
|
*/
|
|
104
105
|
getEffective(agentType, registry) {
|
|
105
|
-
const saved = this.load(agentType);
|
|
106
|
+
const saved = { ...this.load(agentType) };
|
|
107
|
+
// Same normalization as resolve(), for a base saved under the provider
|
|
108
|
+
// key directly (agn env --set ANTHROPIC_BASE_URL=…, hand-edited files).
|
|
109
|
+
if (saved.ANTHROPIC_BASE_URL) {
|
|
110
|
+
saved.ANTHROPIC_BASE_URL = normalizeAnthropicBase(saved.ANTHROPIC_BASE_URL);
|
|
111
|
+
}
|
|
106
112
|
const resolved = this.resolve(agentType, saved, registry);
|
|
107
113
|
return { ...saved, ...resolved };
|
|
108
114
|
}
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Anthropic's SDK appends the `/v1` segment itself: a base saved as
|
|
119
|
+
* `https://relay.example/v1` makes the claude CLI call `…/v1/v1/messages`,
|
|
120
|
+
* a 404 it mis-reports as "there's an issue with the selected model". The
|
|
121
|
+
* launcher already strips the suffix for env saved through its UI
|
|
122
|
+
* (env-normalize.ts); this is the same rule at the resolution layer, so env
|
|
123
|
+
* written by ANY path — `agn env --set`, the workspace's remote
|
|
124
|
+
* configure_agent command, a hand-edited file — is covered too. OpenAI-style
|
|
125
|
+
* bases legitimately carry `/v1` and are left alone.
|
|
126
|
+
*/
|
|
127
|
+
function normalizeAnthropicBase(url) {
|
|
128
|
+
return String(url || '').trim().replace(/\/+$/, '').replace(/\/v1$/i, '');
|
|
129
|
+
}
|
|
130
|
+
|
|
111
131
|
module.exports = { EnvManager };
|
package/src/index.js
CHANGED
|
@@ -126,6 +126,14 @@ class AgentConnector {
|
|
|
126
126
|
const saved = this.env.load(agentType);
|
|
127
127
|
OpenClawAdapter.configureNativeAuth(saved);
|
|
128
128
|
}
|
|
129
|
+
// Hermes reads a custom endpoint only from its own config.yaml, so the
|
|
130
|
+
// saved LLM_* values must be pushed through `hermes config set` — the
|
|
131
|
+
// env file alone leaves it "No inference provider configured".
|
|
132
|
+
if (agentType === 'hermes') {
|
|
133
|
+
const HermesAdapter = require('./adapters/hermes');
|
|
134
|
+
const saved = this.env.load(agentType);
|
|
135
|
+
HermesAdapter.configureNativeAuth(saved);
|
|
136
|
+
}
|
|
129
137
|
} catch {}
|
|
130
138
|
return { success: true };
|
|
131
139
|
}
|
|
@@ -154,6 +162,11 @@ class AgentConnector {
|
|
|
154
162
|
const typeEnv = this.env.load(agent.type || 'openclaw');
|
|
155
163
|
OpenClawAdapter.configureNativeAuth({ ...typeEnv, ...saved });
|
|
156
164
|
}
|
|
165
|
+
if (agent.type === 'hermes') {
|
|
166
|
+
const HermesAdapter = require('./adapters/hermes');
|
|
167
|
+
const typeEnv = this.env.load('hermes');
|
|
168
|
+
HermesAdapter.configureNativeAuth({ ...typeEnv, ...saved });
|
|
169
|
+
}
|
|
157
170
|
} catch {}
|
|
158
171
|
|
|
159
172
|
return { success: true };
|
package/src/installer.js
CHANGED
|
@@ -728,7 +728,16 @@ class Installer {
|
|
|
728
728
|
const directEnv = this._hasAllValues(process.env, checkReady.env_all);
|
|
729
729
|
const directSaved = this._hasAllValues(savedEnv, checkReady.saved_env_all || checkReady.env_all);
|
|
730
730
|
const directReady = directEnv || directSaved;
|
|
731
|
-
|
|
731
|
+
// env_vars must be satisfiable by the SAVED (and resolved) per-agent env,
|
|
732
|
+
// not only the daemon's own process env. The workspace configure flow
|
|
733
|
+
// saves LLM_API_KEY, which resolve rules map to e.g. ANTHROPIC_AUTH_TOKEN
|
|
734
|
+
// — the exact var the adapter launches the CLI with. Checking process.env
|
|
735
|
+
// alone made a perfectly working key-configured agent read "Not logged
|
|
736
|
+
// in" (health banner + failed smoke test) while chat answered fine, with
|
|
737
|
+
// guidance telling the user to configure the very key they had saved.
|
|
738
|
+
const envAnyReady =
|
|
739
|
+
this._hasAnyValue(process.env, checkReady.env_vars) ||
|
|
740
|
+
this._hasAnyValue(savedEnv, checkReady.env_vars);
|
|
732
741
|
const savedAnyReady = !!(checkReady.saved_env_key && savedEnv[checkReady.saved_env_key]);
|
|
733
742
|
// A registry entry may require a non-empty JSON object rather than mere
|
|
734
743
|
// parseability. In that mode the stricter _evaluateCredsFile path below is
|
|
@@ -1544,6 +1553,21 @@ class Installer {
|
|
|
1544
1553
|
const aliases = entry && entry.install && Array.isArray(entry.install.binary_aliases)
|
|
1545
1554
|
? entry.install.binary_aliases
|
|
1546
1555
|
: [];
|
|
1556
|
+
// Tier 0: the copy THIS launcher installed, when there really is one.
|
|
1557
|
+
//
|
|
1558
|
+
// getInstallInfo() checks the isolated runtime prefix before anything else,
|
|
1559
|
+
// so an agent with a managed install reports location:'runtime' and shows
|
|
1560
|
+
// that copy's version. A PATH lookup, though, hits the user's own global
|
|
1561
|
+
// copy first — /usr/local/bin and the node-manager bins all rank above
|
|
1562
|
+
// ~/.openagents/runtimes/*/node_modules/.bin. The two then disagree about
|
|
1563
|
+
// which binary is in play: the marketplace said "opencode 1.18.25,
|
|
1564
|
+
// managed" while every run spawned the 1.17 the user had installed
|
|
1565
|
+
// themselves, and pressing Update changed the number without changing the
|
|
1566
|
+
// program. Gated on the package actually being present so a stale shim
|
|
1567
|
+
// still falls through to a working global install.
|
|
1568
|
+
const managed = this._resolveManagedBinary(agentType, entry, binary, aliases);
|
|
1569
|
+
if (managed) return managed;
|
|
1570
|
+
|
|
1547
1571
|
for (const candidate of [binary, ...aliases]) {
|
|
1548
1572
|
const found = whichBinary(candidate);
|
|
1549
1573
|
if (found) return found;
|
|
@@ -1567,6 +1591,50 @@ class Installer {
|
|
|
1567
1591
|
return resolveBinaryInKnownDirs([binary, ...aliases], agentType);
|
|
1568
1592
|
}
|
|
1569
1593
|
|
|
1594
|
+
/**
|
|
1595
|
+
* The launcher-installed copy of an agent's CLI, or null.
|
|
1596
|
+
*
|
|
1597
|
+
* Only answers when the agent's npm package is really unpacked in one of our
|
|
1598
|
+
* prefixes — an orphaned `.bin` shim is not a managed install, and treating
|
|
1599
|
+
* it as one would shadow a perfectly good global CLI with something that
|
|
1600
|
+
* cannot run.
|
|
1601
|
+
*/
|
|
1602
|
+
_resolveManagedBinary(agentType, entry, binary, aliases) {
|
|
1603
|
+
const npmPkg = entry && entry.install ? entry.install.npm_package : null;
|
|
1604
|
+
const installCmd = entry && entry.install ? this._getInstallCommand(entry.install) : null;
|
|
1605
|
+
let npmPkgFromCmd = null;
|
|
1606
|
+
if (!npmPkg && installCmd && installCmd.includes('npm install')) {
|
|
1607
|
+
const m = installCmd.match(/npm install\s+(?:-g\s+)?(@?[\w-]+(?:\/[\w-]+)?)(?:@\S*)?$/);
|
|
1608
|
+
if (m) npmPkgFromCmd = m[1];
|
|
1609
|
+
}
|
|
1610
|
+
const pkgName = npmPkg || npmPkgFromCmd;
|
|
1611
|
+
if (!pkgName) return null; // script-installed agent: nothing of ours to prefer
|
|
1612
|
+
|
|
1613
|
+
const prefixes = [
|
|
1614
|
+
path.join(getRuntimePrefix(agentType), 'node_modules'),
|
|
1615
|
+
path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
|
|
1616
|
+
];
|
|
1617
|
+
const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
|
|
1618
|
+
for (const modules of prefixes) {
|
|
1619
|
+
try {
|
|
1620
|
+
if (!fs.existsSync(path.join(modules, pkgName, 'package.json'))) continue;
|
|
1621
|
+
} catch { continue; }
|
|
1622
|
+
for (const name of [binary, ...aliases]) {
|
|
1623
|
+
for (const ext of exts) {
|
|
1624
|
+
const candidate = path.join(modules, '.bin', `${name}${ext}`);
|
|
1625
|
+
try {
|
|
1626
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1627
|
+
} catch {}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// npm does not always link the root package's own bin — same case
|
|
1631
|
+
// _resolvePackageBin exists for.
|
|
1632
|
+
const own = this._resolvePackageBin(agentType, entry, binary);
|
|
1633
|
+
if (own) return own;
|
|
1634
|
+
}
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1570
1638
|
/**
|
|
1571
1639
|
* Resolve the absolute path to a package's own executable from its
|
|
1572
1640
|
* package.json `bin` field, searched in the runtime and legacy prefixes.
|