@openagents-org/agent-launcher 0.2.188 → 0.2.190

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.188",
3
+ "version": "0.2.190",
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": {
package/registry.json CHANGED
@@ -333,6 +333,10 @@
333
333
  "env_vars": [
334
334
  "CURSOR_API_KEY"
335
335
  ],
336
+ "creds_file": "~/.cursor/cli-config.json",
337
+ "creds_key": "authInfo",
338
+ "creds_no_parse": true,
339
+ "unverifiable": true,
336
340
  "login_command": "cursor-agent login"
337
341
  },
338
342
  "env_config": [
@@ -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:
@@ -31,6 +31,41 @@ const OPENCLAW_STATE_DIR = path.join(
31
31
  '.openclaw'
32
32
  );
33
33
 
34
+ /**
35
+ * Archive the legacy auth-profiles.json this adapter used to write.
36
+ *
37
+ * Current OpenClaw keeps credentials in SQLite and refuses to run while a
38
+ * legacy JSON store sits beside an empty one ("requires legacy credential
39
+ * migration; run openclaw doctor --fix"); beside a populated one it silently
40
+ * ignores the file, so a changed key never took effect either. The key now
41
+ * reaches OpenClaw through the env and openclaw.json, so the file only gets
42
+ * in the way.
43
+ *
44
+ * Only a file made entirely of our entries is moved: the `custom` provider
45
+ * exists only because configureNativeAuth created it, and any other entry must
46
+ * carry a key we hold ourselves. A profile the user made (OpenClaw's own
47
+ * paste-token also names them `<provider>:manual`) keeps the file in place,
48
+ * for `openclaw doctor --fix` to migrate. Renamed, never deleted.
49
+ *
50
+ * @param {Array<string|undefined>} knownKeys API keys this agent is configured with
51
+ */
52
+ function retireOwnLegacyAuthProfiles(knownKeys) {
53
+ const file = path.join(OPENCLAW_STATE_DIR, 'agents', 'main', 'agent', 'auth-profiles.json');
54
+ let data;
55
+ try { data = JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return false; }
56
+ const keys = new Set((knownKeys || []).filter(Boolean));
57
+ const profiles = Object.values((data && data.profiles) || {});
58
+ const ours = profiles.every((p) => p && p.type === 'token'
59
+ && (p.provider === 'custom' || keys.has(p.token)));
60
+ if (!ours) return false;
61
+ try {
62
+ fs.renameSync(file, `${file}.openagents-retired`);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
34
69
  class OpenClawAdapter extends BaseAdapter {
35
70
  /**
36
71
  * @param {object} opts - BaseAdapter opts plus:
@@ -54,6 +89,14 @@ class OpenClawAdapter extends BaseAdapter {
54
89
  this._log('OpenClaw binary not found — agent will not be able to process messages');
55
90
  }
56
91
 
92
+ // Agents configured before the key stopped going into auth-profiles.json
93
+ // still have that file; clear it before the first run rather than on the
94
+ // next save.
95
+ const env = this.agentEnv || {};
96
+ if (retireOwnLegacyAuthProfiles([env.LLM_API_KEY, env.OPENAI_API_KEY, env.ANTHROPIC_API_KEY])) {
97
+ this._log('Archived the legacy auth-profiles.json this launcher wrote (OpenClaw now stores credentials in SQLite)');
98
+ }
99
+
57
100
  // Install workspace skill
58
101
  this._installWorkspaceSkill();
59
102
  }
@@ -586,9 +629,10 @@ class OpenClawAdapter extends BaseAdapter {
586
629
  * LLM_API_KEY / LLM_BASE_URL / LLM_MODEL values.
587
630
  * Called by the Launcher's saveAgentEnv when type === 'openclaw'.
588
631
  *
589
- * For standard providers (OpenAI, Anthropic), uses auth-profiles.json.
590
- * For custom endpoints, uses models.providers in openclaw.json which
591
- * gives full tool support via the CLI gateway mode.
632
+ * For standard providers (OpenAI, Anthropic), the key travels in the env
633
+ * and only the model is set here. For custom endpoints, uses
634
+ * models.providers in openclaw.json which gives full tool support via the
635
+ * CLI gateway mode.
592
636
  */
593
637
  static configureNativeAuth(env) {
594
638
  const apiKey = env.LLM_API_KEY;
@@ -602,26 +646,19 @@ class OpenClawAdapter extends BaseAdapter {
602
646
  const isAnthropic = baseUrl.includes('api.anthropic.com');
603
647
  const configFile = path.join(OPENCLAW_STATE_DIR, 'openclaw.json');
604
648
 
649
+ // The key itself never goes into auth-profiles.json any more: current
650
+ // OpenClaw keeps credentials in SQLite and refuses to start while a legacy
651
+ // JSON store sits beside an empty one ("requires legacy credential
652
+ // migration; run openclaw doctor --fix"). A standard provider reads the
653
+ // key from the OPENAI_API_KEY / ANTHROPIC_API_KEY the resolve rules put
654
+ // in its env; a custom one from models.providers below.
655
+ retireOwnLegacyAuthProfiles([apiKey]);
656
+
605
657
  if (isOpenAI || isAnthropic) {
606
- // Standard provider — use auth-profiles.json
607
658
  const provider = isAnthropic ? 'anthropic' : 'openai';
608
- const profileId = `${provider}:manual`;
609
- const agentDir = path.join(OPENCLAW_STATE_DIR, 'agents', 'main', 'agent');
610
-
611
- try {
612
- fs.mkdirSync(agentDir, { recursive: true });
613
- const authFile = path.join(agentDir, 'auth-profiles.json');
614
- let authData = { version: 1, profiles: {} };
615
- try { authData = JSON.parse(fs.readFileSync(authFile, 'utf-8')); } catch {}
616
- authData.profiles = authData.profiles || {};
617
- authData.profiles[profileId] = { type: 'token', provider, token: apiKey };
618
- authData.lastGood = authData.lastGood || {};
619
- authData.lastGood[provider] = profileId;
620
- fs.writeFileSync(authFile, JSON.stringify(authData, null, 2), 'utf-8');
621
- } catch {}
622
-
623
659
  // Set model
624
660
  try {
661
+ fs.mkdirSync(OPENCLAW_STATE_DIR, { recursive: true });
625
662
  let config = {};
626
663
  try { config = JSON.parse(fs.readFileSync(configFile, 'utf-8')); } catch {}
627
664
  config.agents = config.agents || {};
@@ -653,22 +690,9 @@ class OpenClawAdapter extends BaseAdapter {
653
690
 
654
691
  fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf-8');
655
692
  } catch {}
656
-
657
- // Also write auth-profiles.json for the custom provider
658
- try {
659
- const agentDir = path.join(OPENCLAW_STATE_DIR, 'agents', 'main', 'agent');
660
- fs.mkdirSync(agentDir, { recursive: true });
661
- const authFile = path.join(agentDir, 'auth-profiles.json');
662
- let authData = { version: 1, profiles: {} };
663
- try { authData = JSON.parse(fs.readFileSync(authFile, 'utf-8')); } catch {}
664
- authData.profiles = authData.profiles || {};
665
- authData.profiles['custom:manual'] = { type: 'token', provider: 'custom', token: apiKey };
666
- authData.lastGood = authData.lastGood || {};
667
- authData.lastGood.custom = 'custom:manual';
668
- fs.writeFileSync(authFile, JSON.stringify(authData, null, 2), 'utf-8');
669
- } catch {}
670
693
  }
671
694
  }
672
695
  }
673
696
 
674
697
  module.exports = OpenClawAdapter;
698
+ module.exports.retireOwnLegacyAuthProfiles = retireOwnLegacyAuthProfiles;
@@ -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
@@ -985,14 +985,16 @@ async function cmdProbe(connector, flags, positional) {
985
985
  const target = positional[0];
986
986
  if (!target) { print('Usage: agn probe <type|agent> [--json]'); return; }
987
987
 
988
- // Accept an agent name as a convenience — probe its type.
988
+ // Accept an agent name — probe its type on that agent's own env, so a
989
+ // signed-in agent is tested on its sign-in rather than the type's key.
989
990
  let type = target;
991
+ let agentEnv;
990
992
  try {
991
993
  const agent = connector.config.getAgent(target);
992
- if (agent) type = agent.type || 'openclaw';
994
+ if (agent) { type = agent.type || 'openclaw'; agentEnv = agent.env || {}; }
993
995
  } catch {}
994
996
 
995
- const result = await connector.probeAgentType(type);
997
+ const result = await connector.probeAgentType(type, { agentEnv });
996
998
 
997
999
  if (flags && flags.json) {
998
1000
  // 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,19 @@ 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
+
75
89
  class Daemon {
76
90
  constructor(config, envManager, registry) {
77
91
  this.config = config;
@@ -139,7 +153,9 @@ class Daemon {
139
153
  const type = agent.type || 'openclaw';
140
154
  this._probeInFlight.add(name);
141
155
  try {
142
- const r = await this._runAgn(['probe', type, '--json']);
156
+ // By agent name, not type: the probe runs on this agent's own env, so a
157
+ // signed-in agent is tested on its sign-in, not the type's saved key.
158
+ const r = await this._runAgn(['probe', name, '--json']);
143
159
  let parsed = null;
144
160
  try { parsed = JSON.parse(r.stdout.trim()); } catch {}
145
161
  if (!parsed || typeof parsed !== 'object') {
@@ -347,7 +363,7 @@ class Daemon {
347
363
  // ever leaving the device. The endpoint goes out as a hostname, so
348
364
  // the workspace can tell a relay from the vendor its model list is for.
349
365
  let typeEnv = {};
350
- try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
366
+ try { typeEnv = typeEnvFor(a.type, this.envManager.load(a.type) || {}, this.registry, a.env); } catch {}
351
367
  const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
352
368
  const apiKey = (a.env && a.env.LLM_API_KEY) || typeEnv.LLM_API_KEY || null;
353
369
  roster.push({
@@ -372,9 +388,11 @@ class Daemon {
372
388
  * builds its env: LLM_BASE_URL or a provider variable (OPENAI_BASE_URL,
373
389
  * ANTHROPIC_BASE_URL, …) saved for it or mapped by resolve_env, and failing
374
390
  * those, the daemon's own environment for the provider variable this type's
375
- * LLM_BASE_URL maps to, which the spawned CLI inherits.
391
+ * LLM_BASE_URL maps to, which the spawned CLI inherits. A signed-in agent
392
+ * has none: _buildAgentEnv drops every saved endpoint for it.
376
393
  */
377
394
  _configuredBaseUrl(a, typeEnv) {
395
+ if (isCliLogin(a.env)) return null;
378
396
  const saved = { ...typeEnv, ...(a.env || {}) };
379
397
  let env = saved;
380
398
  try { env = { ...saved, ...this.envManager.resolve(a.type, saved, this.registry) }; } catch {}
@@ -470,7 +488,12 @@ class Daemon {
470
488
  */
471
489
  _saveNodeAgentEnv(name, type, args) {
472
490
  const changes = {};
473
- if (args.apiKey && !args.useDeviceCredentials) changes.LLM_API_KEY = args.apiKey;
491
+ if (_sentKey(args)) {
492
+ changes.LLM_API_KEY = args.apiKey;
493
+ // A key sent for the agent means it runs on that key: a sign-in marker
494
+ // left from the launcher would strip it again at launch.
495
+ changes[AUTH_MODE_KEY] = '';
496
+ }
474
497
  if (args.baseUrl !== undefined) changes.LLM_BASE_URL = args.baseUrl;
475
498
  if (args.model !== undefined) {
476
499
  const existing = this.config.getAgent(name);
@@ -585,7 +608,7 @@ async _runNodeCommand(n, cmd) {
585
608
  // Optional credentials for API-key agents (generic → provider mapping
586
609
  // happens in env resolution). `useDeviceCredentials` means the device
587
610
  // already holds them — nothing to write.
588
- if (args.apiKey && !args.useDeviceCredentials)
611
+ if (_sentKey(args))
589
612
  await this._runAgn(['env', type, '--set', `LLM_API_KEY=${args.apiKey}`]);
590
613
  if (args.model) await this._setModelEnv(type, args.model);
591
614
  if (args.baseUrl) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
@@ -622,7 +645,7 @@ async _runNodeCommand(n, cmd) {
622
645
  // restart it so the change takes effect. Working-dir changes require a
623
646
  // recreate (agn has no in-place path change) at the new path.
624
647
  const type = (args.type || '').trim();
625
- if (args.apiKey && !args.useDeviceCredentials)
648
+ if (_sentKey(args))
626
649
  await this._runAgn(['env', type, '--set', `LLM_API_KEY=${args.apiKey}`]);
627
650
  if (args.model !== undefined) await this._setModelEnv(type, args.model);
628
651
  if (args.baseUrl !== undefined) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
@@ -1519,11 +1542,9 @@ async _runNodeCommand(n, cmd) {
1519
1542
 
1520
1543
  _buildAgentEnv(agentCfg) {
1521
1544
  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 };
1545
+ const merged = this.envManager.getForAgent(type, this.registry, agentCfg.env);
1546
+ // A signed-in agent drops every key, the launcher's own environment's too.
1547
+ return stripForCliLogin(type, { ...process.env, ...merged }, this.registry, agentCfg.env);
1527
1548
  }
1528
1549
 
1529
1550
  _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.
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}`);