From 6caa365a7ab88f1bb63b35ecd37fcfa73b7bad96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 10:07:30 +0100 Subject: [PATCH] fix: lease Slack credentials for Mantis gateway setup --- docs/concepts/mantis.md | 6 + .../slack-desktop-smoke.runtime.test.ts | 124 ++++++++++++++++ .../src/mantis/slack-desktop-smoke.runtime.ts | 134 ++++++++++++++++++ 3 files changed, 264 insertions(+) diff --git a/docs/concepts/mantis.md b/docs/concepts/mantis.md index 9ac8e88695d..825dc99b13e 100644 --- a/docs/concepts/mantis.md +++ b/docs/concepts/mantis.md @@ -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 ` reruns against a machine where an operator already logged in to Slack Web through VNC. diff --git a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.test.ts b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.test.ts index 7e0c455b211..a9f693815f0 100644 --- a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.test.ts +++ b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.test.ts @@ -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") { diff --git a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts index 403e758f056..78e285b4145 100644 --- a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts +++ b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts @@ -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; +type SlackGatewayCredentialPayload = { + channelId: string; + sutAppToken: string; + sutBotToken: string; +}; + +type SlackGatewayCredentialLease = Awaited< + ReturnType> +>; +type SlackGatewayCredentialHeartbeat = ReturnType; + 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; + 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({ + 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)}`); + }); + } } }