@openagents-org/agent-launcher 0.2.189 → 0.2.191

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.189",
3
+ "version": "0.2.191",
4
4
  "description": "OpenAgents Launcher \u2014 install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -267,14 +267,23 @@ class HermesAdapter extends BaseAdapter {
267
267
  // Output parsing
268
268
  // ------------------------------------------------------------------
269
269
 
270
- _parseHermesOutput(raw) {
270
+ _parseHermesOutput(raw, meta) {
271
271
  let sessionId = null;
272
272
  let body = raw;
273
273
 
274
- const m = SESSION_ID_RE.exec(body);
275
- if (m) {
276
- sessionId = m[1];
277
- body = body.replace(SESSION_ID_RE, '');
274
+ // Hermes -Q mode emits session_id to stderr (meta); the response body is
275
+ // on stdout (raw). Older Hermes printed session_id on stdout, so fall
276
+ // back to extracting it from the body when stderr carried none.
277
+ if (meta) {
278
+ const m = SESSION_ID_RE.exec(meta);
279
+ if (m) sessionId = m[1];
280
+ }
281
+ if (!sessionId) {
282
+ const m = SESSION_ID_RE.exec(body);
283
+ if (m) {
284
+ sessionId = m[1];
285
+ body = body.replace(SESSION_ID_RE, '');
286
+ }
278
287
  }
279
288
 
280
289
  const lines = [];
@@ -328,6 +337,7 @@ class HermesAdapter extends BaseAdapter {
328
337
  const proc = spawn(this._hermesBin, args, {
329
338
  env,
330
339
  stdio: ['ignore', 'pipe', 'pipe'],
340
+ cwd: this.workingDir,
331
341
  // No process group on Windows (can't signal one); windowsHide keeps a
332
342
  // console from flashing up.
333
343
  detached: !IS_WINDOWS,
@@ -358,7 +368,9 @@ class HermesAdapter extends BaseAdapter {
358
368
  throw new Error(`hermes exited with code ${exitCode}: ${detail}`);
359
369
  }
360
370
 
361
- const { text, sessionId } = this._parseHermesOutput(stdout);
371
+ // Hermes -Q mode: response body on stdout, session metadata
372
+ // (session_id:, resume banner) on stderr — parse both streams.
373
+ const { text, sessionId } = this._parseHermesOutput(stdout, stderr);
362
374
  if (sessionId) {
363
375
  this._channelSessions[channelName] = sessionId;
364
376
  this._saveSessions();
@@ -340,7 +340,9 @@ function classifyKimiError(info) {
340
340
  };
341
341
  }
342
342
 
343
- if (/auth_error|401|unauthorized|invalid.{0,20}(api.?key|token)|credential/i.test(detail)) {
343
+ // Bounded: a bare 401 matched request ids, and `invalid.{0,20}token` turned
344
+ // "Invalid value for max_tokens" into a bad key and a `kimi login` hint.
345
+ if (/auth_error|\b401\b|unauthorized|invalid[\s_-]*(api[\s_-]?key|token|authentication)\b|credential/i.test(detail)) {
344
346
  return {
345
347
  kind: 'auth',
346
348
  userMessage:
@@ -415,28 +415,35 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
415
415
  `\`${curl} -s -X POST -H "${h}" -H "Content-Type: application/json"` +
416
416
  ` ${baseUrl}/v1/browser/tabs` +
417
417
  ` -d '{"url":"URL","network":"${workspaceId}",` +
418
- `"source":"openagents:${agentName}"}'\`\n\n` +
418
+ `"source":"openagents:${agentName}"}'\`\n` +
419
+ 'Tabs are **temporary** by default (closed after a while idle). Add ' +
420
+ '`"persistent":true` (and optionally `"name":"…"`) to open a **permanent** ' +
421
+ 'tab whose login state is saved and reusable — humans see the two kinds ' +
422
+ 'separately in their browser. Permanent slots are scarcer: use them only ' +
423
+ 'for logins worth keeping.\n\n' +
419
424
  '**Navigate:**\n' +
420
425
  `\`${curl} -s -X POST -H "${h}" -H "Content-Type: application/json"` +
421
426
  ` ${baseUrl}/v1/browser/tabs/{tab_id}/navigate` +
422
- ` -d '{"url":"URL"}'\`\n\n` +
427
+ ` -d '{"url":"URL","source":"openagents:${agentName}"}'\`\n\n` +
423
428
  '**Click element:**\n' +
424
429
  `\`${curl} -s -X POST -H "${h}" -H "Content-Type: application/json"` +
425
430
  ` ${baseUrl}/v1/browser/tabs/{tab_id}/click` +
426
- ` -d '{"selector":"CSS_SELECTOR"}'\`\n\n` +
431
+ ` -d '{"selector":"CSS_SELECTOR","source":"openagents:${agentName}"}'\`\n\n` +
427
432
  '**Type text:**\n' +
428
433
  `\`${curl} -s -X POST -H "${h}" -H "Content-Type: application/json"` +
429
434
  ` ${baseUrl}/v1/browser/tabs/{tab_id}/type` +
430
- ` -d '{"selector":"CSS_SELECTOR","text":"TEXT"}'\`\n` +
435
+ ` -d '{"selector":"CSS_SELECTOR","text":"TEXT","source":"openagents:${agentName}"}'\`\n` +
431
436
  '(add `"append":true` to keep existing text instead of replacing it)\n\n' +
432
437
  '**Press a key** (e.g. submit a form with Enter):\n' +
433
438
  `\`${curl} -s -X POST -H "${h}" -H "Content-Type: application/json"` +
434
439
  ` ${baseUrl}/v1/browser/tabs/{tab_id}/press_key` +
435
- ` -d '{"key":"Enter"}'\`\n\n` +
440
+ ` -d '{"key":"Enter","source":"openagents:${agentName}"}'\`\n\n` +
436
441
  '**Run JavaScript** in the page (returns the result):\n' +
437
442
  `\`${curl} -s -X POST -H "${h}" -H "Content-Type: application/json"` +
438
443
  ` ${baseUrl}/v1/browser/tabs/{tab_id}/evaluate` +
439
- ` -d '{"expression":"document.title"}'\`\n\n` +
444
+ ` -d '{"expression":"document.title","source":"openagents:${agentName}"}'\`\n` +
445
+ 'The `source` field is how humans watching the shared browser see *who* is ' +
446
+ 'acting — include it on every tab action.\n\n' +
440
447
  '**Close tab:**\n' +
441
448
  `\`${curl} -s -X DELETE -H "${h}" ${baseUrl}/v1/browser/tabs/{tab_id}\`\n\n` +
442
449
  '**Clicks/typing use CSS selectors only** (no pixel coordinates). If a ' +
package/src/agent-rows.js CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  'use strict';
9
9
 
10
+ const { isCliLogin } = require('./env');
11
+
10
12
  /**
11
13
  * Build one display row per configured agent from a connector.
12
14
  *
@@ -39,7 +41,7 @@ function loadAgentRows(connector) {
39
41
  let notReadyMsg = '';
40
42
  let health = null;
41
43
  try {
42
- health = connector.healthCheck(agent.type || 'openclaw');
44
+ health = connector.healthCheck(agent.type || 'openclaw', { cliLogin: isCliLogin(agent.env), agentEnv: agent.env || {} });
43
45
  if (health && !health.ready) {
44
46
  // "Not installed" only when the executable is genuinely missing; an
45
47
  // installed-but-signed-out agent shows its login/config message, never
package/src/cli.js CHANGED
@@ -582,7 +582,17 @@ async function cmdNode(connector, flags, positional) {
582
582
  process.exitCode = 1;
583
583
  return;
584
584
  }
585
- const info = nodeCfg.gatherDeviceInfo();
585
+ let info;
586
+ try {
587
+ info = nodeCfg.gatherDeviceInfo();
588
+ } catch (e) {
589
+ // Pairing on top of an unreadable node.json would replace the pairings
590
+ // it holds; stop instead, so nothing is lost while it is sorted out.
591
+ print(`Could not read ${nodeCfg.NODE_FILE}: ${e.message}`);
592
+ print('Close anything that may have it open and try again.');
593
+ process.exitCode = 1;
594
+ return;
595
+ }
586
596
  if (flags.type) info.deviceType = flags.type; // server | laptop | desktop
587
597
  if (flags.name) info.name = flags.name;
588
598
 
@@ -985,14 +995,16 @@ async function cmdProbe(connector, flags, positional) {
985
995
  const target = positional[0];
986
996
  if (!target) { print('Usage: agn probe <type|agent> [--json]'); return; }
987
997
 
988
- // Accept an agent name as a convenience — probe its type.
998
+ // Accept an agent name — probe its type on that agent's own env, so a
999
+ // signed-in agent is tested on its sign-in rather than the type's key.
989
1000
  let type = target;
1001
+ let agentEnv;
990
1002
  try {
991
1003
  const agent = connector.config.getAgent(target);
992
- if (agent) type = agent.type || 'openclaw';
1004
+ if (agent) { type = agent.type || 'openclaw'; agentEnv = agent.env || {}; }
993
1005
  } catch {}
994
1006
 
995
- const result = await connector.probeAgentType(type);
1007
+ const result = await connector.probeAgentType(type, { agentEnv });
996
1008
 
997
1009
  if (flags && flags.json) {
998
1010
  // Machine consumers (the daemon's probe_agent node command) read ONLY
package/src/daemon.js CHANGED
@@ -10,6 +10,7 @@ const { spawn } = require('./wsl');
10
10
  const os = require('os');
11
11
  const { WorkspaceClient } = require('./workspace-client');
12
12
  const { listEndpointModels } = require('./model-list');
13
+ const { AUTH_MODE_KEY, isCliLogin, stripForCliLogin, typeEnvFor } = require('./env');
13
14
  const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
14
15
 
15
16
  /**
@@ -72,6 +73,28 @@ function endpointHost(value) {
72
73
  * Compatible with the Python SDK's daemon — reads the same config files,
73
74
  * writes the same status format, and supports the same command protocol.
74
75
  */
76
+ /**
77
+ * Whether a node command carries a key for the agent. A blank key is no key:
78
+ * saving one would leave the agent not-ready, and clearing the sign-in
79
+ * marker on its account would flip a signed-in agent to key mode with
80
+ * nothing to run on. `useDeviceCredentials` means the device already holds
81
+ * the key — nothing to write.
82
+ */
83
+ function _sentKey(args) {
84
+ const key = args && args.apiKey;
85
+ const present = typeof key === 'string' ? key.trim().length > 0 : !!key;
86
+ return present && !args.useDeviceCredentials;
87
+ }
88
+
89
+ /**
90
+ * Whether a node heartbeat failure is the workspace saying the node is gone:
91
+ * a 404 carrying the backend's own message (routers/nodes.py), not any 404.
92
+ */
93
+ const NODE_GONE_MESSAGES = new Set(['Node not found', 'Workspace not found']);
94
+ function _nodeGone(err) {
95
+ return !!err && err.status === 404 && NODE_GONE_MESSAGES.has(err.message);
96
+ }
97
+
75
98
  class Daemon {
76
99
  constructor(config, envManager, registry) {
77
100
  this.config = config;
@@ -139,7 +162,9 @@ class Daemon {
139
162
  const type = agent.type || 'openclaw';
140
163
  this._probeInFlight.add(name);
141
164
  try {
142
- const r = await this._runAgn(['probe', type, '--json']);
165
+ // By agent name, not type: the probe runs on this agent's own env, so a
166
+ // signed-in agent is tested on its sign-in, not the type's saved key.
167
+ const r = await this._runAgn(['probe', name, '--json']);
143
168
  let parsed = null;
144
169
  try { parsed = JSON.parse(r.stdout.trim()); } catch {}
145
170
  if (!parsed || typeof parsed !== 'object') {
@@ -190,7 +215,9 @@ class Daemon {
190
215
  const nodeCfg = require('./node-config');
191
216
  const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
192
217
  if (!pairings.length) return; // no node connected
193
- const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimes || [], fs: this._buildFs() };
218
+ // deviceInfo, not gatherDeviceInfo: the heartbeat sends no device key, and
219
+ // must never be the thing that writes node.json.
220
+ const info = { ...nodeCfg.deviceInfo(), runtimes: this._runtimes || [], fs: this._buildFs() };
194
221
  await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
195
222
  }
196
223
 
@@ -248,8 +275,12 @@ class Daemon {
248
275
  // Without this the launcher kept showing "connected" long after a remote
249
276
  // removal, since node.json was only ever reconciled by the UI on demand.
250
277
  // Transient failures (timeouts, 5xx, auth blips) say nothing about the
251
- // row's existence, so those are swallowed and retried next tick.
252
- if (err && err.status === 404) {
278
+ // row's existence, so those are swallowed and retried next tick. So is a
279
+ // 404 the backend did not write: the hosting platform answers every
280
+ // request with its own 404 ("Application not found") while the service
281
+ // is unreachable, and taking that as the workspace's word unpaired every
282
+ // workspace on the device at once.
283
+ if (_nodeGone(err)) {
253
284
  try { nodeCfg.clearPairing(n.workspace_id); } catch {}
254
285
  this._nodeClients.delete(n.workspace_id);
255
286
  this._log(`node ${n.node_id} no longer recognized by workspace ${n.workspace_slug || n.workspace_id} — pairing cleared`);
@@ -347,7 +378,7 @@ class Daemon {
347
378
  // ever leaving the device. The endpoint goes out as a hostname, so
348
379
  // the workspace can tell a relay from the vendor its model list is for.
349
380
  let typeEnv = {};
350
- try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
381
+ try { typeEnv = typeEnvFor(a.type, this.envManager.load(a.type) || {}, this.registry, a.env); } catch {}
351
382
  const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
352
383
  const apiKey = (a.env && a.env.LLM_API_KEY) || typeEnv.LLM_API_KEY || null;
353
384
  roster.push({
@@ -372,9 +403,11 @@ class Daemon {
372
403
  * builds its env: LLM_BASE_URL or a provider variable (OPENAI_BASE_URL,
373
404
  * ANTHROPIC_BASE_URL, …) saved for it or mapped by resolve_env, and failing
374
405
  * those, the daemon's own environment for the provider variable this type's
375
- * LLM_BASE_URL maps to, which the spawned CLI inherits.
406
+ * LLM_BASE_URL maps to, which the spawned CLI inherits. A signed-in agent
407
+ * has none: _buildAgentEnv drops every saved endpoint for it.
376
408
  */
377
409
  _configuredBaseUrl(a, typeEnv) {
410
+ if (isCliLogin(a.env)) return null;
378
411
  const saved = { ...typeEnv, ...(a.env || {}) };
379
412
  let env = saved;
380
413
  try { env = { ...saved, ...this.envManager.resolve(a.type, saved, this.registry) }; } catch {}
@@ -470,7 +503,12 @@ class Daemon {
470
503
  */
471
504
  _saveNodeAgentEnv(name, type, args) {
472
505
  const changes = {};
473
- if (args.apiKey && !args.useDeviceCredentials) changes.LLM_API_KEY = args.apiKey;
506
+ if (_sentKey(args)) {
507
+ changes.LLM_API_KEY = args.apiKey;
508
+ // A key sent for the agent means it runs on that key: a sign-in marker
509
+ // left from the launcher would strip it again at launch.
510
+ changes[AUTH_MODE_KEY] = '';
511
+ }
474
512
  if (args.baseUrl !== undefined) changes.LLM_BASE_URL = args.baseUrl;
475
513
  if (args.model !== undefined) {
476
514
  const existing = this.config.getAgent(name);
@@ -585,7 +623,7 @@ async _runNodeCommand(n, cmd) {
585
623
  // Optional credentials for API-key agents (generic → provider mapping
586
624
  // happens in env resolution). `useDeviceCredentials` means the device
587
625
  // already holds them — nothing to write.
588
- if (args.apiKey && !args.useDeviceCredentials)
626
+ if (_sentKey(args))
589
627
  await this._runAgn(['env', type, '--set', `LLM_API_KEY=${args.apiKey}`]);
590
628
  if (args.model) await this._setModelEnv(type, args.model);
591
629
  if (args.baseUrl) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
@@ -622,7 +660,7 @@ async _runNodeCommand(n, cmd) {
622
660
  // restart it so the change takes effect. Working-dir changes require a
623
661
  // recreate (agn has no in-place path change) at the new path.
624
662
  const type = (args.type || '').trim();
625
- if (args.apiKey && !args.useDeviceCredentials)
663
+ if (_sentKey(args))
626
664
  await this._runAgn(['env', type, '--set', `LLM_API_KEY=${args.apiKey}`]);
627
665
  if (args.model !== undefined) await this._setModelEnv(type, args.model);
628
666
  if (args.baseUrl !== undefined) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
@@ -1519,11 +1557,9 @@ async _runNodeCommand(n, cmd) {
1519
1557
 
1520
1558
  _buildAgentEnv(agentCfg) {
1521
1559
  const type = agentCfg.type || 'openclaw';
1522
- const saved = this.envManager.load(type);
1523
- const mergedSaved = { ...saved, ...(agentCfg.env || {}) };
1524
- const resolved = this.envManager.resolve(type, mergedSaved, this.registry);
1525
- const merged = { ...mergedSaved, ...resolved };
1526
- return { ...process.env, ...merged };
1560
+ const merged = this.envManager.getForAgent(type, this.registry, agentCfg.env);
1561
+ // A signed-in agent drops every key, the launcher's own environment's too.
1562
+ return stripForCliLogin(type, { ...process.env, ...merged }, this.registry, agentCfg.env);
1527
1563
  }
1528
1564
 
1529
1565
  _agentConfigFingerprint(agentCfg) {
package/src/env.js CHANGED
@@ -112,6 +112,119 @@ class EnvManager {
112
112
  const resolved = this.resolve(agentType, saved, registry);
113
113
  return { ...saved, ...resolved };
114
114
  }
115
+
116
+ /**
117
+ * The saved env one agent runs on: the part of <type>.env it takes (see
118
+ * typeEnvFor) under its own env, resolved. The daemon launches the agent on
119
+ * this over its own environment, and readiness judges the agent on it too.
120
+ */
121
+ getForAgent(agentType, registry, agentEnv) {
122
+ const saved = { ...typeEnvFor(agentType, this.load(agentType), registry, agentEnv), ...(agentEnv || {}) };
123
+ const resolved = this.resolve(agentType, saved, registry);
124
+ return { ...saved, ...resolved };
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Set on an agent's own env when it signs in through its CLI (`codex login`,
130
+ * `claude auth login`) instead of a key. Keys stay shared per type in
131
+ * <type>.env and the launcher's own environment, and a CLI prefers a key it
132
+ * is handed over its account session, so without this an agent created on
133
+ * the sign-in tab still ran on whatever key was saved there earlier.
134
+ */
135
+ const AUTH_MODE_KEY = 'OPENAGENTS_AUTH_MODE';
136
+ const CLI_LOGIN = 'cli_login';
137
+
138
+ /**
139
+ * Whether the agent signs in through its CLI. Read from the agent's own env
140
+ * only: a marker in <type>.env or the launcher's environment would otherwise
141
+ * strip the key of every agent of that type, keyed ones included.
142
+ */
143
+ function isCliLogin(agentEnv) {
144
+ return !!agentEnv && agentEnv[AUTH_MODE_KEY] === CLI_LOGIN;
145
+ }
146
+
147
+ /**
148
+ * Keys and endpoints a CLI reads under a name of its own that no registry
149
+ * field or readiness check declares (the launcher's auth-specs.ts offers the
150
+ * endpoints). Named per type rather than by suffix: the env stripped is the
151
+ * daemon's whole environment, where *_BASE_URL also names endpoints that are
152
+ * not the model's. CODEX_API_KEY: the Codex CLI honors it over its ChatGPT
153
+ * sign-in, so one set system-wide would defeat the sign-in just as
154
+ * OPENAI_API_KEY does.
155
+ */
156
+ const NATIVE_CREDENTIALS = {
157
+ codex: ['CODEX_API_KEY'],
158
+ gemini: ['GOOGLE_GEMINI_BASE_URL'],
159
+ antigravity: ['GOOGLE_GEMINI_BASE_URL'],
160
+ };
161
+
162
+ /**
163
+ * The variables that carry a key or an endpoint for this agent type: its
164
+ * password and *_BASE_URL fields, what LLM_API_KEY / LLM_BASE_URL resolve to,
165
+ * and the *_API_KEY variables its readiness check accepts (Gemini has no
166
+ * fields at all, and reads GEMINI_API_KEY / GOOGLE_API_KEY). Only the
167
+ * *_API_KEY ones: that list also holds models and tokens other tools share
168
+ * (MSWEA_MODEL_NAME, GH_TOKEN). The model is not among them, it applies to a
169
+ * sign-in as well. So is
170
+ * CLAUDE_CODE_OAUTH_TOKEN: `claude setup-token` makes it from the account
171
+ * sign-in, so it is how a signed-in claude authenticates, not a key to drop.
172
+ */
173
+ function credentialKeys(agentType, registry) {
174
+ const keys = new Set(['LLM_API_KEY', 'LLM_BASE_URL', ...(NATIVE_CREDENTIALS[agentType] || [])]);
175
+ if (!registry) return keys;
176
+ for (const field of registry.getEnvFields?.(agentType) || []) {
177
+ if (field.password || /BASE_URL$/.test(field.name || '')) keys.add(field.name);
178
+ }
179
+ for (const rule of registry.getResolveRules?.(agentType) || []) {
180
+ if (keys.has(rule.from) && rule.to) keys.add(rule.to);
181
+ }
182
+ const checkReady = registry.getEntry?.(agentType)?.check_ready;
183
+ for (const name of (checkReady && checkReady.env_vars) || []) {
184
+ if (/_API_KEY$/.test(name)) keys.add(name);
185
+ }
186
+ return keys;
187
+ }
188
+
189
+ /**
190
+ * The variables that pick the model: LLM_MODEL, what it resolves to
191
+ * (CODEX_MODEL, ANTHROPIC_MODEL), and a type's own *_MODEL fields.
192
+ */
193
+ function modelKeys(agentType, registry) {
194
+ const keys = new Set(['LLM_MODEL']);
195
+ if (!registry) return keys;
196
+ for (const field of registry.getEnvFields?.(agentType) || []) {
197
+ if (/_MODEL(_NAME)?$/.test(field.name || '')) keys.add(field.name);
198
+ }
199
+ for (const rule of registry.getResolveRules?.(agentType) || []) {
200
+ if (rule.from === 'LLM_MODEL' && rule.to) keys.add(rule.to);
201
+ }
202
+ return keys;
203
+ }
204
+
205
+ /**
206
+ * The part of <type>.env an agent runs on. All of it for an agent on a key.
207
+ * A signed-in agent takes none of its keys, and none of its model either:
208
+ * that model was picked for the key's endpoint (a relay's
209
+ * openai-gpt-oss-20b), which the account behind a sign-in does not serve.
210
+ * Its model is the one in its own env, else the CLI's default.
211
+ */
212
+ function typeEnvFor(agentType, typeEnv, registry, agentEnv) {
213
+ if (!isCliLogin(agentEnv)) return typeEnv;
214
+ const out = stripForCliLogin(agentType, typeEnv, registry, agentEnv);
215
+ for (const key of modelKeys(agentType, registry)) delete out[key];
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * `env` with every credential removed when the agent's own env (`agentEnv`)
221
+ * marks a sign-in, or unchanged for an agent that runs on a key.
222
+ */
223
+ function stripForCliLogin(agentType, env, registry, agentEnv) {
224
+ if (!isCliLogin(agentEnv)) return env;
225
+ const out = { ...env };
226
+ for (const key of credentialKeys(agentType, registry)) delete out[key];
227
+ return out;
115
228
  }
116
229
 
117
230
  /**
@@ -128,4 +241,6 @@ function normalizeAnthropicBase(url) {
128
241
  return String(url || '').trim().replace(/\/+$/, '').replace(/\/v1$/i, '');
129
242
  }
130
243
 
131
- module.exports = { EnvManager };
244
+ module.exports = {
245
+ EnvManager, AUTH_MODE_KEY, CLI_LOGIN, isCliLogin, credentialKeys, modelKeys, stripForCliLogin, typeEnvFor,
246
+ };
package/src/index.js CHANGED
@@ -69,8 +69,8 @@ class AgentConnector {
69
69
  return this.installer.isInstalled(agentType);
70
70
  }
71
71
 
72
- healthCheck(agentType) {
73
- return this.installer.healthCheck(agentType);
72
+ healthCheck(agentType, opts) {
73
+ return this.installer.healthCheck(agentType, opts);
74
74
  }
75
75
 
76
76
  // -- Agent CRUD --
package/src/installer.js CHANGED
@@ -512,7 +512,15 @@ class Installer {
512
512
  * Health check — binary existence + version.
513
513
  * @returns {{ installed: boolean, binary: string|null, version: string|null }}
514
514
  */
515
- healthCheck(agentType) {
515
+ /**
516
+ * `opts.cliLogin` checks one agent that signs in through its CLI: the core
517
+ * drops every key for it at launch, so a key saved for the type or set in
518
+ * the daemon's environment must not make it ready.
519
+ *
520
+ * `opts.agentEnv` is that agent's own env. With it the agent is judged on
521
+ * the env the daemon launches it on, not on <type>.env alone.
522
+ */
523
+ healthCheck(agentType, opts = {}) {
516
524
  const entry = this.registry.getEntry(agentType);
517
525
  if (entry?.install?.api_only) {
518
526
  const info = this.getInstallInfo(agentType);
@@ -532,7 +540,7 @@ class Installer {
532
540
  };
533
541
  }
534
542
 
535
- const apiReadiness = this._evaluateReadiness(agentType, entry, null);
543
+ const apiReadiness = this._evaluateReadiness(agentType, entry, null, opts);
536
544
  return {
537
545
  installed: true,
538
546
  binary: null,
@@ -568,7 +576,7 @@ class Installer {
568
576
  const versionCmd = checkCmd || this._versionProbeCommand(binary);
569
577
 
570
578
  const version = this._detectVersion(binary, versionCmd);
571
- const readiness = this._evaluateReadiness(agentType, entry, binary);
579
+ const readiness = this._evaluateReadiness(agentType, entry, binary, opts);
572
580
 
573
581
  // Generic minimum-version gate (no per-agent special-casing). An entry opts
574
582
  // in by declaring `install.min_version`. Below the floor we keep
@@ -776,7 +784,7 @@ class Installer {
776
784
  return { compatible: true, minVersion, message: null };
777
785
  }
778
786
 
779
- _evaluateReadiness(agentType, entry, binary) {
787
+ _evaluateReadiness(agentType, entry, binary, opts = {}) {
780
788
  const checkReady = entry?.check_ready;
781
789
  if (!checkReady) {
782
790
  return {
@@ -788,10 +796,16 @@ class Installer {
788
796
  };
789
797
  }
790
798
 
791
- const savedEnv = this.env.getEffective(agentType, this.registry);
799
+ // A key set on the agent itself (the workspace saves it there, as
800
+ // LLM_API_KEY) never reaches <type>.env, so judged on that file alone a
801
+ // Kimi agent that answered fine read "Not configured — … run: kimi login".
802
+ const savedEnv = opts.agentEnv
803
+ ? this.env.getForAgent(agentType, this.registry, opts.agentEnv)
804
+ : this.env.getEffective(agentType, this.registry);
792
805
  const directEnv = this._hasAllValues(process.env, checkReady.env_all);
793
806
  const directSaved = this._hasAllValues(savedEnv, checkReady.saved_env_all || checkReady.env_all);
794
- const directReady = directEnv || directSaved;
807
+ const keyed = !opts.cliLogin;
808
+ const directReady = keyed && (directEnv || directSaved);
795
809
  // env_vars must be satisfiable by the SAVED (and resolved) per-agent env,
796
810
  // not only the daemon's own process env. The workspace configure flow
797
811
  // saves LLM_API_KEY, which resolve rules map to e.g. ANTHROPIC_AUTH_TOKEN
@@ -799,10 +813,10 @@ class Installer {
799
813
  // alone made a perfectly working key-configured agent read "Not logged
800
814
  // in" (health banner + failed smoke test) while chat answered fine, with
801
815
  // guidance telling the user to configure the very key they had saved.
802
- const envAnyReady =
816
+ const envAnyReady = keyed && (
803
817
  this._hasAnyValue(process.env, checkReady.env_vars) ||
804
- this._hasAnyValue(savedEnv, checkReady.env_vars);
805
- const savedAnyReady = !!(checkReady.saved_env_key && savedEnv[checkReady.saved_env_key]);
818
+ this._hasAnyValue(savedEnv, checkReady.env_vars));
819
+ const savedAnyReady = keyed && !!(checkReady.saved_env_key && savedEnv[checkReady.saved_env_key]);
806
820
  // A registry entry may require a non-empty JSON object rather than mere
807
821
  // parseability. In that mode the stricter _evaluateCredsFile path below is
808
822
  // authoritative; the legacy parser treats an empty {} as ready.
@@ -16,30 +16,64 @@ const crypto = require('crypto');
16
16
  const NODE_DIR = path.join(os.homedir(), '.openagents');
17
17
  const NODE_FILE = path.join(NODE_DIR, 'node.json');
18
18
 
19
- function loadNode() {
19
+ /**
20
+ * The record on disk: null when there is no file, and a throw when there is
21
+ * one that cannot be read or parsed (half-written by another process, or held
22
+ * by an antivirus scan on Windows). Every writer reads through this, so an
23
+ * unreadable file is left alone rather than rebuilt from nothing — which
24
+ * dropped every pairing and minted a new device key.
25
+ */
26
+ function readNode() {
27
+ let raw;
20
28
  try {
21
- if (fs.existsSync(NODE_FILE)) return JSON.parse(fs.readFileSync(NODE_FILE, 'utf-8'));
22
- } catch {}
23
- return null;
29
+ raw = fs.readFileSync(NODE_FILE, 'utf-8');
30
+ } catch (err) {
31
+ if (err && err.code === 'ENOENT') return null;
32
+ throw err;
33
+ }
34
+ return JSON.parse(raw);
24
35
  }
25
36
 
37
+ /** Lenient read for display and heartbeat: an unreadable file counts as none. */
38
+ function loadNode() {
39
+ try { return readNode(); } catch { return null; }
40
+ }
41
+
42
+ /**
43
+ * Written through a temp file and a rename, so a reader in the other process
44
+ * (launcher or daemon) sees the old record or the new one, never a torn one.
45
+ */
26
46
  function saveNode(data) {
27
47
  try {
28
48
  fs.mkdirSync(NODE_DIR, { recursive: true });
29
- fs.writeFileSync(NODE_FILE, JSON.stringify(data, null, 2));
30
- try { fs.chmodSync(NODE_FILE, 0o600); } catch {} // holds the workspace token
49
+ const tmp = `${NODE_FILE}.${process.pid}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); // holds the workspace token
51
+ try {
52
+ fs.renameSync(tmp, NODE_FILE);
53
+ } catch {
54
+ // Windows refuses the rename while another process holds the target.
55
+ try { fs.unlinkSync(tmp); } catch {}
56
+ fs.writeFileSync(NODE_FILE, JSON.stringify(data, null, 2));
57
+ }
58
+ try { fs.chmodSync(NODE_FILE, 0o600); } catch {}
31
59
  } catch {}
32
60
  }
33
61
 
34
- /** Stable per-device id, generated once and persisted. */
62
+ /** Stable per-device id, generated once and persisted. Throws if node.json is unreadable. */
35
63
  function getOrCreateNodeKey() {
36
- const existing = loadNode();
64
+ const existing = readNode();
37
65
  if (existing && existing.node_key) return existing.node_key;
38
66
  const key = (crypto.randomUUID && crypto.randomUUID()) || crypto.randomBytes(16).toString('hex');
39
67
  saveNode({ ...(existing || {}), node_key: key });
40
68
  return key;
41
69
  }
42
70
 
71
+ function pairingsOf(record) {
72
+ if (!record) return [];
73
+ if (record.pairings && record.pairings.length) return record.pairings;
74
+ return record.workspace_id ? [record] : [];
75
+ }
76
+
43
77
  /**
44
78
  * Every workspace this device is paired to, most recent first.
45
79
  *
@@ -49,10 +83,7 @@ function getOrCreateNodeKey() {
49
83
  * existed carry a single top-level pairing, which is reconstructed here.
50
84
  */
51
85
  function listPairings() {
52
- const record = loadNode();
53
- if (!record) return [];
54
- if (record.pairings && record.pairings.length) return record.pairings;
55
- return record.workspace_id ? [record] : [];
86
+ return pairingsOf(loadNode());
56
87
  }
57
88
 
58
89
  /**
@@ -67,7 +98,7 @@ function recordPairing(nodeKey, pairing) {
67
98
  const stamped = { ...pairing, paired_at: new Date().toISOString() };
68
99
  const pairings = [
69
100
  stamped,
70
- ...listPairings().filter((p) => p.workspace_id !== pairing.workspace_id),
101
+ ...pairingsOf(readNode()).filter((p) => p.workspace_id !== pairing.workspace_id),
71
102
  ];
72
103
  saveNode({ node_key: nodeKey, ...stamped, pairings });
73
104
  }
@@ -86,12 +117,12 @@ function recordPairing(nodeKey, pairing) {
86
117
  * @returns the pairing that was dropped, or null if there was no such pairing.
87
118
  */
88
119
  function clearPairing(workspaceId) {
89
- const record = loadNode();
120
+ const record = readNode();
90
121
  if (!record) return null;
91
122
  const target = workspaceId || record.workspace_id;
92
123
  if (!target) return null;
93
124
 
94
- const pairings = listPairings();
125
+ const pairings = pairingsOf(record);
95
126
  const dropped = pairings.find((p) => p.workspace_id === target);
96
127
  if (!dropped) return null;
97
128
 
@@ -114,12 +145,11 @@ function inferDeviceType() {
114
145
  return 'server'; // linux is typically a server/VM in this context
115
146
  }
116
147
 
117
- /** Device info sent on redeem/heartbeat. */
118
- function gatherDeviceInfo() {
148
+ /** Device info sent on every heartbeat. Reads and writes nothing on disk. */
149
+ function deviceInfo() {
119
150
  let launcherVersion = '';
120
151
  try { launcherVersion = require('../package.json').version; } catch {}
121
152
  return {
122
- nodeKey: getOrCreateNodeKey(),
123
153
  hostname: os.hostname(),
124
154
  os: os.platform(),
125
155
  deviceType: inferDeviceType(),
@@ -127,7 +157,13 @@ function gatherDeviceInfo() {
127
157
  };
128
158
  }
129
159
 
160
+ /** Device info sent on redeem, with the device key (minted on first pairing). */
161
+ function gatherDeviceInfo() {
162
+ return { nodeKey: getOrCreateNodeKey(), ...deviceInfo() };
163
+ }
164
+
130
165
  module.exports = {
166
+ readNode,
131
167
  loadNode,
132
168
  saveNode,
133
169
  listPairings,
@@ -135,6 +171,7 @@ module.exports = {
135
171
  clearPairing,
136
172
  getOrCreateNodeKey,
137
173
  inferDeviceType,
174
+ deviceInfo,
138
175
  gatherDeviceInfo,
139
176
  NODE_FILE,
140
177
  NODE_DIR,
package/src/probe.js CHANGED
@@ -33,6 +33,7 @@ const { spawn } = require('./wsl');
33
33
  const { getEnhancedEnv } = require('./paths');
34
34
  const { shouldUseShellForBinary } = require('./adapters/health-status');
35
35
  const { formatAuthGuidance } = require('./auth-guidance');
36
+ const { isCliLogin, stripForCliLogin, typeEnvFor } = require('./env');
36
37
 
37
38
  const PROBE_PROMPT = 'hi';
38
39
  const DEFAULT_TIMEOUT_MS = 90_000;
@@ -98,7 +99,14 @@ function authFlavor(entry, agentEnv) {
98
99
  const cr = (entry && entry.check_ready) || {};
99
100
  const envCfg = (entry && entry.env_config) || [];
100
101
  const hasKeyField = envCfg.some((f) => f && /key/i.test(f.name || ''));
101
- const hasSavedKey = !!(agentEnv && (agentEnv.LLM_API_KEY || agentEnv.OPENAI_API_KEY || agentEnv.ANTHROPIC_API_KEY));
102
+ // The type's own key names count too: a Kimi agent on KIMI_API_KEY whose
103
+ // key was rejected was told its sign-in had expired and to run kimi login.
104
+ const keyNames = [
105
+ 'LLM_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', cr.saved_env_key,
106
+ ...(cr.env_vars || []).filter((k) => /_API_KEY$/.test(k)),
107
+ ...envCfg.filter((f) => f && f.password).map((f) => f.name),
108
+ ];
109
+ const hasSavedKey = !!agentEnv && keyNames.some((k) => k && agentEnv[k]);
102
110
  if (hasSavedKey || (hasKeyField && !cr.login_command)) return 'api_key';
103
111
  if (cr.login_command) return 'cli_login';
104
112
  return hasKeyField ? 'api_key' : 'cli_login';
@@ -287,16 +295,22 @@ async function probeAgentType(connector, type, opts = {}) {
287
295
  return done({ ok: false, method: 'none', code: CODE.UNKNOWN_TYPE, message: `Unknown agent type '${type}'` });
288
296
  }
289
297
 
298
+ // A signed-in agent is ready on its sign-in alone: judged on the type, a
299
+ // key it will never be given made it "pass" with no login at all.
300
+ const cliLogin = isCliLogin(opts.agentEnv);
290
301
  let health = {};
291
- try { health = connector.healthCheck(type) || {}; } catch (e) {
302
+ try { health = connector.healthCheck(type, { cliLogin, agentEnv: opts.agentEnv }) || {}; } catch (e) {
292
303
  health = { installed: false, ready: false, message: e.message };
293
304
  }
294
305
 
306
+ // opts.agentEnv is one agent's own env, layered over the type's the way
307
+ // the daemon builds it; a signed-in agent then carries no key from either.
295
308
  let agentEnv = {};
296
309
  try {
297
- const saved = connector.getAgentEnv(type) || {};
310
+ const typeEnv = typeEnvFor(type, connector.getAgentEnv(type) || {}, connector.registry, opts.agentEnv);
311
+ const saved = { ...typeEnv, ...(opts.agentEnv || {}) };
298
312
  const resolved = connector.resolveAgentEnv(type, saved) || {};
299
- agentEnv = { ...saved, ...resolved };
313
+ agentEnv = stripForCliLogin(type, { ...saved, ...resolved }, connector.registry, opts.agentEnv);
300
314
  } catch {}
301
315
 
302
316
  if (!health.installed) {
@@ -321,7 +335,7 @@ async function probeAgentType(connector, type, opts = {}) {
321
335
 
322
336
  const timeoutMs = opts.timeoutMs
323
337
  || (entry.probe && entry.probe.timeout_s ? entry.probe.timeout_s * 1000 : DEFAULT_TIMEOUT_MS);
324
- const env = { ...getEnhancedEnv(), ...agentEnv };
338
+ const env = stripForCliLogin(type, { ...getEnhancedEnv(), ...agentEnv }, connector.registry, opts.agentEnv);
325
339
 
326
340
  // Hoisted above the CLI tier: a failing CLI probe consults it too (below).
327
341
  const hasApiKey = !!(agentEnv.LLM_API_KEY || agentEnv.OPENAI_API_KEY || agentEnv.ANTHROPIC_API_KEY
package/src/tui.js CHANGED
@@ -14,6 +14,7 @@ const fs = require('fs');
14
14
  const { spawn } = require('child_process');
15
15
  const { getExtraBinDirs } = require('./paths');
16
16
  const { hasCredentialMetadata, formatAuthGuidance } = require('./auth-guidance');
17
+ const { isCliLogin } = require('./env');
17
18
 
18
19
  const IS_WINDOWS = process.platform === 'win32';
19
20
 
@@ -774,7 +775,7 @@ function createTUI() {
774
775
  const checkReady = entry && entry.check_ready;
775
776
  if (hasCredentialMetadata(checkReady)) {
776
777
  let health = null;
777
- try { health = connector.healthCheck(agent.type); } catch {}
778
+ try { health = connector.healthCheck(agent.type, { cliLogin: isCliLogin(agent.env), agentEnv: agent.env || {} }); } catch {}
778
779
  const g = formatAuthGuidance(entry, health);
779
780
  const tag = g.ready ? 'green-fg' : 'yellow-fg';
780
781
  log(`{bold}{${tag}}${entry.label || agent.type} authentication: ${g.ready ? 'Ready' : 'Not ready'}{/}{/bold}`);