mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(exec): stop isolated state dirs from moving live approvals (#108742)
* fix(exec): isolate approval state directories * chore: drop release-owned changelog entry * chore: refresh native i18n inventory * fix(ci): pin XcodeGen for Periphery scans
This commit is contained in:
committed by
GitHub
parent
7a18a781bc
commit
8fe4eeea9e
@@ -183,7 +183,6 @@ describe("ensureConfigReady", () => {
|
||||
migrateState: true,
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -205,7 +204,6 @@ describe("ensureConfigReady", () => {
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
observe: false,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,7 +218,6 @@ describe("ensureConfigReady", () => {
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
observe: false,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,7 +228,6 @@ describe("ensureConfigReady", () => {
|
||||
migrateState: true,
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
crossStateDirImports: false,
|
||||
requireStartupMigrationCheckpoint: true,
|
||||
});
|
||||
});
|
||||
@@ -264,7 +260,6 @@ describe("ensureConfigReady", () => {
|
||||
migrateState: true,
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,7 +283,6 @@ describe("ensureConfigReady", () => {
|
||||
migrateState: true,
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -313,7 +307,6 @@ describe("ensureConfigReady", () => {
|
||||
migrateState: true,
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
|
||||
migratedSnapshot.runtimeConfig,
|
||||
@@ -336,7 +329,7 @@ describe("ensureConfigReady", () => {
|
||||
{ commandPath: ["plugins", "list"], source: "exec-approvals.json" },
|
||||
{ commandPath: ["tasks", "list"], source: "plugin-binding-approvals.json" },
|
||||
])(
|
||||
"runs notice-only preflight for $commandPath with default-state $source",
|
||||
"ignores default-state $source while $commandPath uses custom state",
|
||||
async ({ commandPath, source }) => {
|
||||
const root = useTempOpenClawHome();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
@@ -347,14 +340,7 @@ describe("ensureConfigReady", () => {
|
||||
|
||||
await runEnsureConfigReady(commandPath);
|
||||
|
||||
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
|
||||
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
|
||||
migrateState: true,
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
...(commandPath[0] === "status" ? { observe: false } : {}),
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
|
||||
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
|
||||
expect(fs.existsSync(path.join(stateDir, "exec-approvals.json"))).toBe(false);
|
||||
|
||||
@@ -4,13 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { withSuppressedNotes } from "../../../packages/terminal-core/src/note.js";
|
||||
import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
|
||||
import {
|
||||
isNamedProfile,
|
||||
resolveLegacyStateDirs,
|
||||
resolveNewStateDir,
|
||||
resolveOAuthDir,
|
||||
resolveStateDir,
|
||||
} from "../../config/paths.js";
|
||||
import { resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir } from "../../config/paths.js";
|
||||
import type { ConfigFileSnapshot } from "../../config/types.js";
|
||||
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
import { ExitError, type RuntimeEnv } from "../../runtime.js";
|
||||
@@ -101,23 +95,6 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir:
|
||||
return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile);
|
||||
}
|
||||
|
||||
function hasCrossStateDirApprovalMigrationInputs(stateDir: string): boolean {
|
||||
if (!process.env.OPENCLAW_STATE_DIR?.trim() || isNamedProfile()) {
|
||||
return false;
|
||||
}
|
||||
const homeDir = resolveRequiredHomeDir(process.env, os.homedir);
|
||||
const defaultStateDir = resolveNewStateDir(() => homeDir);
|
||||
if (path.resolve(defaultStateDir) === path.resolve(stateDir)) {
|
||||
return false;
|
||||
}
|
||||
const execApprovalsSource = path.join(defaultStateDir, "exec-approvals.json");
|
||||
const execApprovalsTarget = path.join(stateDir, "exec-approvals.json");
|
||||
return (
|
||||
(fileOrDirExists(execApprovalsSource) && !fileOrDirExists(execApprovalsTarget)) ||
|
||||
fileOrDirExists(path.join(defaultStateDir, "plugin-binding-approvals.json"))
|
||||
);
|
||||
}
|
||||
|
||||
function hasPendingSqliteSidecarArchive(sourcePath: string): boolean {
|
||||
return (
|
||||
fileOrDirExists(`${sourcePath}.migrated`) &&
|
||||
@@ -153,8 +130,7 @@ function hasLegacyStateMigrationInputs(): boolean {
|
||||
sqliteSidecarPaths.some(
|
||||
(sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath),
|
||||
) ||
|
||||
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) ||
|
||||
hasCrossStateDirApprovalMigrationInputs(stateDir)
|
||||
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,7 +208,6 @@ export async function ensureConfigReady(params: {
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
...(commandName === "status" ? { observe: false } : {}),
|
||||
crossStateDirImports: false,
|
||||
...(shouldRequireStartupMigrationCheckpoint(commandPath)
|
||||
? { requireStartupMigrationCheckpoint: true }
|
||||
: {}),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Register maintenance tests cover maintenance command registration in the CLI program.
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerMaintenanceCommands } from "./register.maintenance.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -69,10 +68,6 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("exits with code 0 after successful doctor run", async () => {
|
||||
doctorCommand.mockResolvedValue(undefined);
|
||||
|
||||
@@ -106,28 +101,6 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
const [runtimeArg, options] = commandCall(doctorCommand);
|
||||
expect(runtimeArg).toBe(runtime);
|
||||
expect(options.repair).toBe(true);
|
||||
expect(options.crossStateDirImports).toBe(true);
|
||||
});
|
||||
|
||||
it("denies cross-state imports when an automation parent disables them", async () => {
|
||||
doctorCommand.mockResolvedValue(undefined);
|
||||
vi.stubEnv(DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV, "1");
|
||||
|
||||
await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]);
|
||||
|
||||
const [, options] = commandCall(doctorCommand);
|
||||
expect(options.repair).toBe(true);
|
||||
expect(options.crossStateDirImports).toBe(false);
|
||||
});
|
||||
|
||||
it("denies cross-state imports for older update parents", async () => {
|
||||
doctorCommand.mockResolvedValue(undefined);
|
||||
vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1");
|
||||
|
||||
await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]);
|
||||
|
||||
const [, options] = commandCall(doctorCommand);
|
||||
expect(options.crossStateDirImports).toBe(false);
|
||||
});
|
||||
|
||||
it("passes session sqlite options to doctor command", async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
|
||||
import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { resolveDoctorCrossStateDirImports } from "../../commands/doctor-invocation.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { hasExplicitOptions } from "../command-options.js";
|
||||
@@ -171,7 +170,6 @@ export function registerMaintenanceCommands(program: Command) {
|
||||
sessionSqliteAllAgents: Boolean(opts.sessionSqliteAllAgents),
|
||||
sessionSqliteGithubIssue: Boolean(opts.githubIssue),
|
||||
json: Boolean(opts.json),
|
||||
crossStateDirImports: resolveDoctorCrossStateDirImports(),
|
||||
});
|
||||
defaultRuntime.exit(0);
|
||||
});
|
||||
|
||||
@@ -7467,7 +7467,6 @@ describe("update-cli", () => {
|
||||
nonInteractive: true,
|
||||
repair: true,
|
||||
yes: true,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
expect(syncPluginCall()?.channel).toBe("stable");
|
||||
expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true);
|
||||
@@ -7568,7 +7567,6 @@ describe("update-cli", () => {
|
||||
nonInteractive: true,
|
||||
repair: true,
|
||||
yes: false,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
expect(syncPluginCall()?.channel).toBe("beta");
|
||||
expect(syncPluginCall()?.config).toEqual({
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
|
||||
import { doctorCommand } from "../../commands/doctor.js";
|
||||
import {
|
||||
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
|
||||
@@ -231,7 +230,6 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
|
||||
nonInteractive: true,
|
||||
repair: true,
|
||||
yes: opts.yes === true,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
|
||||
if (requestedChannel) {
|
||||
@@ -520,7 +518,6 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
|
||||
env: {
|
||||
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
|
||||
[POST_CORE_UPDATE_ENV]: "1",
|
||||
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
|
||||
...(params.requestedChannel
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
checkShellCompletionStatus,
|
||||
ensureCompletionCacheExists,
|
||||
} from "../../commands/doctor-completion.js";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
|
||||
import { doctorCommand } from "../../commands/doctor.js";
|
||||
import { UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV } from "../../commands/doctor/shared/update-phase.js";
|
||||
import { resolveGatewayPort } from "../../config/config.js";
|
||||
@@ -880,7 +879,6 @@ export function resolvePostInstallDoctorEnv(params?: {
|
||||
}): NodeJS.ProcessEnv {
|
||||
const resolvedEnv: NodeJS.ProcessEnv = {
|
||||
...disableUpdatedPackageCompileCacheEnv(params?.baseEnv ?? process.env),
|
||||
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
|
||||
};
|
||||
if (!params?.serviceEnv) {
|
||||
return resolvedEnv;
|
||||
@@ -1436,7 +1434,6 @@ export async function maybeRestartService(params: {
|
||||
process.stdin.isTTY && !params.opts.json && params.opts.yes !== true;
|
||||
await doctorCommand(defaultRuntime, {
|
||||
nonInteractive: !interactiveDoctor,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
|
||||
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
|
||||
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
|
||||
@@ -208,7 +207,6 @@ describe("resolvePostInstallDoctorEnv", () => {
|
||||
|
||||
expect(env.PATH).toBe("/bin");
|
||||
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
|
||||
expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1");
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe(path.join("/srv/openclaw", "daemon-state"));
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBe(
|
||||
path.join("/srv/openclaw", "daemon-state", "openclaw.json"),
|
||||
@@ -227,7 +225,6 @@ describe("resolvePostInstallDoctorEnv", () => {
|
||||
|
||||
expect(env.PATH).toBe("/bin");
|
||||
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
|
||||
expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1");
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe("/caller/state");
|
||||
expect(env.OPENCLAW_PROFILE).toBe("caller");
|
||||
});
|
||||
|
||||
@@ -1538,30 +1538,6 @@ describe("doctor config flow", () => {
|
||||
runDoctorConfigPreflightOptionsMock.mockClear();
|
||||
});
|
||||
|
||||
it("grants config preflight cross-state imports only with repair and direct capability", async () => {
|
||||
await runDoctorConfigWithInput({
|
||||
config: {},
|
||||
repair: true,
|
||||
run: ({ options, confirm }) =>
|
||||
loadAndMaybeMigrateDoctorConfig({
|
||||
options: { ...options, crossStateDirImports: true },
|
||||
confirm: async () => confirm(),
|
||||
}),
|
||||
});
|
||||
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ crossStateDirImports: true }),
|
||||
);
|
||||
|
||||
await runDoctorConfigWithInput({
|
||||
config: {},
|
||||
repair: true,
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ crossStateDirImports: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves invalid config for doctor repairs", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
config: {
|
||||
|
||||
@@ -142,7 +142,6 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
const preflight = await runDoctorConfigPreflight({
|
||||
repairPrefixedConfig: shouldRepair,
|
||||
recoverCorruptTargetStore: shouldRepair,
|
||||
crossStateDirImports: shouldRepair && params.options.crossStateDirImports === true,
|
||||
});
|
||||
const snapshot = preflight.snapshot;
|
||||
const baseCfg = preflight.baseConfig;
|
||||
|
||||
@@ -225,13 +225,6 @@ export async function runDoctorConfigPreflight(
|
||||
skipPristineCoreStateMigrations?: boolean;
|
||||
/** Prepared before Gateway bootstrap can create files under an otherwise pristine state root. */
|
||||
skipPristineStartupStateMigrations?: boolean;
|
||||
/**
|
||||
* Allows legacy imports whose source lives in the DEFAULT home state dir
|
||||
* while OPENCLAW_STATE_DIR points elsewhere. Only explicit doctor repair
|
||||
* runs opt in; the implicit CLI/gateway preflight must never archive
|
||||
* files that belong to another install's state dir.
|
||||
*/
|
||||
crossStateDirImports?: boolean;
|
||||
} = {},
|
||||
): Promise<DoctorConfigPreflightResult> {
|
||||
const stateMigrationsRequested = options.migrateState !== false;
|
||||
@@ -420,7 +413,6 @@ export async function runDoctorConfigPreflight(
|
||||
: {}),
|
||||
env: process.env,
|
||||
recoverCorruptTargetStore: options.recoverCorruptTargetStore,
|
||||
crossStateDirImports: options.crossStateDirImports,
|
||||
}),
|
||||
);
|
||||
} else if (stateMigrationInput.pluginDoctorConfig) {
|
||||
@@ -433,7 +425,6 @@ export async function runDoctorConfigPreflight(
|
||||
noteStartupStateMigrationResult(
|
||||
await autoMigrateLegacyTaskStateSidecars({
|
||||
env: process.env,
|
||||
crossStateDirImports: options.crossStateDirImports,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -441,7 +432,6 @@ export async function runDoctorConfigPreflight(
|
||||
noteStartupStateMigrationResult(
|
||||
await autoMigrateLegacyTaskStateSidecars({
|
||||
env: process.env,
|
||||
crossStateDirImports: options.crossStateDirImports,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/** Internal doctor invocation capabilities shared by direct and automated callers. */
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { UPDATE_IN_PROGRESS_ENV } from "./doctor/shared/update-phase.js";
|
||||
|
||||
export const DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV =
|
||||
"OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS";
|
||||
|
||||
/** Direct CLI doctor owns cross-state imports unless its automation parent denies them. */
|
||||
export function resolveDoctorCrossStateDirImports(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
// Older update parents know only OPENCLAW_UPDATE_IN_PROGRESS. Treat that
|
||||
// existing cross-version handshake as deny-by-default for a newer doctor.
|
||||
return !(
|
||||
isTruthyEnvValue(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]) ||
|
||||
isTruthyEnvValue(env[UPDATE_IN_PROGRESS_ENV])
|
||||
);
|
||||
}
|
||||
@@ -2820,148 +2820,9 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates default exec approvals into an explicit state dir", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
const legacySocketPath = path.join(root, ".openclaw", "exec-approvals.sock");
|
||||
const targetPath = path.join(stateDir, "exec-approvals.json");
|
||||
const targetSocketPath = path.join(stateDir, "exec-approvals.sock");
|
||||
writeJson5(sourcePath, {
|
||||
version: 1,
|
||||
socket: {
|
||||
path: legacySocketPath,
|
||||
token: "legacy-token",
|
||||
},
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
},
|
||||
agents: {
|
||||
main: {
|
||||
allowlist: [{ pattern: "git status" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
homedir: () => root,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
expect(detected.preview).toContain(`- Exec approvals: ${sourcePath} → ${targetPath}`);
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
expect(result.changes).toContain(`Archived legacy exec approvals → ${sourcePath}.migrated`);
|
||||
expect(fs.existsSync(sourcePath)).toBe(false);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
|
||||
const migrated = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
|
||||
socket?: { path?: string; token?: string };
|
||||
defaults?: Record<string, unknown>;
|
||||
agents?: Record<string, { allowlist?: Array<Record<string, unknown>> }>;
|
||||
};
|
||||
expect(migrated.socket?.path).toBe(targetSocketPath);
|
||||
expect(migrated.socket?.token).toBe("legacy-token");
|
||||
expect(migrated.defaults).toEqual({
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
});
|
||||
expect(migrated.agents?.main?.allowlist?.[0]?.pattern).toBe("git status");
|
||||
});
|
||||
|
||||
it("skips exec approvals migration when the target appears after detection", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
const targetPath = path.join(stateDir, "exec-approvals.json");
|
||||
writeJson5(sourcePath, {
|
||||
version: 1,
|
||||
socket: {
|
||||
token: "legacy-token",
|
||||
},
|
||||
defaults: {
|
||||
security: "deny",
|
||||
},
|
||||
});
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
homedir: () => root,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
writeJson5(targetPath, {
|
||||
version: 1,
|
||||
socket: {
|
||||
path: path.join(stateDir, "exec-approvals.sock"),
|
||||
token: "current-token",
|
||||
},
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
const current = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
|
||||
socket?: { token?: string };
|
||||
defaults?: Record<string, unknown>;
|
||||
};
|
||||
expect(current.socket?.token).toBe("current-token");
|
||||
expect(current.defaults).toEqual({
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-migrates exec approvals without a valid config snapshot when doctor opts in", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
const targetPath = path.join(stateDir, "exec-approvals.json");
|
||||
writeJson5(sourcePath, {
|
||||
version: 1,
|
||||
socket: {
|
||||
token: "legacy-token",
|
||||
},
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await autoMigrateLegacyTaskStateSidecars({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
homedir: () => root,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
expect(result.changes).toContain(`Archived legacy exec approvals → ${sourcePath}.migrated`);
|
||||
expect(fs.existsSync(sourcePath)).toBe(false);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
|
||||
const migrated = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
|
||||
socket?: { token?: string };
|
||||
defaults?: Record<string, unknown>;
|
||||
};
|
||||
expect(migrated.socket?.token).toBe("legacy-token");
|
||||
expect(migrated.defaults).toEqual({
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps default exec approvals in place without the cross-state-dir opt-in", async () => {
|
||||
// Regression: the implicit preflight (every CLI command, gateway startup)
|
||||
// must never archive files that belong to the default state dir just
|
||||
// because OPENCLAW_STATE_DIR points somewhere else.
|
||||
it("never imports default exec approvals into a custom state dir", async () => {
|
||||
// Regression: every custom state root is an independent trust scope.
|
||||
// Even direct doctor repair must not copy or archive default approvals.
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
@@ -2978,22 +2839,23 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
const sourceRaw = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
const result = await autoMigrateLegacyTaskStateSidecars({
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
homedir: () => root,
|
||||
});
|
||||
expect(detected.preview.some((entry) => entry.includes("Exec approvals"))).toBe(false);
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
expect(result.notices?.join("\n")).toContain(
|
||||
"Exec approvals in the default state dir were not imported",
|
||||
);
|
||||
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
|
||||
expect(fs.existsSync(targetPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps default exec approvals in place when autoMigrateLegacyState runs without the opt-in", async () => {
|
||||
it("keeps default exec approvals in place during automatic state migration", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
@@ -3018,9 +2880,6 @@ describe("doctor legacy state migrations", () => {
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
expect(result.notices?.join("\n")).toContain(
|
||||
"Exec approvals in the default state dir were not imported",
|
||||
);
|
||||
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
|
||||
expect(fs.existsSync(targetPath)).toBe(false);
|
||||
|
||||
@@ -317,11 +317,6 @@ function createLegacyStateMigrationDetectionResult(params?: {
|
||||
accountIds: {},
|
||||
hasLegacy: false,
|
||||
},
|
||||
execApprovals: {
|
||||
sourcePath: "/tmp/state/exec-approvals.legacy.json",
|
||||
targetPath: "/tmp/state/exec-approvals.json",
|
||||
hasLegacy: false,
|
||||
},
|
||||
channelPlans: {
|
||||
hasLegacy: false,
|
||||
plans: [],
|
||||
|
||||
@@ -16,6 +16,4 @@ export type DoctorOptions = {
|
||||
sessionSqliteAllAgents?: boolean;
|
||||
sessionSqliteGithubIssue?: boolean;
|
||||
json?: boolean;
|
||||
/** Internal capability granted only to direct operator-owned doctor invocations. */
|
||||
crossStateDirImports?: boolean;
|
||||
};
|
||||
|
||||
@@ -1461,7 +1461,6 @@ describe("doctor health contributions", () => {
|
||||
expect(mocks.detectLegacyStateMigrations).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({
|
||||
detected,
|
||||
@@ -1470,51 +1469,6 @@ describe("doctor health contributions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("grants legacy-state cross-state imports only to capable doctor origins", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:legacy-state");
|
||||
const detected = { preview: [], warnings: [], notices: [] };
|
||||
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
|
||||
|
||||
const directRepairContext = {
|
||||
cfg: {},
|
||||
sourceConfigValid: true,
|
||||
prompter: buildDoctorPrompter(true),
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
options: { nonInteractive: true, repair: true, crossStateDirImports: true },
|
||||
} as unknown as Parameters<(typeof contribution)["run"]>[0];
|
||||
await contribution.run(directRepairContext);
|
||||
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
|
||||
cfg: {},
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
|
||||
const interactivePrompter = buildDoctorPrompter(false);
|
||||
interactivePrompter.repairMode.canPrompt = true;
|
||||
interactivePrompter.repairMode.nonInteractive = false;
|
||||
await contribution.run({
|
||||
...directRepairContext,
|
||||
prompter: interactivePrompter,
|
||||
options: { crossStateDirImports: true },
|
||||
});
|
||||
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
|
||||
cfg: {},
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
|
||||
const automatedRepairContext = {
|
||||
...directRepairContext,
|
||||
options: { nonInteractive: true, repair: true, crossStateDirImports: false },
|
||||
};
|
||||
await contribution.run(automatedRepairContext);
|
||||
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
|
||||
cfg: {},
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prints legacy state migration notices during manual doctor runs", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:legacy-state");
|
||||
const detected = { preview: ["legacy sessions"], warnings: [], notices: [] };
|
||||
|
||||
@@ -518,16 +518,9 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
|
||||
await import("../commands/doctor-state-migrations.js");
|
||||
const { note } = await loadNoteModule();
|
||||
// Only a direct operator-owned doctor may inspect the default state dir for
|
||||
// imports. Automated repair callers explicitly lack this capability so a
|
||||
// temporary OPENCLAW_STATE_DIR cannot capture and archive production trust.
|
||||
const operatorCanApproveCrossStateDirImports =
|
||||
ctx.prompter.repairMode.canPrompt || ctx.prompter.shouldRepair;
|
||||
const legacyState = await detectLegacyStateMigrations({
|
||||
cfg: ctx.cfg,
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports:
|
||||
ctx.options.crossStateDirImports === true && operatorCanApproveCrossStateDirImports,
|
||||
});
|
||||
if (legacyState.warnings.length > 0) {
|
||||
note(legacyState.warnings.join("\n"), "Doctor warnings");
|
||||
|
||||
@@ -292,7 +292,7 @@ describe("exec approvals store helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed without writing target approvals before state migration runs", async () => {
|
||||
it("keeps custom-state approvals independent from the default state", async () => {
|
||||
const dir = createHomeDir();
|
||||
const stateDir = path.join(dir, "custom-state");
|
||||
fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true });
|
||||
@@ -312,6 +312,7 @@ describe("exec approvals store helpers", () => {
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const defaultBefore = fs.readFileSync(approvalsFilePath(dir), "utf8");
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
const resolved = await resolveExecApprovals("main", {
|
||||
@@ -319,28 +320,25 @@ describe("exec approvals store helpers", () => {
|
||||
ask: "off",
|
||||
});
|
||||
|
||||
expect(resolved.agent.security).toBe("deny");
|
||||
expect(resolved.agent.ask).toBe("always");
|
||||
expect(resolved.agent.security).toBe("full");
|
||||
expect(resolved.agent.ask).toBe("off");
|
||||
expect(resolved.token).toBe("");
|
||||
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false);
|
||||
expect(fs.existsSync(approvalsFilePath(dir))).toBe(true);
|
||||
|
||||
const ensured = ensureExecApprovals();
|
||||
|
||||
expect(ensured.defaults).toEqual({
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: undefined,
|
||||
});
|
||||
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false);
|
||||
expect(ensured.socket?.token).not.toBe("legacy-token");
|
||||
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(true);
|
||||
|
||||
await expect(
|
||||
updateExecApprovals({
|
||||
update: (current) => ({ ...current, defaults: { security: "full" } }),
|
||||
}),
|
||||
).rejects.toThrow("must be migrated");
|
||||
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false);
|
||||
await updateExecApprovals({
|
||||
update: (current) => ({ ...current, defaults: { security: "allowlist" } }),
|
||||
});
|
||||
const custom = JSON.parse(
|
||||
fs.readFileSync(stateApprovalsFilePath(stateDir), "utf8"),
|
||||
) as ExecApprovalsFile;
|
||||
expect(custom.defaults?.security).toBe("allowlist");
|
||||
expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toBe(defaultBefore);
|
||||
expect(fs.existsSync(`${approvalsFilePath(dir)}.migrated`)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps named-profile approvals isolated from the default profile", () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
normalizeOptionalString,
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { isNamedProfile } from "../config/paths.js";
|
||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||
import { resolveGlobalMap } from "../shared/global-singleton.js";
|
||||
import { getFileLockProcessStartTime } from "../shared/pid-alive.js";
|
||||
@@ -413,34 +412,6 @@ export function resolveExecApprovalsTranscriptPath(): string {
|
||||
: `${DEFAULT_EXEC_APPROVALS_STATE_DIR}/${EXEC_APPROVALS_FILE}`;
|
||||
}
|
||||
|
||||
function resolveLegacyExecApprovalsPath(): string {
|
||||
return path.join(expandHomePrefix(DEFAULT_EXEC_APPROVALS_STATE_DIR), EXEC_APPROVALS_FILE);
|
||||
}
|
||||
|
||||
function hasUnmigratedLegacyExecApprovals(filePath: string): boolean {
|
||||
if (!process.env.OPENCLAW_STATE_DIR?.trim() || isNamedProfile()) {
|
||||
return false;
|
||||
}
|
||||
const legacyPath = resolveLegacyExecApprovalsPath();
|
||||
return (
|
||||
path.resolve(legacyPath) !== path.resolve(filePath) &&
|
||||
!fs.existsSync(filePath) &&
|
||||
fs.existsSync(legacyPath)
|
||||
);
|
||||
}
|
||||
|
||||
function createUnmigratedLegacyExecApprovalsFallback(): ExecApprovalsFile {
|
||||
return normalizeExecApprovals({
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
askFallback: "deny",
|
||||
},
|
||||
agents: {},
|
||||
});
|
||||
}
|
||||
|
||||
function createFailClosedExecApprovalsFallback(): ExecApprovalsFile {
|
||||
return normalizeExecApprovals({
|
||||
version: 1,
|
||||
@@ -1066,16 +1037,6 @@ function generateToken(): string {
|
||||
|
||||
function readExecApprovalsSnapshotUnlocked(): ExecApprovalsSnapshot {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
const file = createUnmigratedLegacyExecApprovalsFallback();
|
||||
return {
|
||||
path: filePath,
|
||||
exists: false,
|
||||
raw: null,
|
||||
file,
|
||||
hash: hashExecApprovalsRaw(null),
|
||||
};
|
||||
}
|
||||
return readExecApprovalsSnapshotFromPath(filePath);
|
||||
}
|
||||
|
||||
@@ -1090,9 +1051,6 @@ export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot {
|
||||
|
||||
function loadExecApprovalsUnlocked(): ExecApprovalsFile {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
return createUnmigratedLegacyExecApprovalsFallback();
|
||||
}
|
||||
try {
|
||||
return readExecApprovalsSnapshotFromPath(filePath).file;
|
||||
} catch {
|
||||
@@ -1293,9 +1251,6 @@ type ExecApprovalsUpdate = {
|
||||
|
||||
function updateExecApprovalsUnlocked(params: ExecApprovalsUpdate): ExecApprovalsSnapshot | null {
|
||||
// Both sync and async entry points hold the sidecar lock across this full CAS transaction.
|
||||
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
|
||||
throw new Error("Exec approvals must be migrated before they can be updated");
|
||||
}
|
||||
const current = readExecApprovalsSnapshotUnlocked();
|
||||
if (params.baseHash !== undefined && current.hash !== params.baseHash) {
|
||||
return null;
|
||||
@@ -1465,27 +1420,18 @@ function requireInitializedExecApprovals(
|
||||
}
|
||||
|
||||
export async function ensureExecApprovalsSnapshot(): Promise<ExecApprovalsSnapshot> {
|
||||
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
|
||||
return readExecApprovalsSnapshot();
|
||||
}
|
||||
return requireInitializedExecApprovals(
|
||||
await updateExecApprovals({ update: ensureExecApprovalsSocket }),
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureExecApprovals(): ExecApprovalsFile {
|
||||
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
|
||||
return createUnmigratedLegacyExecApprovalsFallback();
|
||||
}
|
||||
return requireInitializedExecApprovals(
|
||||
updateExecApprovalsSync({ update: ensureExecApprovalsSocket }),
|
||||
).file;
|
||||
}
|
||||
|
||||
function readExecApprovalsForNoPersistenceUnlocked(filePath: string): ExecApprovalsFile {
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
return createUnmigratedLegacyExecApprovalsFallback();
|
||||
}
|
||||
try {
|
||||
return readExecApprovalsSnapshotFromPath(filePath).file;
|
||||
} catch (err) {
|
||||
@@ -1670,15 +1616,6 @@ export function resolveExecApprovals(
|
||||
overrides?: ExecApprovalsDefaultOverrides,
|
||||
): ExecApprovalsResolved {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
return shapeResolvedExecApprovals({
|
||||
file: createUnmigratedLegacyExecApprovalsFallback(),
|
||||
filePath,
|
||||
agentId,
|
||||
overrides,
|
||||
socket: "none",
|
||||
});
|
||||
}
|
||||
if (!overrides?.requireSocket) {
|
||||
const file = withExecApprovalsReadLockSync(filePath, () =>
|
||||
readExecApprovalsForNoPersistenceUnlocked(filePath),
|
||||
@@ -1708,15 +1645,6 @@ export async function resolveExecApprovalsLocked(
|
||||
overrides?: ExecApprovalsDefaultOverrides,
|
||||
): Promise<ExecApprovalsResolved> {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
return shapeResolvedExecApprovals({
|
||||
file: createUnmigratedLegacyExecApprovalsFallback(),
|
||||
filePath,
|
||||
agentId,
|
||||
overrides,
|
||||
socket: "none",
|
||||
});
|
||||
}
|
||||
if (!overrides?.requireSocket) {
|
||||
const file = await withExecApprovalsReadLock(filePath, async () =>
|
||||
readExecApprovalsForNoPersistenceUnlocked(filePath),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin } from "../channels/plugins/registry.js";
|
||||
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
import { isNamedProfile, resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
@@ -43,10 +43,6 @@ import {
|
||||
detectLegacyDebugProxyCaptureSidecar,
|
||||
migrateLegacyDebugProxyCaptureSidecar,
|
||||
} from "./state-migrations.debug-proxy.js";
|
||||
import {
|
||||
detectLegacyExecApprovalsMigration,
|
||||
migrateLegacyExecApprovals,
|
||||
} from "./state-migrations.exec-approvals.js";
|
||||
import {
|
||||
existsDir,
|
||||
fileExists,
|
||||
@@ -245,28 +241,12 @@ export async function detectLegacyStateMigrations(params: {
|
||||
homedir?: () => string;
|
||||
pluginSessionStoreAgentIds?: readonly string[];
|
||||
sessionStoreOwnership?: SessionStoreOwnership;
|
||||
crossStateDirImports?: boolean;
|
||||
doctorOnlyStateMigrations?: boolean;
|
||||
}): Promise<LegacyStateDetection> {
|
||||
const env = params.env ?? process.env;
|
||||
const homedir = params.homedir ?? os.homedir;
|
||||
const stateDir = resolveStateDir(env, homedir);
|
||||
const oauthDir = resolveOAuthDir(env, stateDir);
|
||||
// Sources under the DEFAULT home state dir are foreign state when
|
||||
// OPENCLAW_STATE_DIR points elsewhere: an isolated/test gateway must never
|
||||
// import (and archive) another install's files. Only an explicit doctor run
|
||||
// opts into the cross-directory import.
|
||||
const crossStateDirImports = params.crossStateDirImports === true;
|
||||
const notices: string[] = [];
|
||||
const detectedExecApprovals = detectLegacyExecApprovalsMigration({ env, homedir, stateDir });
|
||||
const execApprovals = crossStateDirImports
|
||||
? detectedExecApprovals
|
||||
: { ...detectedExecApprovals, hasLegacy: false };
|
||||
if (detectedExecApprovals.hasLegacy && !crossStateDirImports) {
|
||||
notices.push(
|
||||
`Exec approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${detectedExecApprovals.sourcePath} -> ${detectedExecApprovals.targetPath}); run \`openclaw doctor --fix\` to import them.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg));
|
||||
const rawMainKey = params.cfg.session?.mainKey;
|
||||
@@ -393,22 +373,9 @@ export async function detectLegacyStateMigrations(params: {
|
||||
const pluginBindingApprovals = {
|
||||
sourcePath: resolveLegacyPluginBindingApprovalsPath(env, homedir),
|
||||
};
|
||||
const pluginBindingApprovalsCrossDir =
|
||||
path.resolve(path.dirname(pluginBindingApprovals.sourcePath)) !== path.resolve(stateDir);
|
||||
const hasPluginBindingApprovals =
|
||||
!isNamedProfile(env) &&
|
||||
fileExists(pluginBindingApprovals.sourcePath) &&
|
||||
(crossStateDirImports || !pluginBindingApprovalsCrossDir);
|
||||
if (
|
||||
!isNamedProfile(env) &&
|
||||
fileExists(pluginBindingApprovals.sourcePath) &&
|
||||
pluginBindingApprovalsCrossDir &&
|
||||
!crossStateDirImports
|
||||
) {
|
||||
notices.push(
|
||||
`Plugin binding approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${pluginBindingApprovals.sourcePath}); run \`openclaw doctor --fix\` to import them.`,
|
||||
);
|
||||
}
|
||||
path.resolve(path.dirname(pluginBindingApprovals.sourcePath)) === path.resolve(stateDir) &&
|
||||
fileExists(pluginBindingApprovals.sourcePath);
|
||||
const currentConversationBindings = {
|
||||
sourcePath: resolveLegacyCurrentConversationBindingsPath(stateDir),
|
||||
};
|
||||
@@ -613,9 +580,6 @@ export async function detectLegacyStateMigrations(params: {
|
||||
if (channelPairing.hasLegacy) {
|
||||
preview.push("- Channel pairing state: legacy JSON files → shared SQLite state");
|
||||
}
|
||||
if (execApprovals.hasLegacy) {
|
||||
preview.push(`- Exec approvals: ${execApprovals.sourcePath} → ${execApprovals.targetPath}`);
|
||||
}
|
||||
if (channelPlans.length > 0) {
|
||||
preview.push(...channelPlans.map(buildLegacyMigrationPreview));
|
||||
}
|
||||
@@ -704,9 +668,8 @@ export async function detectLegacyStateMigrations(params: {
|
||||
subagentRegistry,
|
||||
rescuePending,
|
||||
channelPairing,
|
||||
execApprovals,
|
||||
warnings: pluginPlanWarnings,
|
||||
notices,
|
||||
notices: [],
|
||||
preview,
|
||||
};
|
||||
}
|
||||
@@ -921,7 +884,6 @@ export async function runLegacyStateMigrations(params: {
|
||||
detected: detected.channelPairing,
|
||||
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
|
||||
});
|
||||
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
|
||||
const preSessionChannelPlans = await runLegacyMigrationPlans(
|
||||
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
|
||||
);
|
||||
@@ -978,7 +940,6 @@ export async function runLegacyStateMigrations(params: {
|
||||
...subagentRegistry.changes,
|
||||
...rescuePending.changes,
|
||||
...channelPairing.changes,
|
||||
...execApprovals.changes,
|
||||
...preSessionChannelPlans.changes,
|
||||
...pluginPlans.changes,
|
||||
...sessions.changes,
|
||||
@@ -1008,7 +969,6 @@ export async function runLegacyStateMigrations(params: {
|
||||
...subagentRegistry.warnings,
|
||||
...rescuePending.warnings,
|
||||
...channelPairing.warnings,
|
||||
...execApprovals.warnings,
|
||||
...preSessionChannelPlans.warnings,
|
||||
...pluginPlans.warnings,
|
||||
...sessions.warnings,
|
||||
@@ -1039,7 +999,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
log?: MigrationLogger;
|
||||
now?: () => number;
|
||||
recoverCorruptTargetStore?: boolean;
|
||||
crossStateDirImports?: boolean;
|
||||
}): Promise<{
|
||||
migrated: boolean;
|
||||
skipped: boolean;
|
||||
@@ -1127,7 +1086,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
sessionStoreOwnership,
|
||||
env,
|
||||
homedir: params.homedir,
|
||||
crossStateDirImports: params.crossStateDirImports,
|
||||
});
|
||||
const hasCustomAgentDir = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (hasCustomAgentDir) {
|
||||
@@ -1171,7 +1129,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
detected: detected.channelPairing,
|
||||
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
|
||||
});
|
||||
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
|
||||
const preSessionChannelPlans = await runLegacyMigrationPlans(
|
||||
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
|
||||
);
|
||||
@@ -1196,7 +1153,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
...pluginBindingApprovals.changes,
|
||||
...currentConversationBindings.changes,
|
||||
...channelPairing.changes,
|
||||
...execApprovals.changes,
|
||||
...preSessionChannelPlans.changes,
|
||||
...pluginPlans.changes,
|
||||
];
|
||||
@@ -1217,7 +1173,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
...pluginBindingApprovals.warnings,
|
||||
...currentConversationBindings.warnings,
|
||||
...channelPairing.warnings,
|
||||
...execApprovals.warnings,
|
||||
...preSessionChannelPlans.warnings,
|
||||
...pluginPlans.warnings,
|
||||
];
|
||||
@@ -1241,7 +1196,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
pluginBindingApprovals.changes.length > 0 ||
|
||||
currentConversationBindings.changes.length > 0 ||
|
||||
channelPairing.changes.length > 0 ||
|
||||
execApprovals.changes.length > 0 ||
|
||||
preSessionChannelPlans.changes.length > 0 ||
|
||||
pluginPlans.changes.length > 0,
|
||||
skipped: true,
|
||||
@@ -1266,8 +1220,7 @@ export async function autoMigrateLegacyState(params: {
|
||||
!detected.configHealth.hasLegacy &&
|
||||
!detected.pluginBindingApprovals.hasLegacy &&
|
||||
!detected.currentConversationBindings.hasLegacy &&
|
||||
!detected.channelPairing.hasLegacy &&
|
||||
!detected.execApprovals.hasLegacy
|
||||
!detected.channelPairing.hasLegacy
|
||||
) {
|
||||
const changes = [
|
||||
...stateDirResult.changes,
|
||||
@@ -1338,7 +1291,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
detected: detected.channelPairing,
|
||||
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
|
||||
});
|
||||
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
|
||||
const preSessionChannelPlans = await runLegacyMigrationPlans(
|
||||
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
|
||||
);
|
||||
@@ -1376,7 +1328,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
...pluginBindingApprovals.changes,
|
||||
...currentConversationBindings.changes,
|
||||
...channelPairing.changes,
|
||||
...execApprovals.changes,
|
||||
...preSessionChannelPlans.changes,
|
||||
...pluginPlans.changes,
|
||||
...sessions.changes,
|
||||
@@ -1401,7 +1352,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
...pluginBindingApprovals.warnings,
|
||||
...currentConversationBindings.warnings,
|
||||
...channelPairing.warnings,
|
||||
...execApprovals.warnings,
|
||||
...preSessionChannelPlans.warnings,
|
||||
...pluginPlans.warnings,
|
||||
...sessions.warnings,
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isNamedProfile } from "../config/paths.js";
|
||||
import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
|
||||
import { fileExists } from "./state-migrations.fs.js";
|
||||
import type { LegacyExecApprovalsMigrationDetection } from "./state-migrations.types.js";
|
||||
|
||||
const EXEC_APPROVALS_FILENAME = "exec-approvals.json";
|
||||
const EXEC_APPROVALS_SOCKET_FILENAME = "exec-approvals.sock";
|
||||
|
||||
function resolveDefaultExecApprovalsStateDir(
|
||||
env: NodeJS.ProcessEnv,
|
||||
homedir: () => string,
|
||||
): string {
|
||||
return path.join(resolveRequiredHomeDir(env, homedir), ".openclaw");
|
||||
}
|
||||
|
||||
function resolveDefaultExecApprovalsPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
|
||||
return path.join(resolveDefaultExecApprovalsStateDir(env, homedir), EXEC_APPROVALS_FILENAME);
|
||||
}
|
||||
|
||||
function resolveExecApprovalsPathForStateDir(stateDir: string): string {
|
||||
return path.join(stateDir, EXEC_APPROVALS_FILENAME);
|
||||
}
|
||||
|
||||
function resolveExecApprovalsSocketPathForStateDir(stateDir: string): string {
|
||||
return path.join(stateDir, EXEC_APPROVALS_SOCKET_FILENAME);
|
||||
}
|
||||
|
||||
export function detectLegacyExecApprovalsMigration(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
homedir: () => string;
|
||||
stateDir: string;
|
||||
}): LegacyExecApprovalsMigrationDetection {
|
||||
const sourcePath = resolveDefaultExecApprovalsPath(params.env, params.homedir);
|
||||
const targetPath = resolveExecApprovalsPathForStateDir(params.stateDir);
|
||||
return {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
hasLegacy:
|
||||
Boolean(params.env.OPENCLAW_STATE_DIR?.trim()) &&
|
||||
!isNamedProfile(params.env) &&
|
||||
path.resolve(sourcePath) !== path.resolve(targetPath) &&
|
||||
fileExists(sourcePath) &&
|
||||
!fileExists(targetPath),
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isDefaultLegacyExecApprovalsSocketPath(params: {
|
||||
socketPath: string;
|
||||
sourcePath: string;
|
||||
}): boolean {
|
||||
const expanded = expandHomePrefix(params.socketPath);
|
||||
return (
|
||||
path.resolve(expanded) ===
|
||||
path.join(path.dirname(params.sourcePath), EXEC_APPROVALS_SOCKET_FILENAME)
|
||||
);
|
||||
}
|
||||
|
||||
function prepareMigratedExecApprovalsFile(params: {
|
||||
raw: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
}): { raw: string; warning?: string } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(params.raw) as unknown;
|
||||
} catch {
|
||||
return {
|
||||
raw: "",
|
||||
warning: `Legacy exec approvals file unreadable; left in place at ${params.sourcePath}`,
|
||||
};
|
||||
}
|
||||
if (!isPlainJsonObject(parsed) || parsed.version !== 1) {
|
||||
return {
|
||||
raw: "",
|
||||
warning: `Legacy exec approvals file has unsupported shape; left in place at ${params.sourcePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = { ...parsed };
|
||||
const socket = isPlainJsonObject(next.socket) ? { ...next.socket } : {};
|
||||
const rawSocketPath = typeof socket.path === "string" ? socket.path.trim() : "";
|
||||
if (
|
||||
!rawSocketPath ||
|
||||
isDefaultLegacyExecApprovalsSocketPath({
|
||||
socketPath: rawSocketPath,
|
||||
sourcePath: params.sourcePath,
|
||||
})
|
||||
) {
|
||||
socket.path = resolveExecApprovalsSocketPathForStateDir(path.dirname(params.targetPath));
|
||||
}
|
||||
next.socket = socket;
|
||||
return { raw: `${JSON.stringify(next, null, 2)}\n` };
|
||||
}
|
||||
|
||||
function assertSafeExecApprovalsMigrationTarget(targetPath: string): void {
|
||||
const targetDir = path.dirname(targetPath);
|
||||
assertNoSymlinkParentsSync({
|
||||
rootDir: resolveRequiredHomeDir(),
|
||||
targetPath: targetDir,
|
||||
allowOutsideRoot: true,
|
||||
messagePrefix: "Refusing to traverse symlink in exec approvals migration path",
|
||||
});
|
||||
try {
|
||||
const targetStat = fs.lstatSync(targetPath);
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to migrate exec approvals via symlink: ${targetPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeMigratedExecApprovalsFile(targetPath: string, raw: string): boolean {
|
||||
const targetDir = path.dirname(targetPath);
|
||||
assertSafeExecApprovalsMigrationTarget(targetPath);
|
||||
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
||||
assertSafeExecApprovalsMigrationTarget(targetPath);
|
||||
const dirStat = fs.lstatSync(targetDir);
|
||||
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to migrate exec approvals into unsafe directory: ${targetDir}`);
|
||||
}
|
||||
try {
|
||||
fs.chmodSync(targetDir, 0o700);
|
||||
} catch {
|
||||
// best-effort on platforms without chmod
|
||||
}
|
||||
const tempPath = path.join(targetDir, `.exec-approvals.migration.${process.pid}.tmp`);
|
||||
fs.writeFileSync(tempPath, raw, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
try {
|
||||
try {
|
||||
fs.copyFileSync(tempPath, targetPath, fs.constants.COPYFILE_EXCL);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
} catch {
|
||||
// best-effort cleanup for an incomplete exclusive copy target
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
fs.chmodSync(targetPath, 0o600);
|
||||
} catch {
|
||||
// best-effort on platforms without chmod
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function archiveMigratedExecApprovalsSource(sourcePath: string): string {
|
||||
let archivePath = `${sourcePath}.migrated`;
|
||||
if (fileExists(archivePath)) {
|
||||
archivePath = `${archivePath}-${Date.now()}`;
|
||||
}
|
||||
fs.renameSync(sourcePath, archivePath);
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
export function migrateLegacyExecApprovals(detected: LegacyExecApprovalsMigrationDetection): {
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (!detected.hasLegacy) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
if (fileExists(detected.targetPath)) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
try {
|
||||
const sourceStat = fs.lstatSync(detected.sourcePath);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
warnings.push(
|
||||
`Legacy exec approvals file is not a regular file; left in place at ${detected.sourcePath}`,
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
try {
|
||||
const targetStat = fs.lstatSync(detected.targetPath);
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
warnings.push(
|
||||
`Target exec approvals path is a symlink; skipped migration at ${detected.targetPath}`,
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const prepared = prepareMigratedExecApprovalsFile({
|
||||
raw: fs.readFileSync(detected.sourcePath, "utf8"),
|
||||
sourcePath: detected.sourcePath,
|
||||
targetPath: detected.targetPath,
|
||||
});
|
||||
if (prepared.warning) {
|
||||
warnings.push(prepared.warning);
|
||||
return { changes, warnings };
|
||||
}
|
||||
if (!writeMigratedExecApprovalsFile(detected.targetPath, prepared.raw)) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
changes.push(`Migrated exec approvals → ${detected.targetPath}`);
|
||||
try {
|
||||
const archivePath = archiveMigratedExecApprovalsSource(detected.sourcePath);
|
||||
changes.push(`Archived legacy exec approvals → ${archivePath}`);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed archiving legacy exec approvals at ${detected.sourcePath}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed migrating exec approvals (${detected.sourcePath} → ${detected.targetPath}): ${String(
|
||||
err,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
@@ -649,8 +649,8 @@ export function migrateLegacyPluginBindingApprovals(params: {
|
||||
}): { changes: string[]; warnings: string[] } {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
// hasLegacy is the detection verdict (it stays false for suppressed
|
||||
// cross-state-dir sources); fileExists re-checks for races since detection.
|
||||
// Detection requires the source to belong to this state root; fileExists
|
||||
// re-checks for races before the import mutates the same trust scope.
|
||||
if (!params.detected.hasLegacy || !fileExists(params.detected.sourcePath)) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
@@ -4,10 +4,6 @@ import path from "node:path";
|
||||
import { resolveLegacyStateDirs, resolveNewStateDir, resolveStateDir } from "../config/paths.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { isWithinDir } from "./path-safety.js";
|
||||
import {
|
||||
detectLegacyExecApprovalsMigration,
|
||||
migrateLegacyExecApprovals,
|
||||
} from "./state-migrations.exec-approvals.js";
|
||||
import { migrateLegacyInstalledPluginIndex } from "./state-migrations.plugin-state.js";
|
||||
import { migrateLegacyTaskStateSidecars } from "./state-migrations.storage.js";
|
||||
import type { MigrationLogger } from "./state-migrations.types.js";
|
||||
@@ -309,7 +305,6 @@ export async function autoMigrateLegacyTaskStateSidecars(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
log?: MigrationLogger;
|
||||
crossStateDirImports?: boolean;
|
||||
}): Promise<{
|
||||
migrated: boolean;
|
||||
skipped: boolean;
|
||||
@@ -324,45 +319,21 @@ export async function autoMigrateLegacyTaskStateSidecars(params: {
|
||||
|
||||
const stateDir = resolveStateDir(params.env ?? process.env, params.homedir);
|
||||
const result = await migrateLegacyTaskStateSidecars({ stateDir });
|
||||
const detectedExecApprovals = detectLegacyExecApprovalsMigration({
|
||||
env: params.env ?? process.env,
|
||||
homedir: params.homedir ?? os.homedir,
|
||||
stateDir,
|
||||
});
|
||||
// Cross-state-dir sources need the explicit doctor opt-in (see
|
||||
// detectLegacyStateMigrations); the implicit preflight must not archive
|
||||
// files that belong to the default state dir.
|
||||
const crossStateDirImports = params.crossStateDirImports === true;
|
||||
const execApprovals = migrateLegacyExecApprovals(
|
||||
crossStateDirImports ? detectedExecApprovals : { ...detectedExecApprovals, hasLegacy: false },
|
||||
);
|
||||
const notices: string[] = [];
|
||||
if (detectedExecApprovals.hasLegacy && !crossStateDirImports) {
|
||||
notices.push(
|
||||
`Exec approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${detectedExecApprovals.sourcePath} -> ${detectedExecApprovals.targetPath}); run \`openclaw doctor --fix\` to import them.`,
|
||||
);
|
||||
}
|
||||
const changes = [...result.changes, ...execApprovals.changes];
|
||||
const warnings = [...result.warnings, ...execApprovals.warnings];
|
||||
const logger = params.log ?? createSubsystemLogger("state-migrations");
|
||||
if (changes.length > 0) {
|
||||
logger.info(`Auto-migrated legacy state:\n${changes.map((entry) => `- ${entry}`).join("\n")}`);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
logger.warn(
|
||||
`Legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
if (result.changes.length > 0) {
|
||||
logger.info(
|
||||
`Auto-migrated legacy state:\n${result.changes.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
if (notices.length > 0) {
|
||||
logger.info(
|
||||
`Legacy state migration notes:\n${notices.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
if (result.warnings.length > 0) {
|
||||
logger.warn(
|
||||
`Legacy state migration warnings:\n${result.warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
migrated: result.changes.length > 0,
|
||||
skipped: false,
|
||||
changes,
|
||||
warnings,
|
||||
...(notices.length > 0 ? { notices } : {}),
|
||||
changes: result.changes,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2641,61 +2641,9 @@ describe("state migrations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates home-state-dir plugin binding approvals only with the cross-state-dir opt-in", async () => {
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const env = createEnv(stateDir);
|
||||
const cfg = createConfig();
|
||||
const sourcePath = path.join(root, ".openclaw", "plugin-binding-approvals.json");
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
approvals: [
|
||||
{
|
||||
pluginRoot: "/plugins/codex-a",
|
||||
pluginId: "codex",
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
approvedAt: 2345,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env,
|
||||
homedir: () => root,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
expect(detected.pluginBindingApprovals).toMatchObject({
|
||||
sourcePath,
|
||||
hasLegacy: true,
|
||||
});
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toContain("Migrated 1 plugin binding approval → shared SQLite state");
|
||||
expect(readPluginBindingApprovalRows(env)).toEqual([
|
||||
{
|
||||
plugin_root: "/plugins/codex-a",
|
||||
channel: "telegram",
|
||||
account_id: "default",
|
||||
plugin_id: "codex",
|
||||
plugin_name: null,
|
||||
approved_at: 2345,
|
||||
},
|
||||
]);
|
||||
await expectMissingPath(sourcePath);
|
||||
});
|
||||
|
||||
it("leaves home-state-dir plugin binding approvals alone without the cross-state-dir opt-in", async () => {
|
||||
// Regression: an isolated gateway pointed at a temp OPENCLAW_STATE_DIR must
|
||||
// not import (and archive) the default state dir's approvals file.
|
||||
it("never imports home-state plugin approvals into a custom state dir", async () => {
|
||||
// Regression: direct doctor repair follows the same trust boundary as
|
||||
// automatic startup migration and cannot archive another state's policy.
|
||||
const root = await createTempDir();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const env = createEnv(stateDir);
|
||||
@@ -2721,9 +2669,6 @@ describe("state migrations", () => {
|
||||
sourcePath,
|
||||
hasLegacy: false,
|
||||
});
|
||||
expect(detected.notices.join("\n")).toContain(
|
||||
"Plugin binding approvals in the default state dir were not imported",
|
||||
);
|
||||
expect(detected.preview).not.toContain(
|
||||
"- Plugin binding approvals: legacy JSON file → shared SQLite state",
|
||||
);
|
||||
@@ -2770,10 +2715,8 @@ describe("state migrations", () => {
|
||||
cfg,
|
||||
env,
|
||||
homedir: () => root,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
|
||||
expect(detected.execApprovals.hasLegacy).toBe(false);
|
||||
expect(detected.pluginBindingApprovals.hasLegacy).toBe(false);
|
||||
expect(detected.notices).toEqual([]);
|
||||
const result = await runLegacyStateMigrations({ detected, config: cfg, env });
|
||||
|
||||
@@ -123,18 +123,11 @@ export type LegacyStateDetection = {
|
||||
};
|
||||
rescuePending: LegacyRescuePendingDetection;
|
||||
channelPairing: LegacyChannelPairingStateDetection;
|
||||
execApprovals: {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
warnings: string[];
|
||||
notices: string[];
|
||||
preview: string[];
|
||||
};
|
||||
|
||||
export type LegacyExecApprovalsMigrationDetection = LegacyStateDetection["execApprovals"];
|
||||
|
||||
export type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
|
||||
@@ -1091,7 +1091,6 @@ describe("runGatewayUpdate", () => {
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
|
||||
expect(doctorEnv?.OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
|
||||
expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1");
|
||||
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
|
||||
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1");
|
||||
@@ -2821,7 +2820,6 @@ describe("runGatewayUpdate", () => {
|
||||
expect(calls).toContain(doctorCommand);
|
||||
expect(result.steps.map((step) => step.name)).toContain("openclaw doctor");
|
||||
expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
|
||||
expect(doctorEnv?.OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
|
||||
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
|
||||
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1");
|
||||
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR).toBe("1");
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
normalizeStringEntries,
|
||||
uniqueStrings,
|
||||
} from "@openclaw/normalization-core/string-normalization";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../commands/doctor-invocation.js";
|
||||
import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js";
|
||||
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
@@ -1634,7 +1633,6 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
const doctorStep = await runStep(
|
||||
step("openclaw doctor", doctorArgv, gitRoot, {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
|
||||
...(opts.deferConfiguredPluginInstallRepair
|
||||
? { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1" }
|
||||
: {}),
|
||||
@@ -1830,7 +1828,6 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
timeoutMs,
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
|
||||
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
|
||||
[UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV]: "1",
|
||||
[UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]: allowGatewayServiceRepair
|
||||
|
||||
@@ -366,7 +366,6 @@ describe("watch-node script", () => {
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(requireSpawnOptions(spawn, 1).stdio).toBe("inherit");
|
||||
expect(requireSpawnEnv(spawn, 1).OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
|
||||
|
||||
doctor.emit("exit", 0, null);
|
||||
await new Promise((resolve) => {
|
||||
@@ -378,9 +377,6 @@ describe("watch-node script", () => {
|
||||
expect(restartedGatewaySpawnCall[0]).toBe("/usr/local/bin/node");
|
||||
expect(restartedGatewaySpawnCall[1]).toEqual(["scripts/run-node.mjs", "gateway", "--force"]);
|
||||
expect(requireSpawnOptions(spawn, 2).stdio).toBe("inherit");
|
||||
expect(
|
||||
requireSpawnEnv(spawn, 2).OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS,
|
||||
).toBeUndefined();
|
||||
|
||||
fakeProcess.emit("SIGINT");
|
||||
const exitCode = await runPromise;
|
||||
|
||||
Reference in New Issue
Block a user