@openagents-org/agent-launcher 0.2.173 → 0.2.176

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/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 agent type (see
59
- // probe.js) — the check that catches "connected but never answers".
60
- // Results are persisted so a daemon restart doesn't re-spend LLM calls,
61
- // and are reported to every paired workspace on the node heartbeat.
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
- return data && typeof data === 'object' ? data : {};
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 type via a child `agn probe --json` (same off-event-loop
83
- * pattern as _refreshRuntimes; a probe can block for its full timeout).
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 _probeType(type) {
86
- if (this._probeInFlight.has(type)) return this._probes[type] || null;
87
- this._probeInFlight.add(type);
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
- this._probes[type] = parsed;
108
+ parsed.agent = name;
109
+ this._probes[name] = parsed;
100
110
  this._saveProbes();
101
111
  return parsed;
102
112
  } finally {
103
- this._probeInFlight.delete(type);
113
+ this._probeInFlight.delete(name);
104
114
  }
105
115
  }
106
116
 
107
117
  /**
108
- * Periodic sweep: probe each type that has a configured agent and whose
109
- * last result is missing or stale. Sequential on purpose — probes cost a
110
- * (tiny) model call each; there is no hurry.
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 _probeConfiguredTypes(maxAgeMs) {
113
- const types = [...new Set(this.config.getAgents().map((a) => a.type || 'openclaw'))];
114
- for (const type of types) {
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._probeType(type); } catch {}
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._runtimesWithProbes(), fs: this._buildFs() };
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
- async _runNodeCommand(n, cmd) {
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
- if (args.apiKey) await this._runAgn(['env', type, '--set', `LLM_API_KEY=${args.apiKey}`]);
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
- // Attach to this node's workspace so the agent shows up as a member.
346
- const r2 = await this._runAgn(['connect', name, n.token, '--endpoint', n.endpoint]);
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 runtimes[].probe.
353
- this._probeType(type).then(() => this._nodeHeartbeat()).catch(() => {});
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) await this._runAgn(['env', type, '--set', `LLM_API_KEY=${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(['connect', name, n.token, '--endpoint', n.endpoint]);
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
- await this._runAgn(['stop', name]);
384
- await this._runAgn(['start', name]);
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 ("Test agent" in the workspace). Accepts a
390
- // type directly, or an agent name (must belong to this workspace).
391
- let type = (args.type || '').trim();
392
- if (!type && name) {
393
- const agent = this.config.getAgent(name);
394
- if (!agent || !this._agentOnNodeWorkspace(agent, n)) {
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
- if (!type) throw new Error('Missing agent type');
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
- const PROBE_MAX_AGE_MS = 24 * 3600 * 1000;
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._probeConfiguredTypes(PROBE_MAX_AGE_MS).catch(() => {}),
625
+ () => this._probeConfiguredAgents(PROBE_MAX_AGE_MS).catch(() => {}),
537
626
  3 * 60 * 1000,
538
627
  );
539
628
  this._probeInterval = setInterval(
540
- () => this._probeConfiguredTypes(PROBE_MAX_AGE_MS).catch(() => {}),
541
- 6 * 3600 * 1000,
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.config.getNetworks().find(
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
- const srcVal = saved[src];
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
- const envAnyReady = this._hasAnyValue(process.env, checkReady.env_vars);
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.