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

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);

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 }
: {}),

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 () => {

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);
});

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({

View File

@@ -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

View File

@@ -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)}`));

View File

@@ -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");
});