mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix: lease Slack credentials for Mantis gateway setup
This commit is contained in:
@@ -146,6 +146,12 @@ Required inputs for `--credential-source env`:
|
||||
before invoking Crabbox so Crabbox's `OPENCLAW_*` env forwarding can carry it
|
||||
into the VM.
|
||||
|
||||
With `--gateway-setup --credential-source convex`, Mantis leases the Slack SUT
|
||||
credential from the shared pool before creating the VM and forwards the leased
|
||||
channel id, Socket Mode app token, and bot token as the `OPENCLAW_MANTIS_SLACK_*`
|
||||
runtime env inside the desktop. That keeps GitHub workflows thin: they only need
|
||||
the Convex broker secret, not raw Slack bot or app tokens.
|
||||
|
||||
Useful Slack desktop flags:
|
||||
|
||||
- `--lease-id <cbx_...>` reruns against a machine where an operator already logged in to Slack Web through VNC.
|
||||
|
||||
@@ -4,6 +4,29 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runMantisSlackDesktopSmoke } from "./slack-desktop-smoke.runtime.js";
|
||||
|
||||
function describeFetchInput(input: RequestInfo | URL) {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function describeFetchBody(body: BodyInit | null | undefined) {
|
||||
if (body == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof body === "string") {
|
||||
return body;
|
||||
}
|
||||
if (body instanceof URLSearchParams) {
|
||||
return body.toString();
|
||||
}
|
||||
return `[${body.constructor.name}]`;
|
||||
}
|
||||
|
||||
describe("mantis Slack desktop smoke runtime", () => {
|
||||
let repoRoot: string;
|
||||
|
||||
@@ -12,6 +35,7 @@ describe("mantis Slack desktop smoke runtime", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await fs.rm(repoRoot, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
@@ -131,6 +155,106 @@ describe("mantis Slack desktop smoke runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("leases Convex Slack credentials for gateway setup and maps them into the VM env", async () => {
|
||||
const commands: { args: readonly string[]; command: string; env?: NodeJS.ProcessEnv }[] = [];
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = describeFetchInput(input);
|
||||
if (url.endsWith("/acquire")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
credentialId: "cred-slack",
|
||||
heartbeatIntervalMs: 600_000,
|
||||
leaseToken: "lease-slack",
|
||||
leaseTtlMs: 900_000,
|
||||
payload: {
|
||||
channelId: "CLEASED",
|
||||
sutAppToken: "xapp-leased",
|
||||
sutBotToken: "xoxb-leased",
|
||||
},
|
||||
status: "ok",
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/release") || url.endsWith("/heartbeat")) {
|
||||
return new Response(JSON.stringify({ status: "ok" }), { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url} ${describeFetchBody(init?.body)}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runner = vi.fn(
|
||||
async (command: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => {
|
||||
commands.push({ command, args, env: options.env });
|
||||
if (command === "/tmp/crabbox" && args[0] === "warmup") {
|
||||
return { stdout: "ready lease cbx_c0ffee\n", stderr: "" };
|
||||
}
|
||||
if (command === "/tmp/crabbox" && args[0] === "inspect") {
|
||||
return {
|
||||
stdout: `${JSON.stringify({
|
||||
host: "203.0.113.10",
|
||||
id: "cbx_c0ffee",
|
||||
provider: "hetzner",
|
||||
sshKey: "/tmp/key",
|
||||
sshPort: "2222",
|
||||
sshUser: "crabbox",
|
||||
state: "active",
|
||||
})}\n`,
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (command === "rsync") {
|
||||
const outputDir = args.at(-1);
|
||||
await fs.mkdir(outputDir as string, { recursive: true });
|
||||
if (!String(outputDir).endsWith("slack-qa/")) {
|
||||
await fs.writeFile(path.join(outputDir as string, "slack-desktop-smoke.png"), "png");
|
||||
await fs.writeFile(path.join(outputDir as string, "remote-metadata.json"), "{}\n");
|
||||
await fs.writeFile(path.join(outputDir as string, "slack-desktop-command.log"), "qa\n");
|
||||
}
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runMantisSlackDesktopSmoke({
|
||||
commandRunner: runner,
|
||||
crabboxBin: "/tmp/crabbox",
|
||||
credentialRole: "ci",
|
||||
credentialSource: "convex",
|
||||
env: {
|
||||
CI: "1",
|
||||
OPENAI_API_KEY: "openai-runtime-key",
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: "convex-secret",
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: "https://example.convex.site",
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
gatewaySetup: true,
|
||||
now: () => new Date("2026-05-04T14:00:00.000Z"),
|
||||
outputDir: ".artifacts/qa-e2e/mantis/slack-desktop-convex",
|
||||
repoRoot,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
const runCommand = commands.find(
|
||||
(entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "run",
|
||||
);
|
||||
expect(runCommand?.env).toMatchObject({
|
||||
OPENCLAW_MANTIS_SLACK_APP_TOKEN: "xapp-leased",
|
||||
OPENCLAW_MANTIS_SLACK_BOT_TOKEN: "xoxb-leased",
|
||||
OPENCLAW_MANTIS_SLACK_CHANNEL_ID: "CLEASED",
|
||||
OPENCLAW_QA_SLACK_CHANNEL_ID: "CLEASED",
|
||||
OPENCLAW_QA_SLACK_SUT_APP_TOKEN: "xapp-leased",
|
||||
OPENCLAW_QA_SLACK_SUT_BOT_TOKEN: "xoxb-leased",
|
||||
});
|
||||
const remoteScript = runCommand?.args.at(-1);
|
||||
expect(remoteScript).toContain("setup_gateway=1");
|
||||
expect(remoteScript).toContain("openclaw gateway run");
|
||||
expect(fetchMock.mock.calls.map(([url]) => describeFetchInput(url))).toEqual([
|
||||
"https://example.convex.site/qa-credentials/v1/acquire",
|
||||
"https://example.convex.site/qa-credentials/v1/release",
|
||||
]);
|
||||
});
|
||||
|
||||
it("copies the screenshot before reporting a failed remote Slack QA run", async () => {
|
||||
const runner = vi.fn(async (command: string, args: readonly string[]) => {
|
||||
if (command === "/tmp/crabbox" && args[0] === "inspect") {
|
||||
|
||||
@@ -3,6 +3,10 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
||||
import {
|
||||
acquireQaCredentialLease,
|
||||
startQaCredentialLeaseHeartbeat,
|
||||
} from "../live-transports/shared/credential-lease.runtime.js";
|
||||
|
||||
export type MantisSlackDesktopSmokeOptions = {
|
||||
alternateModel?: string;
|
||||
@@ -49,6 +53,17 @@ type CommandRunner = (
|
||||
options: SpawnOptions,
|
||||
) => Promise<CommandResult>;
|
||||
|
||||
type SlackGatewayCredentialPayload = {
|
||||
channelId: string;
|
||||
sutAppToken: string;
|
||||
sutBotToken: string;
|
||||
};
|
||||
|
||||
type SlackGatewayCredentialLease = Awaited<
|
||||
ReturnType<typeof acquireQaCredentialLease<SlackGatewayCredentialPayload>>
|
||||
>;
|
||||
type SlackGatewayCredentialHeartbeat = ReturnType<typeof startQaCredentialLeaseHeartbeat>;
|
||||
|
||||
type CrabboxInspect = {
|
||||
host?: string;
|
||||
id?: string;
|
||||
@@ -194,12 +209,110 @@ function buildCrabboxEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (!trimToValue(next.OPENCLAW_MANTIS_SLACK_BOT_TOKEN) && trimToValue(next.SLACK_BOT_TOKEN)) {
|
||||
next.OPENCLAW_MANTIS_SLACK_BOT_TOKEN = next.SLACK_BOT_TOKEN;
|
||||
}
|
||||
if (
|
||||
!trimToValue(next.OPENCLAW_MANTIS_SLACK_BOT_TOKEN) &&
|
||||
trimToValue(next.OPENCLAW_QA_SLACK_SUT_BOT_TOKEN)
|
||||
) {
|
||||
next.OPENCLAW_MANTIS_SLACK_BOT_TOKEN = next.OPENCLAW_QA_SLACK_SUT_BOT_TOKEN;
|
||||
}
|
||||
if (!trimToValue(next.OPENCLAW_MANTIS_SLACK_APP_TOKEN) && trimToValue(next.SLACK_APP_TOKEN)) {
|
||||
next.OPENCLAW_MANTIS_SLACK_APP_TOKEN = next.SLACK_APP_TOKEN;
|
||||
}
|
||||
if (
|
||||
!trimToValue(next.OPENCLAW_MANTIS_SLACK_APP_TOKEN) &&
|
||||
trimToValue(next.OPENCLAW_QA_SLACK_SUT_APP_TOKEN)
|
||||
) {
|
||||
next.OPENCLAW_MANTIS_SLACK_APP_TOKEN = next.OPENCLAW_QA_SLACK_SUT_APP_TOKEN;
|
||||
}
|
||||
if (
|
||||
!trimToValue(next.OPENCLAW_MANTIS_SLACK_CHANNEL_ID) &&
|
||||
trimToValue(next.OPENCLAW_QA_SLACK_CHANNEL_ID)
|
||||
) {
|
||||
next.OPENCLAW_MANTIS_SLACK_CHANNEL_ID = next.OPENCLAW_QA_SLACK_CHANNEL_ID;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveSlackGatewayEnvPayload(env: NodeJS.ProcessEnv): SlackGatewayCredentialPayload {
|
||||
const channelId = trimToValue(env.OPENCLAW_QA_SLACK_CHANNEL_ID);
|
||||
const sutBotToken = trimToValue(env.OPENCLAW_QA_SLACK_SUT_BOT_TOKEN);
|
||||
const sutAppToken = trimToValue(env.OPENCLAW_QA_SLACK_SUT_APP_TOKEN);
|
||||
if (!channelId || !sutBotToken || !sutAppToken) {
|
||||
throw new Error(
|
||||
"Gateway setup requires OPENCLAW_QA_SLACK_CHANNEL_ID, OPENCLAW_QA_SLACK_SUT_BOT_TOKEN, and OPENCLAW_QA_SLACK_SUT_APP_TOKEN when using --credential-source env.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
channelId,
|
||||
sutAppToken,
|
||||
sutBotToken,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSlackGatewayCredentialPayload(payload: unknown): SlackGatewayCredentialPayload {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("Slack credential payload must be an object.");
|
||||
}
|
||||
const candidate = payload as Record<string, unknown>;
|
||||
const channelId =
|
||||
typeof candidate.channelId === "string" ? trimToValue(candidate.channelId) : undefined;
|
||||
const sutBotToken =
|
||||
typeof candidate.sutBotToken === "string" ? trimToValue(candidate.sutBotToken) : undefined;
|
||||
const sutAppToken =
|
||||
typeof candidate.sutAppToken === "string" ? trimToValue(candidate.sutAppToken) : undefined;
|
||||
if (!channelId || !sutBotToken || !sutAppToken) {
|
||||
throw new Error(
|
||||
"Slack credential payload must include channelId, sutBotToken, and sutAppToken.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
channelId,
|
||||
sutAppToken,
|
||||
sutBotToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareGatewayCredentialEnv(params: {
|
||||
credentialRole: string;
|
||||
credentialSource: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
gatewaySetup: boolean;
|
||||
}) {
|
||||
if (!params.gatewaySetup) {
|
||||
return {};
|
||||
}
|
||||
if (
|
||||
trimToValue(params.env.OPENCLAW_MANTIS_SLACK_BOT_TOKEN) &&
|
||||
trimToValue(params.env.OPENCLAW_MANTIS_SLACK_APP_TOKEN)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const credentialLease = await acquireQaCredentialLease<SlackGatewayCredentialPayload>({
|
||||
env: params.env,
|
||||
kind: "slack",
|
||||
source: params.credentialSource,
|
||||
role: params.credentialRole,
|
||||
resolveEnvPayload: () => resolveSlackGatewayEnvPayload(params.env),
|
||||
parsePayload: parseSlackGatewayCredentialPayload,
|
||||
});
|
||||
const leaseHeartbeat = startQaCredentialLeaseHeartbeat(credentialLease);
|
||||
const payload = credentialLease.payload;
|
||||
params.env.OPENCLAW_MANTIS_SLACK_BOT_TOKEN = payload.sutBotToken;
|
||||
params.env.OPENCLAW_MANTIS_SLACK_APP_TOKEN = payload.sutAppToken;
|
||||
params.env.OPENCLAW_MANTIS_SLACK_CHANNEL_ID =
|
||||
trimToValue(params.env.OPENCLAW_MANTIS_SLACK_CHANNEL_ID) ?? payload.channelId;
|
||||
params.env.OPENCLAW_QA_SLACK_CHANNEL_ID =
|
||||
trimToValue(params.env.OPENCLAW_QA_SLACK_CHANNEL_ID) ?? payload.channelId;
|
||||
params.env.OPENCLAW_QA_SLACK_SUT_BOT_TOKEN =
|
||||
trimToValue(params.env.OPENCLAW_QA_SLACK_SUT_BOT_TOKEN) ?? payload.sutBotToken;
|
||||
params.env.OPENCLAW_QA_SLACK_SUT_APP_TOKEN =
|
||||
trimToValue(params.env.OPENCLAW_QA_SLACK_SUT_APP_TOKEN) ?? payload.sutAppToken;
|
||||
return {
|
||||
credentialLease,
|
||||
leaseHeartbeat,
|
||||
};
|
||||
}
|
||||
|
||||
function extractLeaseId(output: string) {
|
||||
return output.match(/\b(?:cbx_[a-f0-9]+|tbx_[A-Za-z0-9_-]+)\b/u)?.[0];
|
||||
}
|
||||
@@ -656,6 +769,8 @@ export async function runMantisSlackDesktopSmoke(
|
||||
const remoteOutputDir = `/tmp/openclaw-mantis-slack-desktop-${startedAt
|
||||
.toISOString()
|
||||
.replace(/[^0-9A-Za-z]/gu, "-")}`;
|
||||
let credentialLease: SlackGatewayCredentialLease | undefined;
|
||||
let leaseHeartbeat: SlackGatewayCredentialHeartbeat | undefined;
|
||||
let leaseId = explicitLeaseId;
|
||||
let summary: MantisSlackDesktopSmokeSummary | undefined;
|
||||
let screenshotPath: string | undefined;
|
||||
@@ -663,6 +778,14 @@ export async function runMantisSlackDesktopSmoke(
|
||||
let videoPath: string | undefined;
|
||||
|
||||
try {
|
||||
const preparedCredentialEnv = await prepareGatewayCredentialEnv({
|
||||
credentialRole,
|
||||
credentialSource,
|
||||
env,
|
||||
gatewaySetup,
|
||||
});
|
||||
credentialLease = preparedCredentialEnv.credentialLease;
|
||||
leaseHeartbeat = preparedCredentialEnv.leaseHeartbeat;
|
||||
leaseId =
|
||||
leaseId ??
|
||||
(await warmupCrabbox({
|
||||
@@ -718,6 +841,7 @@ export async function runMantisSlackDesktopSmoke(
|
||||
remoteRunError = error;
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
leaseHeartbeat?.throwIfFailed();
|
||||
await copyRemoteArtifacts({
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
@@ -814,5 +938,15 @@ export async function runMantisSlackDesktopSmoke(
|
||||
if (summary?.status === "pass" && createdLease && leaseId && !keepLease) {
|
||||
await stopCrabbox({ crabboxBin, cwd: repoRoot, env, leaseId, provider, runner });
|
||||
}
|
||||
if (leaseHeartbeat) {
|
||||
await leaseHeartbeat.stop().catch((error: unknown) => {
|
||||
console.warn(`Slack credential heartbeat cleanup failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
if (credentialLease) {
|
||||
await credentialLease.release().catch((error: unknown) => {
|
||||
console.warn(`Slack credential release failed: ${formatErrorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user