mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
parent
cc5804868f
commit
5fc9b20d24
9 changed files with 236 additions and 37 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CliBackendPreparedExecution } from "openclaw/plugin-sdk/cli-backend";
|
||||
|
|
@ -254,10 +255,16 @@ async function buildGeminiCliSystemSettings(
|
|||
}
|
||||
|
||||
async function writeGeminiCliJson(filePath: string, value: unknown): Promise<void> {
|
||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
||||
const tempPath = path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
||||
);
|
||||
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.chmod(tempPath, 0o600);
|
||||
await fs.rename(tempPath, filePath);
|
||||
await fs.chmod(filePath, 0o600);
|
||||
}
|
||||
|
||||
|
|
@ -268,12 +275,10 @@ async function prepareGeminiCliProfileHome(
|
|||
home: string;
|
||||
geminiDir: string;
|
||||
systemSettingsPath: string;
|
||||
beforeExecution: () => Promise<void>;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const { home, geminiDir } = resolveGeminiCliProfileHome(ctx);
|
||||
await fs.mkdir(geminiDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(home, 0o700);
|
||||
await fs.chmod(geminiDir, 0o700);
|
||||
const settings = buildGeminiCliAuthSettings(selectedType);
|
||||
const systemSettings = await buildGeminiCliSystemSettings(ctx, selectedType);
|
||||
const systemSettingsDir = await fs.mkdtemp(
|
||||
|
|
@ -281,20 +286,20 @@ async function prepareGeminiCliProfileHome(
|
|||
);
|
||||
await fs.chmod(systemSettingsDir, 0o700);
|
||||
const systemSettingsPath = path.join(systemSettingsDir, "settings.json");
|
||||
try {
|
||||
await Promise.all([
|
||||
writeGeminiCliJson(path.join(geminiDir, "settings.json"), settings),
|
||||
writeGeminiCliJson(path.join(home, "settings.json"), settings),
|
||||
writeGeminiCliJson(systemSettingsPath, systemSettings),
|
||||
]);
|
||||
} catch (error) {
|
||||
await fs.rm(systemSettingsDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
home,
|
||||
geminiDir,
|
||||
systemSettingsPath,
|
||||
beforeExecution: async () => {
|
||||
await fs.mkdir(geminiDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(home, 0o700);
|
||||
await fs.chmod(geminiDir, 0o700);
|
||||
await Promise.all([
|
||||
writeGeminiCliJson(path.join(geminiDir, "settings.json"), settings),
|
||||
writeGeminiCliJson(path.join(home, "settings.json"), settings),
|
||||
writeGeminiCliJson(systemSettingsPath, systemSettings),
|
||||
]);
|
||||
},
|
||||
cleanup: async () => {
|
||||
await fs.rm(systemSettingsDir, { recursive: true, force: true });
|
||||
},
|
||||
|
|
@ -328,11 +333,7 @@ async function prepareGeminiCliOAuthHome(
|
|||
return null;
|
||||
}
|
||||
|
||||
const { home, geminiDir, systemSettingsPath, cleanup } = await prepareGeminiCliProfileHome(
|
||||
ctx,
|
||||
"oauth-personal",
|
||||
);
|
||||
await clearGeminiCliCachedCredentials(geminiDir);
|
||||
const profileHome = await prepareGeminiCliProfileHome(ctx, "oauth-personal");
|
||||
const idToken = normalizeString(oauth.idToken);
|
||||
const oauthCreds: Record<string, string | number> = {
|
||||
access_token: oauth.access,
|
||||
|
|
@ -344,17 +345,20 @@ async function prepareGeminiCliOAuthHome(
|
|||
oauthCreds.id_token = idToken;
|
||||
}
|
||||
|
||||
await writeGeminiCliJson(path.join(geminiDir, "oauth_creds.json"), oauthCreds);
|
||||
|
||||
return {
|
||||
env: {
|
||||
GEMINI_CLI_HOME: home,
|
||||
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
|
||||
GEMINI_CLI_HOME: profileHome.home,
|
||||
GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileHome.systemSettingsPath,
|
||||
GEMINI_FORCE_FILE_STORAGE: "true",
|
||||
...buildGeminiCliProjectEnv(oauth.projectId),
|
||||
},
|
||||
clearEnv: [...GEMINI_CLI_PROFILE_AUTH_ENV, ...GEMINI_CLI_PROFILE_SETTINGS_ENV],
|
||||
cleanup,
|
||||
beforeExecution: async () => {
|
||||
await profileHome.beforeExecution();
|
||||
await clearGeminiCliCachedCredentials(profileHome.geminiDir);
|
||||
await writeGeminiCliJson(path.join(profileHome.geminiDir, "oauth_creds.json"), oauthCreds);
|
||||
},
|
||||
cleanup: profileHome.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -367,23 +371,23 @@ async function prepareGeminiCliApiKeyHome(
|
|||
return null;
|
||||
}
|
||||
|
||||
const { home, geminiDir, systemSettingsPath, cleanup } = await prepareGeminiCliProfileHome(
|
||||
ctx,
|
||||
"gemini-api-key",
|
||||
);
|
||||
await Promise.all([
|
||||
fs.rm(path.join(geminiDir, "oauth_creds.json"), { force: true }),
|
||||
clearGeminiCliCachedCredentials(geminiDir),
|
||||
]);
|
||||
const profileHome = await prepareGeminiCliProfileHome(ctx, "gemini-api-key");
|
||||
return {
|
||||
env: {
|
||||
GEMINI_CLI_HOME: home,
|
||||
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
|
||||
GEMINI_CLI_HOME: profileHome.home,
|
||||
GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileHome.systemSettingsPath,
|
||||
GEMINI_FORCE_FILE_STORAGE: "true",
|
||||
GEMINI_API_KEY: apiKey.key,
|
||||
},
|
||||
clearEnv: [...GEMINI_CLI_PROFILE_AUTH_ENV, ...GEMINI_CLI_PROFILE_SETTINGS_ENV],
|
||||
cleanup,
|
||||
beforeExecution: async () => {
|
||||
await profileHome.beforeExecution();
|
||||
await Promise.all([
|
||||
fs.rm(path.join(profileHome.geminiDir, "oauth_creds.json"), { force: true }),
|
||||
clearGeminiCliCachedCredentials(profileHome.geminiDir),
|
||||
]);
|
||||
},
|
||||
cleanup: profileHome.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import path from "node:path";
|
|||
import type { CliBackendPlugin } from "openclaw/plugin-sdk/cli-backend";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildGoogleGeminiCliBackend } from "./cli-backend.js";
|
||||
import setupEntry from "./setup-api.js";
|
||||
|
||||
|
|
@ -24,6 +25,15 @@ type GeminiPrepareContext = Parameters<
|
|||
email?: string;
|
||||
};
|
||||
};
|
||||
type GeminiPreparedExecution = Awaited<
|
||||
ReturnType<NonNullable<ReturnType<typeof buildGoogleGeminiCliBackend>["prepareExecution"]>>
|
||||
>;
|
||||
|
||||
async function stageGeminiPreparedExecution(
|
||||
prepared: GeminiPreparedExecution | null | undefined,
|
||||
): Promise<void> {
|
||||
await prepared?.beforeExecution?.();
|
||||
}
|
||||
|
||||
function buildGeminiOAuthPrepareContext(workspaceDir: string): GeminiPrepareContext {
|
||||
const agentDir = path.join(workspaceDir, "agent");
|
||||
|
|
@ -164,6 +174,7 @@ describe("google gemini cli backend auth bridge", () => {
|
|||
if (prepared?.cleanup) {
|
||||
cleanups.push(prepared.cleanup);
|
||||
}
|
||||
await stageGeminiPreparedExecution(prepared);
|
||||
|
||||
home = prepared?.env?.GEMINI_CLI_HOME;
|
||||
const systemSettingsPath = prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
|
||||
|
|
@ -223,6 +234,7 @@ describe("google gemini cli backend auth bridge", () => {
|
|||
if (preparedAgain?.cleanup) {
|
||||
cleanups.push(preparedAgain.cleanup);
|
||||
}
|
||||
await stageGeminiPreparedExecution(preparedAgain);
|
||||
expect(preparedAgain?.env?.GEMINI_CLI_HOME).toBe(home);
|
||||
await expect(fs.access(sessionMarker)).resolves.toBeUndefined();
|
||||
await expect(fs.access(cachedCredentialsPath)).rejects.toThrow();
|
||||
|
|
@ -234,6 +246,49 @@ describe("google gemini cli backend auth bridge", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("stages Gemini CLI JSON through same-directory atomic renames", async () => {
|
||||
await withTempDir("openclaw-test-workspace-", async (workspaceDir) => {
|
||||
const backend = buildGoogleGeminiCliBackend();
|
||||
const realRename = fs.rename.bind(fs);
|
||||
const renameCalls: Array<{ from: string; to: string }> = [];
|
||||
const renameSpy = vi
|
||||
.spyOn(fs, "rename")
|
||||
.mockImplementation(async (...args: Parameters<typeof fs.rename>) => {
|
||||
renameCalls.push({ from: String(args[0]), to: String(args[1]) });
|
||||
await realRename(...args);
|
||||
});
|
||||
let prepared: GeminiPreparedExecution | null | undefined;
|
||||
|
||||
try {
|
||||
prepared = await backend.prepareExecution?.(buildGeminiOAuthPrepareContext(workspaceDir));
|
||||
await stageGeminiPreparedExecution(prepared);
|
||||
|
||||
const home = prepared?.env?.GEMINI_CLI_HOME;
|
||||
const systemSettingsPath = prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
|
||||
if (!home || !systemSettingsPath) {
|
||||
throw new Error("expected Gemini CLI staging paths");
|
||||
}
|
||||
const expectedTargets = [
|
||||
path.join(home, ".gemini", "settings.json"),
|
||||
path.join(home, "settings.json"),
|
||||
systemSettingsPath,
|
||||
path.join(home, ".gemini", "oauth_creds.json"),
|
||||
];
|
||||
expect(renameCalls.map((call) => call.to).toSorted()).toEqual(expectedTargets.toSorted());
|
||||
for (const call of renameCalls) {
|
||||
expect(path.dirname(call.from)).toBe(path.dirname(call.to));
|
||||
expect(path.basename(call.from).startsWith(`.${path.basename(call.to)}.`)).toBe(true);
|
||||
expect(path.basename(call.from).endsWith(".tmp")).toBe(true);
|
||||
}
|
||||
const oauthStat = await fs.stat(path.join(home, ".gemini", "oauth_creds.json"));
|
||||
expect(oauthStat.mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
renameSpy.mockRestore();
|
||||
await prepared?.cleanup?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("prepares selected canonical Google API-key credentials and removes stale OAuth state for that profile home", async () => {
|
||||
const backend = buildGoogleGeminiCliBackend();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-"));
|
||||
|
|
@ -246,6 +301,7 @@ describe("google gemini cli backend auth bridge", () => {
|
|||
if (firstPrepared?.cleanup) {
|
||||
cleanups.push(firstPrepared.cleanup);
|
||||
}
|
||||
await stageGeminiPreparedExecution(firstPrepared);
|
||||
home = firstPrepared?.env?.GEMINI_CLI_HOME;
|
||||
expect(home).toBeTruthy();
|
||||
await fs.writeFile(path.join(home ?? "", ".gemini", "oauth_creds.json"), "{}\n", "utf8");
|
||||
|
|
@ -259,6 +315,7 @@ describe("google gemini cli backend auth bridge", () => {
|
|||
if (prepared?.cleanup) {
|
||||
cleanups.push(prepared.cleanup);
|
||||
}
|
||||
await stageGeminiPreparedExecution(prepared);
|
||||
|
||||
home = prepared?.env?.GEMINI_CLI_HOME;
|
||||
expect(home).toBeTruthy();
|
||||
|
|
@ -339,6 +396,7 @@ describe("google gemini cli backend auth bridge", () => {
|
|||
process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = inheritedSettingsPath;
|
||||
|
||||
prepared = await backend.prepareExecution?.(buildGeminiOAuthPrepareContext(workspaceDir));
|
||||
await stageGeminiPreparedExecution(prepared);
|
||||
|
||||
const systemSettingsRaw = await fs.readFile(
|
||||
prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? "",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { writeGeminiMcpCaptureSettings, writeGeminiSystemSettings } from "./bund
|
|||
|
||||
type PreparedCliBundleMcpConfig = {
|
||||
backend: CliBackendConfig;
|
||||
beforeExecution?: () => Promise<void>;
|
||||
cleanup?: () => Promise<void>;
|
||||
mcpConfigHash?: string;
|
||||
mcpResumeHash?: string;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,20 @@ import type { PreparedCliRunContext } from "./types.js";
|
|||
type ProcessSupervisor = ReturnType<typeof getProcessSupervisor>;
|
||||
type SupervisorSpawnInput = Parameters<ProcessSupervisor["spawn"]>[0];
|
||||
|
||||
function createDeferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
} {
|
||||
let resolve: (value: T | PromiseLike<T>) => void = () => {};
|
||||
let reject: (reason?: unknown) => void = () => {};
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function recordMcpLoopbackToolCallResult(params: {
|
||||
captureKey: string;
|
||||
toolName: string;
|
||||
|
|
@ -43,6 +57,8 @@ function recordMcpLoopbackToolCallResult(params: {
|
|||
function buildPreparedCliRunContext(params: {
|
||||
output: "jsonl" | "text";
|
||||
provider?: string;
|
||||
runId?: string;
|
||||
beforeExecution?: () => Promise<void>;
|
||||
}): PreparedCliRunContext {
|
||||
const provider = params.provider ?? "codex-cli";
|
||||
const backend = {
|
||||
|
|
@ -62,7 +78,7 @@ function buildPreparedCliRunContext(params: {
|
|||
provider,
|
||||
model: "model",
|
||||
timeoutMs: 1_000,
|
||||
runId: `run-${params.output}`,
|
||||
runId: params.runId ?? `run-${params.output}`,
|
||||
},
|
||||
started: Date.now(),
|
||||
workspaceDir: "/tmp",
|
||||
|
|
@ -74,6 +90,7 @@ function buildPreparedCliRunContext(params: {
|
|||
preparedBackend: {
|
||||
backend,
|
||||
env: {},
|
||||
...(params.beforeExecution ? { beforeExecution: params.beforeExecution } : {}),
|
||||
},
|
||||
reusableCliSession: {},
|
||||
hadSessionFile: false,
|
||||
|
|
@ -101,6 +118,65 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
describe("executePreparedCliRun supervisor output capture", () => {
|
||||
it("runs prepared backend staging inside the serialized execution queue", async () => {
|
||||
const firstSpawnEntered = createDeferred();
|
||||
const releaseFirstSpawn = createDeferred();
|
||||
const events: string[] = [];
|
||||
let spawnCount = 0;
|
||||
|
||||
supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => {
|
||||
spawnCount += 1;
|
||||
const input = args[0] as SupervisorSpawnInput;
|
||||
const label = spawnCount === 1 ? "first" : "second";
|
||||
events.push(`spawn:${label}`);
|
||||
input.onStdout?.(`answer ${label}`);
|
||||
if (label === "first") {
|
||||
firstSpawnEntered.resolve();
|
||||
await releaseFirstSpawn.promise;
|
||||
}
|
||||
return createManagedRun({
|
||||
reason: "exit",
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 50,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
});
|
||||
});
|
||||
|
||||
const first = executePreparedCliRun(
|
||||
buildPreparedCliRunContext({
|
||||
output: "text",
|
||||
runId: "run-first",
|
||||
beforeExecution: async () => {
|
||||
events.push("stage:first");
|
||||
},
|
||||
}),
|
||||
);
|
||||
await firstSpawnEntered.promise;
|
||||
const second = executePreparedCliRun(
|
||||
buildPreparedCliRunContext({
|
||||
output: "text",
|
||||
runId: "run-second",
|
||||
beforeExecution: async () => {
|
||||
events.push("stage:second");
|
||||
},
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(events).toEqual(["stage:first", "spawn:first"]);
|
||||
|
||||
releaseFirstSpawn.resolve();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(events).toEqual(["stage:first", "spawn:first", "stage:second", "spawn:second"]);
|
||||
});
|
||||
|
||||
it("disables supervisor capture without parsing from the diagnostic stdout tail", async () => {
|
||||
const fullText = `start-${"x".repeat(80 * 1024)}-end`;
|
||||
|
||||
|
|
|
|||
|
|
@ -568,6 +568,7 @@ export async function executePreparedCliRun(
|
|||
if (params.lifecycleGeneration) {
|
||||
assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration);
|
||||
}
|
||||
await context.preparedBackend.beforeExecution?.();
|
||||
const cliTurnStartedAt = Date.now();
|
||||
const restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
|
|
|
|||
|
|
@ -852,6 +852,51 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("preserves backend staging for queued execution without running it during prepare", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
const beforeExecution = vi.fn(async () => {});
|
||||
const prepareExecution = vi.fn(async () => ({ beforeExecution }));
|
||||
cliBackendsTesting.setDepsForTest({
|
||||
resolvePluginSetupCliBackend: () => undefined,
|
||||
resolveRuntimeCliBackends: () => [
|
||||
{
|
||||
id: "test-cli",
|
||||
pluginId: "test-plugin",
|
||||
bundleMcp: false,
|
||||
prepareExecution,
|
||||
config: {
|
||||
command: "test-cli",
|
||||
args: ["--print"],
|
||||
sessionMode: "existing",
|
||||
output: "text",
|
||||
input: "arg",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await prepareCliRunContext({
|
||||
sessionId: "session-test",
|
||||
sessionFile,
|
||||
workspaceDir: dir,
|
||||
prompt: "latest ask",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-staging-thunk",
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(prepareExecution).toHaveBeenCalledOnce();
|
||||
expect(beforeExecution).not.toHaveBeenCalled();
|
||||
await context.preparedBackend.beforeExecution?.();
|
||||
expect(beforeExecution).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans generated Gemini MCP settings when auth preparation fails", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
let generatedSystemSettingsPath: string | undefined;
|
||||
|
|
|
|||
|
|
@ -530,6 +530,13 @@ export async function prepareCliRunContext(
|
|||
}
|
||||
}
|
||||
: undefined;
|
||||
const preparedBackendBeforeExecution =
|
||||
preparedBackend.beforeExecution || preparedExecution?.beforeExecution
|
||||
? async () => {
|
||||
await preparedBackend.beforeExecution?.();
|
||||
await preparedExecution?.beforeExecution?.();
|
||||
}
|
||||
: undefined;
|
||||
const claudeSkillsPlugin = isSideQuestion
|
||||
? { args: [], cleanup: async () => {} }
|
||||
: await prepareDeps.prepareClaudeCliSkillsPlugin({
|
||||
|
|
@ -566,6 +573,7 @@ export async function prepareCliRunContext(
|
|||
: {}),
|
||||
},
|
||||
...(preparedBackendEnv ? { env: preparedBackendEnv } : {}),
|
||||
...(preparedBackendBeforeExecution ? { beforeExecution: preparedBackendBeforeExecution } : {}),
|
||||
...(preparedCleanup ? { cleanup: preparedCleanup } : {}),
|
||||
};
|
||||
const promptTools =
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ export type RunCliAgentParams = {
|
|||
/** Backend config after MCP, skill, env, and cleanup preparation. */
|
||||
export type CliPreparedBackend = {
|
||||
backend: CliBackendConfig;
|
||||
beforeExecution?: () => Promise<void>;
|
||||
cleanup?: () => Promise<void>;
|
||||
mcpConfigHash?: string;
|
||||
mcpResumeHash?: string;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ export type CliBackendPrepareExecutionContext = {
|
|||
export type CliBackendPreparedExecution = {
|
||||
env?: Record<string, string>;
|
||||
clearEnv?: string[];
|
||||
/**
|
||||
* Backend-owned staging that must run after the core CLI queue admits the turn.
|
||||
* Use this for mutable per-profile CLI homes that the launched process also owns.
|
||||
*/
|
||||
beforeExecution?: () => Promise<void>;
|
||||
cleanup?: () => Promise<void>;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue