diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 5d368484469e..d8eafa6933c7 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -238,7 +238,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { const includeOwnsRoster = configIncludeOwnsAgentRoster(snapshot); if (snapshot.exists && rosterMigrationNeeded && !includeOwnsRoster) { // Runtime roster normalization is read-only; doctor --fix owns persistence. - const migrated = migratePersistedImplicitMainRoster(state.candidate).config as OpenClawConfig; + // Persist the legacy owner's workspace in doctor's canonical candidate. The writer may run + // again after health repairs, when the retired owner marker is no longer available to recover it. + const migrated = migratePersistedImplicitMainRoster(state.candidate, { + materializeWorkspace: true, + }).config as OpenClawConfig; const migratedRoster = readAgentRosterProperty(migrated); const migratedEntries = migratedRoster?.kind === "entries" ? migratedRoster.value : undefined; const { list: _legacyList, ...candidateAgents } = migrated.agents ?? {}; diff --git a/src/commands/doctor-config-flow.workspace-persistence.test.ts b/src/commands/doctor-config-flow.workspace-persistence.test.ts new file mode 100644 index 000000000000..0b2b0841df8b --- /dev/null +++ b/src/commands/doctor-config-flow.workspace-persistence.test.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readConfigFileSnapshot } from "../config/config.js"; +import { withEnvOverride, withTempHome, writeOpenClawConfig } from "../config/test-helpers.js"; +import { + runInitialConfigWriteHealth, + runWriteConfigHealth, +} from "../flows/doctor-health-contribution-runners.config.js"; +import type { DoctorHealthFlowContext } from "../flows/doctor-health-contribution-types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; +import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; + +describe("Doctor workspace persistence", () => { + afterEach(() => { + closeOpenClawStateDatabaseForTest(); + }); + + it("keeps the legacy owner on the shared workspace across later health writes", async () => { + await withTempHome(async (home) => { + await withEnvOverride({ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }, async () => { + const workspace = path.join(home, "shared-workspace"); + const configPath = await writeOpenClawConfig(home, { + agents: { + defaults: { workspace }, + entries: { + main: { default: true }, + cursor: { workspace }, + }, + }, + gateway: { mode: "local" }, + }); + const runtime: RuntimeEnv = { + error: vi.fn(), + exit: vi.fn(), + log: vi.fn(), + }; + const options: DoctorOptions = { nonInteractive: true, repair: true }; + const prompter = createDoctorPrompter({ runtime, options }); + const configResult = await loadAndMaybeMigrateDoctorConfig({ + options, + confirm: (params) => prompter.confirm(params), + runtime, + prompter, + }); + const ctx: DoctorHealthFlowContext = { + runtime, + options, + prompter, + configResult, + cfg: configResult.cfg, + cfgForPersistence: structuredClone(configResult.cfg), + sourceConfigValid: configResult.sourceConfigValid ?? true, + configPath, + stateDirExistedAtStart: true, + ...(configResult.runWithPluginMetadataSnapshot + ? { runWithPluginMetadataSnapshot: configResult.runWithPluginMetadataSnapshot } + : {}), + ...(configResult.invalidatePluginMetadataSnapshot + ? { invalidatePluginMetadataSnapshot: configResult.invalidatePluginMetadataSnapshot } + : {}), + }; + + await runInitialConfigWriteHealth(ctx); + expect((await readConfigFileSnapshot()).config.agents?.entries?.main?.workspace).toBe( + workspace, + ); + + ctx.cfg = { + ...ctx.cfg, + gateway: { ...ctx.cfg.gateway, bind: "lan" }, + }; + await runWriteConfigHealth(ctx); + + const snapshot = await readConfigFileSnapshot(); + expect(snapshot.valid).toBe(true); + expect(snapshot.config.agents?.ownership).toBe("explicit"); + expect(snapshot.config.agents?.entries?.main?.workspace).toBe(workspace); + }); + }); + }); +}); diff --git a/src/config/legacy.roster.ts b/src/config/legacy.roster.ts index e87d366f61a0..6feec3365abd 100644 --- a/src/config/legacy.roster.ts +++ b/src/config/legacy.roster.ts @@ -15,7 +15,10 @@ type MigrationResult = { retainedLegacyDefaultAgentId?: string; }; -export function migratePersistedImplicitMainRoster(raw: unknown): MigrationResult { +export function migratePersistedImplicitMainRoster( + raw: unknown, + options: { materializeWorkspace?: boolean } = {}, +): MigrationResult { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return { config: raw, changed: false, diagnostics: [] }; } @@ -124,6 +127,7 @@ export function migratePersistedImplicitMainRoster(raw: unknown): MigrationResul const materialized = materializeLegacyDefaultAgentRoles( nextRoot as OpenClawConfig, legacyDefaultAgentId, + options, ); nextRoot = materialized.config as Record; insertedPaths = materialized.insertedPaths;