fix: lease Slack credentials for Mantis gateway setup

This commit is contained in:
Peter Steinberger
2026-05-05 10:07:30 +01:00
parent 9fa685e3b3
commit 6caa365a7a
3 changed files with 264 additions and 0 deletions

View File

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

View File

@@ -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") {

View File

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