mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
2af5eca07f
commit
e6eebc5ad8
+4
-1
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user