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:
Peter Steinberger
2026-07-16 08:57:32 -07:00
committed by GitHub
parent 7a18a781bc
commit 8fe4eeea9e
35 changed files with 88 additions and 1208 deletions
+2 -16
View File
@@ -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);
+2 -27
View File
@@ -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 -28
View File
@@ -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
View File
@@ -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);
});
-2
View File
@@ -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");
});
-24
View File
@@ -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: {
-1
View File
@@ -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;
-10
View File
@@ -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,
}),
);
}
-16
View File
@@ -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])
);
}
+9 -150
View File
@@ -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);
-5
View File
@@ -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: [],
-2
View File
@@ -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: [] };
-7
View File
@@ -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");
+15 -17
View File
@@ -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", () => {
-72
View File
@@ -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),
+5 -55
View File
@@ -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 };
}
+2 -2
View File
@@ -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 };
}
+9 -38
View File
@@ -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,
};
}
+3 -60
View File
@@ -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 });
-7
View File
@@ -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;
-2
View File
@@ -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");
-3
View File
@@ -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
-4
View File
@@ -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;