feat(agents): un-reserve literal main (#123609)

* feat(agents): un-reserve literal main

* fix(agents): scan legacy sessions before reusing main
This commit is contained in:
Peter Steinberger
2026-08-14 04:23:19 -07:00
committed by GitHub
parent 2af5eca07f
commit e6eebc5ad8
18 changed files with 621 additions and 65 deletions
+4 -1
View File
@@ -80,7 +80,10 @@ not overwrite the existing skill.
`--non-interactive`; omit `--classic` for automated setup.
- `--agent-name <name>`: names the first agent when no roster exists. Interactive
onboarding asks **What should we call your first agent?** and suggests `main`;
non-interactive onboarding keeps `main` unless this flag is provided.
non-interactive onboarding keeps `main` unless this flag is provided. The id
`main` is not reserved: if you later recreate it beside a named agent, run
`openclaw doctor --fix` first when creation reports legacy-session or
shared-auth ownership still attached to the old `main` installation.
- `--flow quickstart`: opens the classic wizard with minimal prompts, uses
token auth by default, and generates a token when no stored or explicit
credential applies. Explicit local Gateway flags such as
+7
View File
@@ -20,6 +20,13 @@ uses `main` as the suggested value. Automated onboarding keeps the historical
store as `main`; onboarding also migrates legacy `agent:main:*` session history
to that sole owner before it finishes.
`main` is an ordinary agent id. Reusing it after a named agent owns the install
is guarded so old data is never silently adopted: `legacy-session-migration-required`
means `openclaw doctor --fix` must finish or quarantine legacy `agent:main:*`
claims, while `shared-auth-store-owned-by-main` means Doctor must first relocate
the shared auth store into `state/openclaw.sqlite`. After both repairs, the new
`main` gets fresh agent-scoped session and auth storage like any other agent.
## Agent defaults
### `agents.defaults.workspace`
+3 -1
View File
@@ -202,7 +202,9 @@ Notes:
- Default workspace (when `--workspace` is omitted in the interactive wizard): `~/.openclaw/workspace-<agentId>`.
- `--bind <channel[:accountId]>` is repeatable; add bindings to route inbound messages to the new agent (the wizard can also do this interactively).
- The agent name is normalized to a valid agent id; `main` is reserved.
- The agent name is normalized to a valid agent id. `main` is allowed, but an
existing named installation may require `openclaw doctor --fix` to finish
legacy-session and shared-auth ownership migrations before creating it.
## Related docs
+114
View File
@@ -2,10 +2,24 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { mutateConfigFileWithRetry } from "../config/config.js";
import { migrateLegacyMainSessionKeys } from "../config/sessions/legacy-main-session-migration.js";
import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js";
import { readExactSessionEntryRowForCanonicalRepair } from "../config/sessions/session-accessor.sqlite-canonical-repair.js";
import { writeSessionEntry } from "../config/sessions/session-accessor.sqlite-entry-store.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { writeConfigMachineState } from "../state/config-machine-state.js";
import {
closeOpenClawAgentDatabasesForTest,
runOpenClawAgentWriteTransaction,
} from "../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { createAgent } from "./agent-create.js";
import { resolveSharedAuthStorePath } from "./auth-profiles/path-resolve.js";
import { resolveAuthProfileDatabasePath } from "./auth-profiles/sqlite.js";
import {
DEFAULT_IDENTITY_FILENAME,
ensureAgentWorkspace,
@@ -115,3 +129,103 @@ describe("agent roster persistence", () => {
}
});
});
it("creates main as an ordinary fresh agent after doctor completes both ownership handoffs", async () => {
const state = await createOpenClawTestState({
layout: "state-only",
scenario: "empty",
label: "ordinary-main-agent",
});
const cfg: OpenClawConfig = {
agents: { entries: { robby: { workspace: state.path("workspace-robby") } } },
};
const legacyDatabasePath = path.join(state.agentDir("main"), "openclaw-agent.sqlite");
const ownerDatabasePath = path.join(state.agentDir("robby"), "openclaw-agent.sqlite");
const legacyKey = "agent:main:main";
const canonicalKey = "agent:robby:main";
const lateLegacyKey = "agent:main:late";
const lateCanonicalKey = "agent:robby:late";
try {
await state.writeConfig(cfg);
runOpenClawAgentWriteTransaction(
(database) => {
writeSessionEntry(
database,
legacyKey,
{ sessionId: "legacy-before-main-reuse", updatedAt: 100 },
{ allowStoredAliases: true, previousEntry: null },
);
},
{ agentId: "main", env: state.env, path: legacyDatabasePath },
);
await migrateLegacyMainSessionKeys({ cfg, env: state.env, mode: "doctor-fix" });
writeConfigMachineState("auth.sharedStore", { location: "state-db" }, { env: state.env });
runOpenClawAgentWriteTransaction(
(database) => {
writeSessionEntry(
database,
lateLegacyKey,
{ sessionId: "late-legacy-before-main-reuse", updatedAt: 200 },
{ allowStoredAliases: true, previousEntry: null },
);
},
{ agentId: "main", env: state.env, path: legacyDatabasePath },
);
const blocked = await createAgent({ name: "main", workspace: state.path("workspace-main") });
expect(blocked).toMatchObject({
status: "error",
reason: "legacy-session-migration-required",
});
expect(
runOpenClawAgentWriteTransaction(
(database) => readExactSessionEntryRowForCanonicalRepair(database, lateLegacyKey)?.entry,
{ agentId: "main", env: state.env, path: legacyDatabasePath },
),
).toMatchObject({ sessionId: "late-legacy-before-main-reuse" });
await migrateLegacyMainSessionKeys({ cfg, env: state.env, mode: "doctor-fix" });
const created = await createAgent({ name: "main", workspace: state.path("workspace-main") });
expect(created).toMatchObject({ status: "created", agentId: "main" });
if (created.status !== "created") {
throw new Error(`expected main creation, got ${JSON.stringify(created)}`);
}
const persisted = JSON.parse(await fs.readFile(state.configPath, "utf8")) as OpenClawConfig;
const mainSessionTarget = resolveSqliteTargetFromSessionStorePath(
resolveSessionStorePathCore(persisted.session?.store, { agentId: "main", env: state.env }),
{ agentId: "main", env: state.env },
);
expect(mainSessionTarget).toMatchObject({ agentId: "main", path: legacyDatabasePath });
expect(resolveAuthProfileDatabasePath(created.agentDir)).toBe(legacyDatabasePath);
expect(resolveSharedAuthStorePath(state.env)).toBe(resolveOpenClawStateSqlitePath(state.env));
expect(resolveAuthProfileDatabasePath(created.agentDir)).not.toBe(
resolveSharedAuthStorePath(state.env),
);
expect(
listSessionEntriesReadOnly({
agentId: "main",
env: state.env,
storePath: legacyDatabasePath,
}).filter((entry) => entry.sessionKey.startsWith("agent:main:")),
).toEqual([]);
expect(
runOpenClawAgentWriteTransaction(
(database) => readExactSessionEntryRowForCanonicalRepair(database, canonicalKey)?.entry,
{ agentId: "robby", env: state.env, path: ownerDatabasePath },
),
).toMatchObject({ sessionId: "legacy-before-main-reuse" });
expect(
runOpenClawAgentWriteTransaction(
(database) => readExactSessionEntryRowForCanonicalRepair(database, lateCanonicalKey)?.entry,
{ agentId: "robby", env: state.env, path: ownerDatabasePath },
),
).toMatchObject({ sessionId: "late-legacy-before-main-reuse" });
} finally {
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
await state.cleanup();
}
});
+162 -1
View File
@@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({
mkdir: vi.fn(),
readAgentDeletionJournal: vi.fn(() => undefined as Record<string, unknown> | undefined),
claimCompletedAgentDeletion: vi.fn(() => true),
migrateLegacyMainSessionKeys: vi.fn(),
resolveSharedAuthStoreOwnership: vi.fn(),
}));
vi.mock("node:fs/promises", () => ({ default: { mkdir: mocks.mkdir } }));
@@ -47,6 +49,14 @@ vi.mock("../state/agent-deletion-journal.js", () => ({
readAgentDeletionJournal: mocks.readAgentDeletionJournal,
}));
vi.mock("../config/sessions/legacy-main-session-migration.js", () => ({
migrateLegacyMainSessionKeys: mocks.migrateLegacyMainSessionKeys,
}));
vi.mock("./auth-profiles/path-resolve.js", () => ({
resolveSharedAuthStoreOwnership: mocks.resolveSharedAuthStoreOwnership,
}));
vi.mock("./workspace.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./workspace.js")>();
return { ...actual, ensureAgentWorkspace: mocks.ensureAgentWorkspace };
@@ -76,6 +86,18 @@ describe("createAgent", () => {
mocks.persisted = {};
mocks.readAgentDeletionJournal.mockReturnValue(undefined);
mocks.claimCompletedAgentDeletion.mockReturnValue(true);
mocks.migrateLegacyMainSessionKeys.mockResolvedValue({
armed: true,
changes: [],
complete: true,
ledgerComplete: true,
legacyAgentId: "main",
mainKey: "main",
outcomes: [{ kind: "no-legacy-rows", detail: "matching completed ledger" }],
ownerAgentId: "researcher",
warnings: [],
});
mocks.resolveSharedAuthStoreOwnership.mockReturnValue({ location: "state-db" });
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/default-researcher");
mocks.resolveAgentDir.mockReturnValue("/tmp/agent-researcher");
mocks.ensureAgentWorkspace.mockImplementation(async ({ dir }: { dir: string }) => ({
@@ -118,7 +140,7 @@ describe("createAgent", () => {
status: "error",
reason: "invalid-name",
});
for (const name of ["main", "OpenClaw", "crestodian"]) {
for (const name of ["OpenClaw", "crestodian"]) {
await expect(createAgent({ name })).resolves.toMatchObject({
status: "error",
reason: "reserved-id",
@@ -127,6 +149,108 @@ describe("createAgent", () => {
expect(mocks.transformConfigFileWithRetry).not.toHaveBeenCalled();
});
it.each([
{ kind: "not-armed", armed: false, detail: "owner-unresolved" },
{ kind: "no-legacy-rows", armed: true },
{ kind: "migrated-in-place", armed: true, canonicalKey: "agent:robby:main" },
{ kind: "migrated-cross-store", armed: true, canonicalKey: "agent:robby:main" },
{ kind: "canonical-exists-identical", armed: true, canonicalKey: "agent:robby:main" },
{ kind: "divergent-canonical", armed: true, canonicalKey: "agent:robby:main" },
{ kind: "divergent-aliases", armed: true, canonicalKey: "agent:robby:main" },
{ kind: "legacy-json-store", armed: true, paths: ["/tmp/sessions.json"] },
{ kind: "store-unreadable", armed: true, paths: ["/tmp/store.sqlite"] },
] as const)("rejects main while the $kind session outcome is unresolved", async (outcome) => {
mocks.config = { agents: { entries: { robby: { id: "robby" } } } };
mocks.migrateLegacyMainSessionKeys.mockResolvedValueOnce({
armed: outcome.armed,
changes: [],
complete: false,
ledgerComplete: false,
legacyAgentId: "main",
mainKey: "main",
outcomes: [
{
...outcome,
paths: "paths" in outcome ? outcome.paths : ["/tmp/legacy.sqlite", "/tmp/owner.sqlite"],
sourceKeys: ["agent:main:main", "agent:robby:main"],
},
],
warnings: [],
});
await expect(createAgent({ name: "main" })).resolves.toMatchObject({
status: "error",
reason: "legacy-session-migration-required",
message: expect.stringContaining("openclaw doctor --fix"),
});
expect(mocks.transformConfigFileWithRetry).not.toHaveBeenCalled();
});
it("names both preserved claims when main creation finds divergence", async () => {
mocks.config = { agents: { entries: { robby: { id: "robby" } } } };
mocks.migrateLegacyMainSessionKeys.mockResolvedValueOnce({
armed: true,
changes: [],
complete: false,
ledgerComplete: false,
legacyAgentId: "main",
mainKey: "main",
outcomes: [
{
kind: "divergent-canonical",
canonicalKey: "agent:robby:main",
paths: ["/tmp/legacy.sqlite", "/tmp/owner.sqlite"],
sourceKeys: ["agent:main:main", "agent:robby:main"],
},
],
warnings: [],
});
const result = await createAgent({ name: "main" });
expect(result).toMatchObject({
status: "error",
message: expect.stringMatching(
/legacy\.sqlite#agent:main:main.*owner\.sqlite#agent:robby:main/u,
),
});
});
it("rejects main while its agent database still owns shared auth", async () => {
mocks.config = { agents: { entries: { robby: { id: "robby" } } } };
mocks.resolveSharedAuthStoreOwnership.mockReturnValueOnce({ location: "legacy-main" });
await expect(createAgent({ name: "main" })).resolves.toMatchObject({
status: "error",
reason: "shared-auth-store-owned-by-main",
message: expect.stringContaining("openclaw doctor --fix"),
});
expect(mocks.transformConfigFileWithRetry).not.toHaveBeenCalled();
});
it("creates main as an ordinary agent once both migration gates are complete", async () => {
mocks.config = { agents: { entries: { robby: { id: "robby" } } } };
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace-main");
mocks.resolveAgentDir.mockReturnValue("/tmp/agents/main/agent");
await expect(createAgent({ name: "main" })).resolves.toMatchObject({
status: "created",
agentId: "main",
agentDir: "/tmp/agents/main/agent",
});
expect(mocks.persisted).toMatchObject({
agents: { entries: { robby: expect.any(Object), main: expect.any(Object) } },
});
expect(mocks.migrateLegacyMainSessionKeys).toHaveBeenCalledWith({
cfg: expect.objectContaining({
agents: { entries: { robby: { id: "robby" } } },
}),
forceScan: true,
legacyAgentId: "main",
mode: "detect",
});
});
it("defaults the workspace through the agent-scoped resolver", async () => {
const result = await createAgent({ name: "Researcher" });
@@ -167,6 +291,43 @@ describe("createAgent", () => {
expect((mocks.persisted.agents as { list?: unknown }).list).toBeUndefined();
});
it("publishes guided staging and its new agent in one conditional transform", async () => {
const result = await createAgent({
entry: {
id: "researcher",
name: "Researcher",
workspace: "/tmp/staged-work",
},
expectedConfigHash: null,
stagedConfig: {
agents: {
entries: {
main: {},
researcher: { workspace: "/tmp/staged-work" },
},
},
channels: { telegram: { enabled: true } },
},
});
expect(result).toMatchObject({ status: "created", agentId: "researcher" });
expect(mocks.transformConfigFileWithRetry).toHaveBeenCalledOnce();
expect(mocks.persisted).toMatchObject({
agents: { entries: { main: expect.any(Object), researcher: expect.any(Object) } },
channels: { telegram: { enabled: true } },
});
});
it("requires a config revision for guided staging", async () => {
await expect(
createAgent({
entry: { id: "researcher" },
stagedConfig: { agents: { entries: { researcher: {} } } },
}),
).rejects.toThrow("staged agent creation requires an expected config hash");
expect(mocks.withConfigMutationExclusive).not.toHaveBeenCalled();
});
it("replaces only the load-time compatibility roster when creating a named first agent", async () => {
await createAgent({
entry: { id: "robby", name: "robby", workspace: "/tmp/robby" },
+95 -17
View File
@@ -12,6 +12,8 @@ import {
transformConfigFileWithRetry,
withConfigMutationExclusive,
} from "../config/config.js";
import type { LegacyMainSessionMigrationOutcome } from "../config/sessions/legacy-main-session-migration.contract.js";
import { migrateLegacyMainSessionKeys } from "../config/sessions/legacy-main-session-migration.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -23,6 +25,7 @@ import { resolveUserPath } from "../utils.js";
import { claimCompletedAgentDeletion } from "./agent-lifecycle-registry.js";
import { toAgentEntriesRecord } from "./agent-scope-config.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "./agent-scope.js";
import { resolveSharedAuthStoreOwnership } from "./auth-profiles/path-resolve.js";
import {
createAgentIdentityConfig,
mergeIdentityMarkdownContent,
@@ -30,7 +33,7 @@ import {
} from "./identity-file.js";
import { DEFAULT_IDENTITY_FILENAME, ensureAgentWorkspace } from "./workspace.js";
const RESERVED_BOOTSTRAP_AGENT_ID = "main";
const BOOTSTRAP_AGENT_ID = "main";
type CreateAgentResult =
| {
@@ -52,6 +55,8 @@ type CreateAgentResult =
| "already-exists"
| "deletion-pending"
| "invalid-bindings"
| "legacy-session-migration-required"
| "shared-auth-store-owned-by-main"
| "unsafe-identity-file";
agentId?: string;
message: string;
@@ -64,12 +69,14 @@ type CreateAgentEntry = AgentEntryConfig & { id: string };
type CreateAgentParams = {
name?: string;
entry?: CreateAgentEntry;
/** Internal authorization for onboarding to materialize the reserved sole `main` agent. */
/** Internal authorization for onboarding to materialize the sole implicit `main` agent. */
bootstrapMain?: boolean;
/** Replace the load-time compatibility roster when onboarding creates the first real agent. */
bootstrapFirstAgent?: boolean;
/** Config revision that must still own first-agent creation under the write lock. */
expectedConfigHash?: string | null;
/** Full guided-flow staging based on expectedConfigHash; creation still publishes it once. */
stagedConfig?: OpenClawConfig;
workspace?: string;
model?: string;
emoji?: unknown;
@@ -99,7 +106,7 @@ function hasValidRawAgentIdCharacters(value: string): boolean {
export function validateAgentIdInput(
rawId: string,
options: { allowBootstrapMain?: boolean; displayName?: string } = {},
options: { displayName?: string } = {},
):
| { ok: true; agentId: string }
| { ok: false; reason: "invalid-name" | "reserved-id"; message: string; agentId?: string } {
@@ -112,18 +119,85 @@ export function validateAgentIdInput(
};
}
const agentId = normalizeAgentId(rawId);
if (
(agentId === RESERVED_BOOTSTRAP_AGENT_ID && options.allowBootstrapMain !== true) ||
isReservedSystemAgentId(agentId)
) {
if (isReservedSystemAgentId(agentId)) {
return { ok: false, reason: "reserved-id", message: `"${agentId}" is reserved`, agentId };
}
return { ok: true, agentId };
}
function isInjectedBootstrapMainEntry(entry: CreateAgentEntry | undefined): boolean {
return (
entry?.id === RESERVED_BOOTSTRAP_AGENT_ID && Object.keys(entry).every((key) => key === "id")
return entry?.id === BOOTSTRAP_AGENT_ID && Object.keys(entry).every((key) => key === "id");
}
function describeLegacySessionOutcome(outcome: LegacyMainSessionMigrationOutcome): string {
const claims = (outcome.sourceKeys ?? []).map(
(key, index) => `${outcome.paths?.[index] ?? outcome.paths?.[0] ?? "session store"}#${key}`,
);
switch (outcome.kind) {
case "divergent-aliases":
case "divergent-canonical":
return `${outcome.kind} for ${outcome.canonicalKey ?? "the canonical session"}; preserved claims ${claims.join(", ") || "could not be reconciled"} must be quarantined`;
case "legacy-json-store":
return `legacy JSON session store ${outcome.paths?.join(", ") ?? "requires import"}`;
case "store-unreadable":
return `unreadable session store ${outcome.paths?.join(", ") ?? "unknown"}${outcome.detail ? ` (${outcome.detail})` : ""}`;
case "migrated-in-place":
case "migrated-cross-store":
case "canonical-exists-identical":
return `legacy claim ${claims.join(", ") || outcome.canonicalKey || "requires migration"}`;
case "not-armed":
return outcome.detail === "owner-unresolved"
? "legacy main sessions have no unambiguous configured owner; set agents.defaults.sessionStore.agentId to the intended live owner"
: `legacy main session migration is not armed (${outcome.detail ?? "unknown reason"})`;
case "no-legacy-rows":
return "the current session-store layout has no matching completed migration ledger";
}
const unreachable: never = outcome.kind;
return unreachable;
}
async function evaluateMainCreationGate(
config: OpenClawConfig,
agentId: string,
): Promise<CreateError | undefined> {
const roster = listAgentEntries(config).map((entry) => normalizeAgentId(entry.id));
if (
agentId !== BOOTSTRAP_AGENT_ID ||
roster.includes(BOOTSTRAP_AGENT_ID) ||
!roster.some((id) => id !== BOOTSTRAP_AGENT_ID)
) {
return undefined;
}
const migration = await migrateLegacyMainSessionKeys({
cfg: config,
forceScan: true,
legacyAgentId: BOOTSTRAP_AGENT_ID,
mode: "detect",
});
if (!migration.armed || !migration.ledgerComplete) {
const details = migration.outcomes.map(describeLegacySessionOutcome).join("; ");
return createError(
"legacy-session-migration-required",
`Cannot create agent "main": ${details}. Run openclaw doctor --fix, then retry.`,
agentId,
);
}
if (resolveSharedAuthStoreOwnership().location !== "state-db") {
return createError(
"shared-auth-store-owned-by-main",
'Cannot create agent "main" while agents/main/agent owns the shared auth store. Run openclaw doctor --fix to relocate shared auth, then retry.',
agentId,
);
}
return undefined;
}
/** Read-only early check for guided flows that stage side effects before their final create. */
export async function checkAgentCreationGate(agentId: string): Promise<CreateError | undefined> {
return await withConfigMutationExclusive(
async (lockedConfig) => await evaluateMainCreationGate(lockedConfig, normalizeAgentId(agentId)),
);
}
@@ -149,20 +223,22 @@ async function writeIdentityFile(params: {
}
export async function createAgent(params: CreateAgentParams): Promise<CreateAgentResult> {
if (params.stagedConfig && !Object.hasOwn(params, "expectedConfigHash")) {
throw new Error("staged agent creation requires an expected config hash");
}
const rawName = (params.entry?.name?.trim() || params.entry?.id || params.name || "").trim();
if (!rawName) {
return createError("invalid-name", "agent name is required");
}
const rawId = params.entry?.id ?? rawName;
const validation = validateAgentIdInput(rawId, {
allowBootstrapMain: params.bootstrapMain,
displayName: rawName,
});
if (!validation.ok) {
return createError(validation.reason, validation.message, validation.agentId);
}
const agentId = validation.agentId;
const isBootstrapMain = agentId === RESERVED_BOOTSTRAP_AGENT_ID && params.bootstrapMain === true;
const isBootstrapMain = agentId === BOOTSTRAP_AGENT_ID && params.bootstrapMain === true;
const safeName = sanitizeAgentIdentityLine(rawName);
const model = normalizeOptionalString(params.model);
@@ -184,6 +260,10 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
try {
return await withConfigMutationExclusive(async (lockedConfig) => {
const gateError = await evaluateMainCreationGate(lockedConfig, agentId);
if (gateError) {
return gateError;
}
const deletion = readAgentDeletionJournal(agentId);
if (deletion && !deletion.cleanupCompleted) {
return createError(
@@ -206,7 +286,7 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
afterWrite: { mode: "auto" },
maxAttempts: 1,
...(params.bootstrapFirstAgent
? { writeOptions: { allowedAgentRosterRemovals: [RESERVED_BOOTSTRAP_AGENT_ID] } }
? { writeOptions: { allowedAgentRosterRemovals: [BOOTSTRAP_AGENT_ID] } }
: {}),
transform: async (currentConfig, context) => {
if (
@@ -231,11 +311,9 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
if (
isBootstrapMain &&
currentEntries.length > 0 &&
!currentEntries.some(
(entry) => normalizeAgentId(entry.id) === RESERVED_BOOTSTRAP_AGENT_ID,
)
!currentEntries.some((entry) => normalizeAgentId(entry.id) === BOOTSTRAP_AGENT_ID)
) {
// Never inject reserved main into a concurrently authored fleet.
// Never inject implicit bootstrap main into a concurrently authored fleet.
throw new DuplicateAgentError();
}
if (existingIndex >= 0 && !isBootstrapMain) {
@@ -279,7 +357,7 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
list: undefined,
},
}
: currentConfig;
: (params.stagedConfig ?? currentConfig);
let nextConfig =
existingIndex < 0 || materializeInjectedMain
? applyAgentConfig(creationBase, {
+54 -10
View File
@@ -22,6 +22,7 @@ const replaceConfigFileMock = vi.hoisted(() =>
vi.fn(async (params: { nextConfig: unknown }) => await writeConfigFileMock(params.nextConfig)),
);
const createAgentMock = vi.hoisted(() => vi.fn());
const checkAgentCreationGateMock = vi.hoisted(() => vi.fn());
const commitConfigWithPendingPluginInstallsMock = vi.hoisted(() =>
vi.fn(async (params: { nextConfig: Record<string, unknown> }) => {
await writeConfigFileMock(params.nextConfig);
@@ -90,7 +91,10 @@ vi.mock("../config/config.js", async () => ({
replaceConfigFile: replaceConfigFileMock,
}));
vi.mock("../agents/agent-create.js", () => ({ createAgent: createAgentMock }));
vi.mock("../agents/agent-create.js", () => ({
checkAgentCreationGate: checkAgentCreationGateMock,
createAgent: createAgentMock,
}));
vi.mock("../plugins/install-record-commit.js", async () => ({
...(await vi.importActual<typeof import("../plugins/install-record-commit.js")>(
@@ -142,10 +146,17 @@ describe("agents add command", () => {
replaceConfigFileMock.mockClear();
commitConfigWithPendingPluginInstallsMock.mockClear();
transformConfigWithPendingPluginInstallsMock.mockClear();
checkAgentCreationGateMock.mockReset().mockResolvedValue(undefined);
createAgentMock.mockReset();
createAgentMock.mockImplementation(
async (params: { name: string; workspace: string; bindingSpecs?: string[] }) => {
const agentId = params.name.toLowerCase();
async (params: {
name?: string;
workspace?: string;
entry?: { id: string; name?: string; workspace?: string; agentDir?: string };
bindingSpecs?: string[];
}) => {
const name = params.name ?? params.entry?.name ?? params.entry?.id ?? "";
const agentId = (params.entry?.id ?? name).toLowerCase();
if (agentId === "openclaw" || agentId === "crestodian") {
return { status: "error", reason: "reserved-id", agentId };
}
@@ -159,9 +170,9 @@ describe("agents add command", () => {
return {
status: "created" as const,
agentId,
name: params.name,
workspace: params.workspace,
agentDir: `/tmp/agent-${agentId}`,
name,
workspace: params.workspace ?? params.entry?.workspace ?? `/tmp/workspace-${agentId}`,
agentDir: params.entry?.agentDir ?? `/tmp/agent-${agentId}`,
bootstrapPending: true,
...(binding
? {
@@ -300,13 +311,46 @@ describe("agents add command", () => {
validateCatalog: false,
}),
);
expect(onboardHelpersMocks.ensureWorkspaceAndSessions).toHaveBeenCalledWith(
"/tmp/openclaw-jon",
runtime,
expect.objectContaining({ agentId: "jon" }),
expect(checkAgentCreationGateMock).toHaveBeenCalledWith("jon");
expect(createAgentMock).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({ id: "jon", workspace: "/tmp/openclaw-jon" }),
stagedConfig: expect.any(Object),
transformConfig: transformConfigWithPendingPluginInstallsMock,
}),
);
});
it("surfaces the canonical main gate before guided auth or workspace side effects", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: { agents: { entries: { robby: { id: "robby" } } } },
sourceConfig: { agents: { entries: { robby: { id: "robby" } } } },
});
const prompter = {
intro: vi.fn(),
text: vi.fn(),
confirm: vi.fn(),
note: vi.fn(),
outro: vi.fn(),
};
wizardMocks.createClackPrompter.mockReturnValue(prompter);
checkAgentCreationGateMock.mockResolvedValueOnce({
status: "error",
reason: "legacy-session-migration-required",
agentId: "main",
message: "Run openclaw doctor --fix, then retry.",
});
await agentsAddCommand({ name: "main" }, runtime);
expect(checkAgentCreationGateMock).toHaveBeenCalledWith("main");
expect(prompter.outro).toHaveBeenCalledWith("Run openclaw doctor --fix, then retry.");
expect(prompter.text).not.toHaveBeenCalled();
expect(authChoiceMocks.applyAuthChoice).not.toHaveBeenCalled();
expect(createAgentMock).not.toHaveBeenCalled();
});
it.each(["legacy-main", "state-db"] as const)(
"reports only auth profiles persisted to the new agent store with %s shared auth",
async (location) => {
+52 -21
View File
@@ -5,7 +5,7 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { createAgent } from "../agents/agent-create.js";
import { checkAgentCreationGate, createAgent } from "../agents/agent-create.js";
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
@@ -34,7 +34,7 @@ import {
transformConfigWithPendingPluginInstalls,
} from "../plugins/install-record-commit.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
@@ -206,7 +206,7 @@ export async function agentsAddCommand(
return "Required";
}
const normalized = normalizeAgentId(value);
if (normalized === LEGACY_IMPLICIT_AGENT_ID || isReservedSystemAgentId(normalized)) {
if (isReservedSystemAgentId(normalized)) {
return `"${normalized}" is reserved. Choose another name.`;
}
return undefined;
@@ -215,7 +215,7 @@ export async function agentsAddCommand(
const agentName = normalizeOptionalString(name) ?? "";
const agentId = normalizeAgentId(agentName);
if (agentId === LEGACY_IMPLICIT_AGENT_ID || isReservedSystemAgentId(agentId)) {
if (isReservedSystemAgentId(agentId)) {
await prompter.outro(`"${agentId}" is reserved. Choose another name.`);
return;
}
@@ -235,6 +235,12 @@ export async function agentsAddCommand(
await prompter.outro("No changes made.");
return;
}
} else {
const gateError = await checkAgentCreationGate(agentId);
if (gateError) {
await prompter.outro(gateError.message);
return;
}
}
const workspaceDefault = resolveAgentWorkspaceDir(cfg, agentId);
@@ -420,24 +426,49 @@ export async function agentsAddCommand(
}
}
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig,
...(baseHash !== undefined ? { baseHash } : {}),
});
nextConfig = committed.config;
let payload: { agentId: string; name: string; workspace: string; agentDir: string };
if (existingAgent) {
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig,
...(baseHash !== undefined ? { baseHash } : {}),
});
nextConfig = committed.config;
const target = resolveOnboardingAgentTarget(nextConfig, agentId);
await ensureOnboardingAgentWorkspace(target, runtime, {
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
});
payload = {
agentId: target.agentId,
name: agentName,
workspace: target.workspaceDir,
agentDir: target.agentDir,
};
} else {
const entry = listAgentEntries(nextConfig).find(
(candidate) => normalizeAgentId(candidate.id) === agentId,
);
if (!entry) {
throw new Error(`staged agent "${agentId}" is missing from config`);
}
const created = await createAgent({
entry: { ...entry, id: agentId },
expectedConfigHash: baseHash ?? null,
stagedConfig: nextConfig,
transformConfig: transformConfigWithPendingPluginInstalls,
});
if (created.status === "error") {
await prompter.outro(created.message);
return;
}
payload = {
agentId: created.agentId,
name: created.name,
workspace: created.workspace,
agentDir: created.agentDir,
};
}
logConfigUpdated(runtime);
const target = resolveOnboardingAgentTarget(nextConfig, agentId);
await ensureOnboardingAgentWorkspace(target, runtime, {
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
});
const payload = {
agentId: target.agentId,
name: agentName,
workspace: target.workspaceDir,
agentDir: target.agentDir,
};
if (opts.json) {
writeRuntimeJson(runtime, payload);
}
@@ -130,6 +130,7 @@ describe("doctor session transcript repair", () => {
armed: false,
changes: [],
complete: false,
ledgerComplete: false,
legacyAgentId: "main",
mainKey: "main",
outcomes: [{ kind: "not-armed" }],
@@ -11,6 +11,7 @@ import {
} from "../config/sessions/paths.js";
import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { writeConfigMachineState } from "../state/config-machine-state.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
@@ -396,8 +397,23 @@ describe("doctor state integrity oauth dir checks", () => {
expect(text).not.toContain("Examples: main");
});
it("reports a removed main directory once shared auth ownership is relocated", async () => {
createAgentDir("main");
writeConfigMachineState("auth.sharedStore", { location: "state-db" });
const text = await runStateIntegrityText({
agents: {
entries: { ops: { default: true } },
},
});
expect(text).toContain("without a matching agents.list entry");
expect(text).toContain("Examples: main");
});
it("does not let OPENCLAW_AGENT_DIR hide an unconfigured agent dir", async () => {
createAgentDir("legacy");
writeConfigMachineState("auth.sharedStore", { location: "state-db" });
const legacyAgentDir = path.join(
process.env.OPENCLAW_STATE_DIR ?? "",
"agents",
+18 -4
View File
@@ -7,11 +7,17 @@ import { asNullableObjectRecord } from "@openclaw/normalization-core/record-coer
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { note } from "../../packages/terminal-core/src/note.js";
import { isSharedAuthStoreOwner } from "../agents/agent-delete-safety.js";
import {
listAgentEntries,
resolveDefaultAgentDir,
tryResolveDefaultAgentId,
} from "../agents/agent-scope.js";
import {
resolveSharedAuthStoreOwnership,
resolveSharedAuthStorePath,
} from "../agents/auth-profiles/path-resolve.js";
import { resolveAuthProfileDatabasePath } from "../agents/auth-profiles/sqlite.js";
import {
clearWedgedSubagentRecoveryAbort,
formatSubagentRecoveryWedgedReason,
@@ -45,7 +51,6 @@ import {
updateLegacySessionStore,
} from "../infra/state-migrations.legacy-session-store.js";
import { listConfiguredChannelIdsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import { LEGACY_IMPLICIT_AGENT_ID } from "../routing/session-key.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { shortenHomePath } from "../utils.js";
@@ -184,9 +189,9 @@ function formatOrphanAgentDirPreview(entries: OrphanAgentDir[], limit = 3): stri
}
function listOrphanAgentDirs(cfg: OpenClawConfig, stateDir: string): OrphanAgentDir[] {
// agents/main/agent also owns the shipped shared legacy auth store.
// Keep main undeletable until named agents make auth-store ownership explicit.
const configuredIds = new Set<string>([LEGACY_IMPLICIT_AGENT_ID]);
const configuredIds = new Set<string>();
const sharedAuthOwnership = resolveSharedAuthStoreOwnership();
const sharedAuthDbPath = resolveSharedAuthStorePath();
const defaultAgentId = tryResolveDefaultAgentId(cfg);
if (defaultAgentId) {
configuredIds.add(normalizeAgentId(defaultAgentId));
@@ -211,6 +216,15 @@ function listOrphanAgentDirs(cfg: OpenClawConfig, stateDir: string): OrphanAgent
if (!hasNestedAgentDir) {
return false;
}
if (
isSharedAuthStoreOwner({
ownership: sharedAuthOwnership,
agentAuthDbPath: resolveAuthProfileDatabasePath(nestedAgentDir),
sharedAuthDbPath,
})
) {
return false;
}
if (liveDefaultAgentDir && areComparablePathsEqual(nestedAgentDir, liveDefaultAgentDir)) {
return false;
}
+1 -1
View File
@@ -21,7 +21,7 @@ export function validateFirstOnboardingAgentName(value: string | undefined): str
if (!name) {
return "Agent name is required.";
}
const validation = validateAgentIdInput(name, { allowBootstrapMain: true });
const validation = validateAgentIdInput(name);
return validation.ok ? undefined : `${validation.message}. Choose another name.`;
}
@@ -27,6 +27,8 @@ export type LegacyMainSessionMigrationResult = {
armed: boolean;
changes: string[];
complete: boolean;
/** The current owner, main key, and physical source layout have a completed doctor ledger. */
ledgerComplete: boolean;
legacyAgentId: string;
mainKey: string;
outcomes: LegacyMainSessionMigrationOutcome[];
@@ -63,6 +63,7 @@ it("keys the startup shortcut to source layout and makes Doctor rescan", async (
mode: "detect",
});
expect(changedStore.outcomes).toEqual([{ kind: "no-legacy-rows" }]);
expect(changedStore.ledgerComplete).toBe(false);
const restoredPath = path.join(root, "restored-main.sqlite");
seedClaim("main", restoredPath, "agent:main:restored");
@@ -86,9 +87,21 @@ it("keys the startup shortcut to source layout and makes Doctor rescan", async (
expect(startupShortcut.outcomes).toEqual([
{ kind: "no-legacy-rows", detail: "matching completed ledger" },
]);
expect(startupShortcut.ledgerComplete).toBe(true);
expect(readClaim("main", mainPath, "agent:main:late")).toBeDefined();
const creationScan = await migrateLegacyMainSessionKeys({
cfg,
env,
mode: "detect",
forceScan: true,
});
expect(creationScan.ledgerComplete).toBe(false);
expect(creationScan.outcomes.map((outcome) => outcome.kind)).toContain("migrated-cross-store");
expect(readClaim("main", mainPath, "agent:main:late")).toBeDefined();
const repaired = await migrateLegacyMainSessionKeys({ cfg, env, mode: "doctor-fix" });
expect(repaired.ledgerComplete).toBe(true);
expect(repaired.outcomes.map((outcome) => outcome.kind)).toContain("migrated-cross-store");
expect(readClaim("main", mainPath, "agent:main:late")).toBeUndefined();
expect(readClaim("ops", opsPath, "agent:ops:late")).toBeDefined();
@@ -13,6 +13,7 @@ it("runs from startup in automatic mode and surfaces unresolved warnings", async
armed: false,
changes: [],
complete: false,
ledgerComplete: false,
legacyAgentId: "main",
mainKey: "main",
outcomes: [{ kind: "not-armed" as const }],
@@ -48,6 +49,7 @@ it("runs the armed startup engine even when no legacy session directory remains"
armed: true,
changes: [],
complete: true,
ledgerComplete: true,
legacyAgentId: "main",
mainKey: "main",
outcomes: [{ kind: "no-legacy-rows" as const }],
@@ -407,6 +407,7 @@ function writeLedger(params: {
async function migrateLegacyMainSessionKeysInternal(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
forceScan?: boolean;
legacyAgentId?: string;
mode: LegacyMainSessionMigrationMode;
now?: () => number;
@@ -427,6 +428,7 @@ async function migrateLegacyMainSessionKeysInternal(params: {
...base,
armed: false,
complete: false,
ledgerComplete: false,
outcomes: [{ kind: "not-armed", detail: arming.reason }],
warnings: unresolved
? [
@@ -464,23 +466,29 @@ async function migrateLegacyMainSessionKeysInternal(params: {
}
const identityBase = { legacyAgentId, mainKey, ownerAgentId };
const identity = { ...identityBase, sourceLayout: resolveSourceLayout(resolved) };
let matchingCompletedLedger = false;
if (params.mode !== "doctor-fix" && outcomes.length === 0) {
try {
const ledger = readLedger(env);
if (ledgerMatches(ledger, identity)) {
return {
...base,
armed: true,
complete: true,
ownerAgentId,
outcomes: [{ kind: "no-legacy-rows", detail: "matching completed ledger" }],
};
matchingCompletedLedger = true;
if (!params.forceScan) {
return {
...base,
armed: true,
complete: true,
ledgerComplete: true,
ownerAgentId,
outcomes: [{ kind: "no-legacy-rows", detail: "matching completed ledger" }],
};
}
}
} catch (error) {
return {
...base,
armed: true,
complete: false,
ledgerComplete: false,
ownerAgentId,
outcomes: [{ kind: "store-unreadable", detail: String(error) }],
warnings: [`session: could not read the legacy-main migration ledger: ${String(error)}`],
@@ -655,6 +663,8 @@ async function migrateLegacyMainSessionKeysInternal(params: {
armed: true,
changes,
complete,
ledgerComplete:
complete && (params.mode !== "detect" || (matchingCompletedLedger && allLegacy.length === 0)),
legacyAgentId,
mainKey,
outcomes,
@@ -666,6 +676,8 @@ async function migrateLegacyMainSessionKeysInternal(params: {
export async function migrateLegacyMainSessionKeys(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
/** Bypass the startup ledger shortcut and verify the physical legacy stores. */
forceScan?: boolean;
legacyAgentId?: string;
mode: LegacyMainSessionMigrationMode;
now?: () => number;
@@ -683,6 +695,7 @@ export async function migrateLegacyMainSessionKeys(params: {
armed: arming.armed,
changes: [],
complete: false,
ledgerComplete: false,
legacyAgentId,
mainKey,
outcomes: [{ kind: "store-unreadable", detail: String(error) }],
@@ -101,6 +101,7 @@ const mocks = vi.hoisted(() => ({
size: 0,
})),
rootWrite: vi.fn(async (_params?: unknown) => {}),
migrateLegacyMainSessionKeys: vi.fn(),
}));
const RESERVED_SYSTEM_AGENT_IDS_FOR_TEST = ["openclaw", "crestodian"] as const; // reserved ids
@@ -185,6 +186,10 @@ vi.mock("../../agents/auth-profiles/path-resolve.js", async () => ({
resolveSharedAuthStorePath: () => "/resolved/agents/main/agent/openclaw-agent.sqlite",
}));
vi.mock("../../config/sessions/legacy-main-session-migration.js", () => ({
migrateLegacyMainSessionKeys: mocks.migrateLegacyMainSessionKeys,
}));
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
listAgentEntries: mocks.listAgentEntries,
@@ -364,6 +369,17 @@ beforeEach(() => {
agentsTesting.resetDepsForTests();
mocks.omitConfigMutationResult = false;
mocks.sharedAuthStoreOwnership = { location: "legacy-main" };
mocks.migrateLegacyMainSessionKeys.mockReset().mockResolvedValue({
armed: true,
changes: [],
complete: true,
ledgerComplete: true,
legacyAgentId: "main",
mainKey: "main",
outcomes: [{ kind: "no-legacy-rows", detail: "matching completed ledger" }],
ownerAgentId: "robby",
warnings: [],
});
mocks.withAgentExecApprovalsRemoved
.mockReset()
.mockImplementation(async (_agentId: string, commit: () => Promise<unknown>) => await commit());
@@ -767,14 +783,31 @@ describe("agents.create", () => {
);
});
it("rejects creating an agent with reserved 'main' id", async () => {
it("routes main through the canonical shared-auth creation gate", async () => {
mocks.loadConfigReturn = { agents: { list: [{ id: "robby" }] } };
const { respond, promise } = makeCall("agents.create", {
name: "main",
workspace: "/tmp/ws",
});
await promise;
expectRespondErrorContaining(respond, "reserved");
expectRespondErrorContaining(respond, "owns the shared auth store");
expect(mocks.migrateLegacyMainSessionKeys).toHaveBeenCalledOnce();
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("creates main through the canonical service after both gates clear", async () => {
mocks.loadConfigReturn = { agents: { list: [{ id: "robby" }] } };
mocks.sharedAuthStoreOwnership = { location: "state-db" };
const { respond, promise } = makeCall("agents.create", {
name: "main",
workspace: "/tmp/ws",
});
await promise;
expectRespondOk(respond, { ok: true, agentId: "main", name: "main" });
expect(mocks.writeConfigFile).toHaveBeenCalledOnce();
});
it.each(RESERVED_SYSTEM_AGENT_IDS_FOR_TEST)(
+22
View File
@@ -449,6 +449,28 @@ describe("parseSystemAgentOperation", () => {
await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow();
});
it("delegates literal main to the canonical creation gate", async () => {
const tempDir = opTempDirs.make("openclaw-agent-main-gate-");
setTestEnvValue("OPENCLAW_STATE_DIR", tempDir);
const { runtime } = createSystemAgentTestRuntime();
const createAgent = vi.fn(async () => ({
status: "error" as const,
reason: "legacy-session-migration-required" as const,
agentId: "main",
message: "Run openclaw doctor --fix before creating main.",
}));
await expect(
executeSystemAgentOperation(
{ kind: "create-agent", agentId: "main", workspace: "/tmp/main" },
runtime,
{ approved: true, deps: { createAgent } },
),
).rejects.toThrow("Run openclaw doctor --fix before creating main.");
expect(createAgent).toHaveBeenCalledWith({ name: "main", workspace: "/tmp/main" });
});
it("keeps the retired agent identity reserved", async () => {
const { runtime } = createSystemAgentTestRuntime();
const createAgent = vi.fn();