@love-moon/conductor-cli 0.9.0 → 0.10.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 43c4f87: Runtime health preflight and agent schedule access control. The daemon now
8
+ advertises positive backend runtime health (`x-conductor-runtime-health`) so the
9
+ backend can reject task creation with `503 runtime_unavailable` before any
10
+ timeline activity, and adds `disable_built_in_cli_list` to opt out of built-in
11
+ SDK backends. The SDK attributes agent-originated scheduled-message calls with
12
+ `X-Conductor-Actor: agent` so per-task `agent_schedule_access`
13
+ (full/read_only/blocked) can govern `conductor task schedule` from agents while
14
+ human/UI calls stay unrestricted.
15
+
16
+ ### Patch Changes
17
+
18
+ - 43c4f87: Fix `conductor fire` and `conductor diagnose` with an explicit `--config-file`
19
+ resolving `agent_token`/`backend_url` from daemon-injected `CONDUCTOR_*` env
20
+ instead of the file. Fire could dial the file's websocket with the inherited
21
+ token and loop on `4002 invalid-token`; diagnose queried the wrong backend and
22
+ 404'd. An explicit config file now wins over inherited env, matching the
23
+ daemon's behavior.
24
+ - 43c4f87: Fix `conductor update` installing into the wrong npm prefix.
25
+ - Updated dependencies [43c4f87]
26
+ - Updated dependencies [43c4f87]
27
+ - @love-moon/ai-sdk@0.10.0
28
+ - @love-moon/conductor-sdk@0.10.0
29
+
3
30
  ## 0.9.0
4
31
 
5
32
  ### Minor Changes
@@ -319,6 +319,16 @@ async function main() {
319
319
  });
320
320
  }
321
321
 
322
+ lines.push(
323
+ "",
324
+ "# Uncomment to disable built-in SDK backends that are otherwise advertised",
325
+ "# automatically (copilot, dsh). Listing a name here hides it from the",
326
+ "# daemon's supported backends without removing the shipped dependency.",
327
+ "# disable_built_in_cli_list:",
328
+ "# - dsh",
329
+ "# - copilot"
330
+ );
331
+
322
332
  lines.push(
323
333
  "",
324
334
  "# Uncomment to use custom envs, such as proxy.",
@@ -6,6 +6,8 @@ import { hideBin } from "yargs/helpers";
6
6
 
7
7
  import { loadConfig } from "@love-moon/conductor-sdk";
8
8
 
9
+ import { envForExplicitConfigFile } from "../src/config-env.js";
10
+
9
11
  const CLI_NAME = process.env.CONDUCTOR_CLI_NAME || "conductor diagnose";
10
12
  const DEFAULT_TIMEOUT_MS = 8000;
11
13
 
@@ -45,7 +47,7 @@ main().catch((error) => {
45
47
  });
46
48
 
47
49
  async function main() {
48
- const config = loadConfig(args.configFile);
50
+ const config = loadConfig(args.configFile, { env: envForExplicitConfigFile(args.configFile) });
49
51
  const timeoutMs = normalizePositiveInt(args.timeoutMs, DEFAULT_TIMEOUT_MS);
50
52
  const baseUrl = String(config.backendUrl || "").replace(/\/+$/, "");
51
53
  const endpoint = `${baseUrl}/api/diagnostics/tasks/${encodeURIComponent(taskId)}`;
@@ -583,6 +583,17 @@ export function createPendingRemoteInterruptQueue() {
583
583
  async function main() {
584
584
  syncPwdEnvWithProcessCwdForDaemonLaunch();
585
585
  const cliArgs = await parseCliArgs();
586
+ if (cliArgs.configFile) {
587
+ // An explicit --config-file must win over CONDUCTOR_* backend/token env
588
+ // injected by an owning daemon or session (same convention as
589
+ // envForExplicitConfigFile); otherwise fire dials the file's websocket
590
+ // with the inherited token and loops on 4002 invalid-token.
591
+ delete process.env.CONDUCTOR_AGENT_TOKEN;
592
+ delete process.env.CONDUCTOR_BACKEND_URL;
593
+ delete process.env.CONDUCTOR_WS_URL;
594
+ delete process.env.CONDUCTOR_BACKEND_WS_URL;
595
+ process.env.CONDUCTOR_CONFIG = cliArgs.configFile;
596
+ }
586
597
  let runtimeProjectPath = process.cwd();
587
598
  let backendSession = null;
588
599
 
@@ -17,6 +17,7 @@ import {
17
17
  fetchLatestVersion,
18
18
  isNewerVersion,
19
19
  detectPackageManager,
20
+ resolveGlobalInstallPrefix,
20
21
  } from "../src/version-check.js";
21
22
  import {
22
23
  buildPnpmAllowBuildArgs,
@@ -163,6 +164,18 @@ async function performUpdate() {
163
164
  console.log(` Using package manager: ${colorize(packageManager, "cyan")}`);
164
165
  console.log("");
165
166
 
167
+ // `npm install -g` targets whichever npm wins the PATH lookup, which is not necessarily the
168
+ // one that installed us -- a Conductor-managed Node, an nvm shim and a system npm all resolve
169
+ // to different prefixes. When they disagree the update lands in a tree nobody runs, so
170
+ // `conductor --version` keeps reporting the old build and the next update repeats the cycle.
171
+ // Pin every child command to the prefix the running package actually lives in.
172
+ const installPrefix = packageManager === "npm" ? resolveGlobalInstallPrefix(PKG_ROOT) : null;
173
+ if (installPrefix) {
174
+ process.env.npm_config_prefix = installPrefix;
175
+ console.log(` Install prefix: ${colorize(installPrefix, "cyan")}`);
176
+ console.log("");
177
+ }
178
+
166
179
  if (packageManager === "pnpm") {
167
180
  console.log(" Preparing pnpm native dependency allowlist...");
168
181
  await ensurePnpmOnlyBuiltDependencies({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.9.0",
4
- "gitCommitId": "606e5a6",
3
+ "version": "0.10.0",
4
+ "gitCommitId": "bc7b3a5",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -24,8 +24,8 @@
24
24
  "test": "node --test test/*.test.js"
25
25
  },
26
26
  "dependencies": {
27
- "@love-moon/ai-sdk": "0.9.0",
28
- "@love-moon/conductor-sdk": "0.9.0",
27
+ "@love-moon/ai-sdk": "0.10.0",
28
+ "@love-moon/conductor-sdk": "0.10.0",
29
29
  "@github/copilot-sdk": "^0.3.0",
30
30
  "chrome-launcher": "^1.2.1",
31
31
  "chrome-remote-interface": "^0.33.0",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "@roamhq/wrtc": "^0.10.0",
41
- "@love-moon/chat-web": "0.9.0"
41
+ "@love-moon/chat-web": "0.10.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -353,6 +353,57 @@ function serializeRuntimeBackendMap(runtimeBackendMap) {
353
353
  .join(",");
354
354
  }
355
355
 
356
+ // Backends whose install/auth health can be probed via the AI manager. Others
357
+ // (external providers) are left out of the advertised health map so the web
358
+ // runtime preflight fails open for them.
359
+ const RUNTIME_HEALTH_TOOLS = new Set(["codex", "claude", "copilot", "kimi", "dsh"]);
360
+
361
+ function runtimeHealthToolForBackend(backend, runtimeBackendMap) {
362
+ const normalized = String(backend || "").trim().toLowerCase();
363
+ if (RUNTIME_HEALTH_TOOLS.has(normalized)) {
364
+ return normalized;
365
+ }
366
+ const runtime = String(runtimeBackendMap?.[normalized] || "").trim().toLowerCase();
367
+ return RUNTIME_HEALTH_TOOLS.has(runtime) ? runtime : null;
368
+ }
369
+
370
+ function serializeRuntimeHealth(runtimeHealth) {
371
+ if (!runtimeHealth || typeof runtimeHealth !== "object") {
372
+ return "";
373
+ }
374
+ return Object.entries(runtimeHealth)
375
+ .filter(([backend, state]) => backend && state)
376
+ .sort(([left], [right]) => left.localeCompare(right))
377
+ .map(([backend, state]) => `${backend}=${state}`)
378
+ .join(",");
379
+ }
380
+
381
+ // Best-effort per-backend runtime health for the web runtime preflight. Only a
382
+ // cheap install probe (which/--version) runs, once at startup. We advertise
383
+ // only a POSITIVE `ready` confirmation: a supported backend whose default-name
384
+ // `which` probe fails is very likely resolved via a custom or absolute path
385
+ // (allow_cli_list, or a systemd PATH that differs from the login shell) and is
386
+ // still runnable, so reporting it as "missing" would be an unreliable false
387
+ // negative that could wrongly block task creation. Omitting it lets the web
388
+ // preflight fail open for that backend.
389
+ async function computeAdvertisedRuntimeHealth(manager, supportedBackends, runtimeBackendMap) {
390
+ if (!manager || typeof manager.checkInstallAll !== "function") {
391
+ return {};
392
+ }
393
+ const install = await manager.checkInstallAll();
394
+ const runtimeHealth = {};
395
+ for (const backend of supportedBackends || []) {
396
+ const tool = runtimeHealthToolForBackend(backend, runtimeBackendMap);
397
+ if (!tool) {
398
+ continue;
399
+ }
400
+ if (install?.[tool]?.installed) {
401
+ runtimeHealth[String(backend).trim().toLowerCase()] = "ready";
402
+ }
403
+ }
404
+ return runtimeHealth;
405
+ }
406
+
356
407
  async function defaultCreatePty(command, args, options) {
357
408
  if (!nodePtySpawnPromise) {
358
409
  const spawnHelperInfo = ensureNodePtySpawnHelperExecutable();
@@ -3085,6 +3136,28 @@ export function startDaemon(config = {}, deps = {}) {
3085
3136
  if (typeof client?.setExtraHeaders === "function") {
3086
3137
  client.setExtraHeaders(extraHeaders);
3087
3138
  }
3139
+
3140
+ // Advertise best-effort runtime health so the backend can reject a task
3141
+ // whose backend is configured but cannot start (CLI missing/not signed in)
3142
+ // before creating any timeline activity. Fully additive and guarded: any
3143
+ // failure just omits the header and the preflight fails open.
3144
+ try {
3145
+ const runtimeHealth = await computeAdvertisedRuntimeHealth(
3146
+ aiManagerHandlers?.manager,
3147
+ SUPPORTED_BACKENDS,
3148
+ SUPPORTED_BACKEND_RUNTIME_MAP,
3149
+ );
3150
+ const serializedRuntimeHealth = serializeRuntimeHealth(runtimeHealth);
3151
+ if (serializedRuntimeHealth) {
3152
+ extraHeaders["x-conductor-runtime-health"] = serializedRuntimeHealth;
3153
+ if (typeof client?.setExtraHeaders === "function") {
3154
+ client.setExtraHeaders(extraHeaders);
3155
+ }
3156
+ }
3157
+ } catch (error) {
3158
+ logError(`Failed to probe runtime health: ${error?.message || error}`);
3159
+ }
3160
+
3088
3161
  if (daemonShuttingDown) {
3089
3162
  return;
3090
3163
  }
@@ -366,6 +366,46 @@ function readConfigEnvValue(configFilePath, key) {
366
366
  }
367
367
  }
368
368
 
369
+ // Read the top-level `disable_built_in_cli_list` config key: the mirror of
370
+ // `allow_cli_list` for the command-optional built-in backends (copilot, dsh)
371
+ // that are advertised WITHOUT any allow_cli_list entry. Listing a name here
372
+ // suppresses that automatic advertising so an operator can opt out of a
373
+ // built-in they don't want. Scoped to command-optional built-ins on purpose:
374
+ // a stray external or PATH-CLI name (e.g. "codex") is a documented no-op,
375
+ // since those never auto-advertise in the first place.
376
+ function readDisabledBuiltInBackends(configFilePath) {
377
+ const targetPath = resolveConductorConfigPath(configFilePath);
378
+ try {
379
+ if (!targetPath || !fs.existsSync(targetPath)) {
380
+ return new Set();
381
+ }
382
+ const parsed = yaml.load(fs.readFileSync(targetPath, "utf8"));
383
+ const raw = parsed?.disable_built_in_cli_list;
384
+ if (!Array.isArray(raw)) {
385
+ return new Set();
386
+ }
387
+ return new Set(
388
+ raw
389
+ .map((value) => normalizeRuntimeBackendName(value))
390
+ .filter((value) => value && isCommandOptionalBuiltInRuntimeBackend(value)),
391
+ );
392
+ } catch {
393
+ return new Set();
394
+ }
395
+ }
396
+
397
+ // Reuse a caller-provided disabled set when present (so a hot loop reads the
398
+ // config file once), else read it now. Keeps every call site one line.
399
+ function resolveDisabledBuiltIns(options = {}) {
400
+ return options.disabledBuiltIns instanceof Set
401
+ ? options.disabledBuiltIns
402
+ : readDisabledBuiltInBackends(options.configFilePath);
403
+ }
404
+
405
+ export function isDisabledBuiltInRuntimeBackend(backend, options = {}) {
406
+ return resolveDisabledBuiltIns(options).has(normalizeRuntimeBackendName(backend));
407
+ }
408
+
369
409
  function resolveProviderModulePaths(options = {}) {
370
410
  return [
371
411
  ...listProviderModulePaths(process.env.AISDK_PROVIDER_PATH),
@@ -577,6 +617,21 @@ export async function filterRuntimeSupportedAllowCliList(allowCliList, options =
577
617
  }
578
618
 
579
619
  export async function resolveConfiguredRuntimeBackend(backend, allowCliList, options = {}) {
620
+ const resolved = await resolveConfiguredRuntimeBackendInner(backend, allowCliList, options);
621
+ if (!resolved?.runtimeBackend) {
622
+ return resolved;
623
+ }
624
+ // A disabled command-optional built-in (copilot/dsh via
625
+ // `disable_built_in_cli_list`) is never resolvable, whether it reached here
626
+ // through an explicit allow_cli_list entry or the command-optional fallback.
627
+ const disabled = resolveDisabledBuiltIns(options);
628
+ if (disabled.has(resolved.runtimeBackend) || disabled.has(resolved.requestedBackend)) {
629
+ return null;
630
+ }
631
+ return resolved;
632
+ }
633
+
634
+ async function resolveConfiguredRuntimeBackendInner(backend, allowCliList, options = {}) {
580
635
  const normalizedBackend = normalizeRuntimeBackendName(backend);
581
636
  if (!normalizedBackend || LEGACY_RUNTIME_BACKEND_ALIASES.has(normalizedBackend)) {
582
637
  return null;
@@ -634,6 +689,9 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
634
689
  )
635
690
  : {};
636
691
  const configuredBackends = Object.keys(filteredAllowCliList);
692
+ // Read the disable list once and thread it through every per-backend
693
+ // resolve below, so this whole pass touches the config file a single time.
694
+ const resolveOptions = { ...options, disabledBuiltIns: readDisabledBuiltInBackends(options.configFilePath) };
637
695
  let runtimeBackends = [...BUILT_IN_RUNTIME_BACKENDS];
638
696
  let discoveryError = null;
639
697
  try {
@@ -647,7 +705,7 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
647
705
 
648
706
  for (const backend of configuredBackends) {
649
707
  try {
650
- const configuredBackend = await resolveConfiguredRuntimeBackend(backend, filteredAllowCliList, options);
708
+ const configuredBackend = await resolveConfiguredRuntimeBackend(backend, filteredAllowCliList, resolveOptions);
651
709
  if (!configuredBackend?.runtimeBackend) {
652
710
  continue;
653
711
  }
@@ -683,7 +741,10 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
683
741
  }
684
742
 
685
743
  const commandOptionalBuiltIns = BUILT_IN_RUNTIME_BACKENDS.filter(
686
- (backend) => isCommandOptionalBuiltInRuntimeBackend(backend) && !runtimeBackendMap[backend],
744
+ (backend) =>
745
+ isCommandOptionalBuiltInRuntimeBackend(backend) &&
746
+ !runtimeBackendMap[backend] &&
747
+ !resolveOptions.disabledBuiltIns.has(backend),
687
748
  );
688
749
  for (const backend of commandOptionalBuiltIns) {
689
750
  runtimeBackendMap[backend] = backend;
@@ -13,6 +13,7 @@ export const DEFAULT_HOMEBREW_FORMULA = "lovemoon-ai/tap/conductor";
13
13
  const DEFAULT_UPDATE_WINDOW = { startMinutes: 120, endMinutes: 240 };
14
14
  const REQUEST_TIMEOUT_MS = 10_000;
15
15
  const INSTALL_METHOD_FILENAME = ".install-method";
16
+ const GLOBAL_PACKAGE_MARKER = `${path.sep}lib${path.sep}node_modules${path.sep}`;
16
17
 
17
18
  function resolveTimeoutMs(value) {
18
19
  const parsed = Number.parseInt(String(value ?? ""), 10);
@@ -139,6 +140,28 @@ export function resolveInstallMethod(options = {}) {
139
140
  }
140
141
  }
141
142
 
143
+ /**
144
+ * Derive the npm global prefix that the currently running package was installed into.
145
+ *
146
+ * A global install always lands at `<prefix>/lib/node_modules/<package>`, so the prefix is
147
+ * whatever precedes that marker. Returns `null` for layouts that are not a global install
148
+ * (git checkout, project-local `node_modules`), where the caller should leave npm's own
149
+ * prefix resolution alone.
150
+ */
151
+ export function resolveGlobalInstallPrefix(packageRoot) {
152
+ if (typeof packageRoot !== "string" || !packageRoot.trim()) {
153
+ return null;
154
+ }
155
+
156
+ const normalized = path.resolve(packageRoot);
157
+ const markerIndex = normalized.lastIndexOf(GLOBAL_PACKAGE_MARKER);
158
+ if (markerIndex <= 0) {
159
+ return null;
160
+ }
161
+
162
+ return normalized.slice(0, markerIndex);
163
+ }
164
+
142
165
  export function buildUpgradeCommand(options = {}) {
143
166
  const installMethod = resolveInstallMethod(options);
144
167
  if (installMethod === "homebrew") {