diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index e6f4d1737b9a..9be6893d0f42 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -80,7 +80,10 @@ not overwrite the existing skill. `--non-interactive`; omit `--classic` for automated setup. - `--agent-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 diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 3445a393d459..f7736ce50162 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -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` diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 8901305128b6..2869694233b4 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -202,7 +202,9 @@ Notes: - Default workspace (when `--workspace` is omitted in the interactive wizard): `~/.openclaw/workspace-`. - `--bind ` 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 diff --git a/src/agents/agent-create.integration.test.ts b/src/agents/agent-create.integration.test.ts index eb9e82bb3ed5..2bbaee61c02a 100644 --- a/src/agents/agent-create.integration.test.ts +++ b/src/agents/agent-create.integration.test.ts @@ -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(); + } +}); diff --git a/src/agents/agent-create.test.ts b/src/agents/agent-create.test.ts index e76f8f97195f..4a856acbd3ef 100644 --- a/src/agents/agent-create.test.ts +++ b/src/agents/agent-create.test.ts @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ mkdir: vi.fn(), readAgentDeletionJournal: vi.fn(() => undefined as Record | 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(); 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" }, diff --git a/src/agents/agent-create.ts b/src/agents/agent-create.ts index 0cd613e449f7..acaec03bf289 100644 --- a/src/agents/agent-create.ts +++ b/src/agents/agent-create.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { if ( @@ -231,11 +311,9 @@ export async function createAgent(params: CreateAgentParams): Promise 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 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 }) => { 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( @@ -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) => { diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 2f1697fd6f91..013000080c27 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -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); } diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index 5641b7567df4..2c5242cd5de2 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -130,6 +130,7 @@ describe("doctor session transcript repair", () => { armed: false, changes: [], complete: false, + ledgerComplete: false, legacyAgentId: "main", mainKey: "main", outcomes: [{ kind: "not-armed" }], diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index c7392f30c229..b70e3b8daa55 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -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", diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index b5808a5a61cb..443ebf27e2ae 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -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([LEGACY_IMPLICIT_AGENT_ID]); + const configuredIds = new Set(); + 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; } diff --git a/src/commands/onboard-agent.ts b/src/commands/onboard-agent.ts index 0a9876bcd1ca..1289bf05b631 100644 --- a/src/commands/onboard-agent.ts +++ b/src/commands/onboard-agent.ts @@ -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.`; } diff --git a/src/config/sessions/legacy-main-session-migration.contract.ts b/src/config/sessions/legacy-main-session-migration.contract.ts index f56973241761..198eb8d459cf 100644 --- a/src/config/sessions/legacy-main-session-migration.contract.ts +++ b/src/config/sessions/legacy-main-session-migration.contract.ts @@ -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[]; diff --git a/src/config/sessions/legacy-main-session-migration.ledger.test.ts b/src/config/sessions/legacy-main-session-migration.ledger.test.ts index c58384585b67..8638b233f428 100644 --- a/src/config/sessions/legacy-main-session-migration.ledger.test.ts +++ b/src/config/sessions/legacy-main-session-migration.ledger.test.ts @@ -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(); diff --git a/src/config/sessions/legacy-main-session-migration.startup.test.ts b/src/config/sessions/legacy-main-session-migration.startup.test.ts index bf1cb22585d5..e0d37a04772a 100644 --- a/src/config/sessions/legacy-main-session-migration.startup.test.ts +++ b/src/config/sessions/legacy-main-session-migration.startup.test.ts @@ -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 }], diff --git a/src/config/sessions/legacy-main-session-migration.ts b/src/config/sessions/legacy-main-session-migration.ts index f3a5171b3d3b..d7eeb7b61642 100644 --- a/src/config/sessions/legacy-main-session-migration.ts +++ b/src/config/sessions/legacy-main-session-migration.ts @@ -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) }], diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index d948f372492d..d3f840ca2bc5 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -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) => 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)( diff --git a/src/system-agent/operations.test.ts b/src/system-agent/operations.test.ts index ae792cac98d5..ad848274a3e1 100644 --- a/src/system-agent/operations.test.ts +++ b/src/system-agent/operations.test.ts @@ -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();