Skip to content

Bug: binding to the implicit default agent "main" is rejected at config load and deleted by doctor --fix when agents.list is non-empty #89412

Description

@yetval

Summary

A route binding to the implicit default agent "main" is treated as a dangling reference whenever agents.list is non-empty. This breaks the documented ACP multi-agent shape (docs/tools/acp-agents.md: a few ACP agents in agents.list, with "main" routed for everything else) in two ways:

  1. Config load throwsloadConfig rejects the config with Unknown agent id "main" (not in agents.list)., so the gateway/CLI refuses to start after an upgrade.
  2. openclaw doctor --fix silently deletes the bindings — the compatibility migration prunes every "main" binding from disk and reports Removed N bindings that referenced missing agents.list ids.

"main" (DEFAULT_AGENT_ID) is the implicit default agent everywhere else: routing binds any agentId and an unlisted agent simply runs on agents.defaults, and session-key.ts uses "main" as the universal fallback agentId. Only these two new code paths treat it as unknown.

  • Impact: startup failure on a documented config after upgrade, plus silent config/routing data loss via doctor (the very tool the load error tells the user to run, and which runs on update).
  • Affected: any multi-agent user who routes the default/main agent via bindings (the documented ACP pattern).
  • Version audited: main @ 68833510

Root cause

1. Load-time rejectionsrc/config/zod-schema.ts:1280-1307. The binding superRefine builds the allowed set from agents.list only and never adds DEFAULT_AGENT_ID:

// src/config/zod-schema.ts ~1281-1305
const agents = cfg.agents?.list ?? [];
if (agents.length === 0) return;                 // only the empty-list case is exempt
const effectiveAgentIds = new Set(agents.map((a) => normalizeAgentId(a.id)));
for (...bindings...) {
  const agentId = (binding as { agentId?: unknown }).agentId;
  if (typeof agentId === "string" && !effectiveAgentIds.has(normalizeAgentId(agentId))) {
    ctx.addIssue({ ... message: `Unknown agent id "${agentId}" (not in agents.list).` });
  }
}

On !ok, loadConfigLocal (src/config/io.ts) turns this into a thrown createInvalidConfigError — a hard startup failure.

2. Destructive doctor migrationsrc/commands/doctor/shared/legacy-config-core-migrate.ts:12-43 (pruneBindingsForMissingAgents, run from the exported normalizeCompatibilityConfigValues at line 72):

// src/commands/doctor/shared/legacy-config-core-migrate.ts ~26-37
const agentIds = new Set(validAgents.map((a) => normalizeAgentId(a.id)));
const nextBindings = bindings.filter((b) => {
  const agentId = b && typeof b === "object" ? b.agentId : undefined;
  return typeof agentId !== "string" || agentIds.has(normalizeAgentId(agentId));
});
// drops every binding whose agentId is not literally in agents.list (incl. "main")
changes.push(`Removed ${removed} binding(s) that referenced missing agents.list ids.`);

Neither path adds DEFAULT_AGENT_ID ("main", src/routing/session-key.ts:26) to the allowed set. But binding to "main" is valid and functional: resolve-route.ts choose(matched.binding.agentId, ...) binds it as-is, resolveAgentConfig("main") returns undefined and the agent runs on agents.defaults, and "main" is the universal fallback agentId throughout session-key.ts. The check was added alongside the onboarding-preservation work (the regression test is annotated openclaw#84692) and only ever considered the empty-agents.list case, so the implicit-main binding ships unflagged.

Reproduction

Standalone vitest against pristine main (place at repo root as mainbind.test.ts):

import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./src/config/validation.js";
import { normalizeCompatibilityConfigValues } from "./src/commands/doctor/shared/legacy-config-core-migrate.js";

// The documented ACP shape (docs/tools/acp-agents.md): only the ACP agents are
// listed; the implicit default agent "main" is routed via bindings.
const docsConfig = {
  agents: {
    list: [
      { id: "codex", model: "anthropic/claude-sonnet-4-6" },
      { id: "claude", model: "anthropic/claude-sonnet-4-6" },
    ],
  },
  bindings: [
    { type: "route", agentId: "main", match: { channel: "discord", accountId: "default" } },
    { type: "route", agentId: "main", match: { channel: "telegram", accountId: "default" } },
  ],
};

describe('binding to the implicit "main" agent with a populated agents.list', () => {
  it("config load must accept the documented shape", () => {
    const res = validateConfigObject(structuredClone(docsConfig));
    expect(res.ok).toBe(true); // FAILS
  });

  it("doctor --fix must not delete the 'main' route bindings", () => {
    const { config } = normalizeCompatibilityConfigValues(structuredClone(docsConfig));
    const remaining = Array.isArray(config.bindings) ? config.bindings.length : 0;
    expect(remaining).toBe(2); // FAILS (becomes 0)
  });
});

Observed (main @ 68833510)

# config load
res.ok = false
issues = ["Unknown agent id \"main\" (not in agents.list).",
          "Unknown agent id \"main\" (not in agents.list)."]

# doctor (normalizeCompatibilityConfigValues)
config.bindings        = []   (both "main" bindings removed)
changes                = ["Removed 2 bindings that referenced missing agents.list ids."]

Expected

The documented config loads without error, and doctor preserves the "main" route bindings. "main" is the implicit default agent and is a valid binding target regardless of agents.list contents.

Suggested direction

  • Treat DEFAULT_AGENT_ID ("main") as always valid in both predicates: add it to effectiveAgentIds in the superRefine and to agentIds in pruneBindingsForMissingAgents, so only genuinely-dangling ids are rejected/pruned.
  • Add regression coverage using the docs/tools/acp-agents.md config (load accepts it; prune leaves the "main" bindings intact).

Happy to send a PR with the fix + the regression tests above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:crash-loopCrash, hang, restart loop, or process-level availability failure.impact:data-lossCan lose, corrupt, or silently drop user/session/config data.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions