feat(qa-lab): add Codex Slack approval scenarios (#91519)

* feat(qa-lab): add codex slack approval scenarios

* fix(qa-lab): harden Codex Slack approval proof

* fix(qa-lab): validate Codex approval model

* test(qa-lab): verify approved Codex operation

* fix(qa-lab): normalize Slack QA account routing

* fix(qa-lab): read Codex tool result transcripts

* fix(qa-lab): harden Codex approval cleanup

* fix(qa-lab): support Codex Mantis checkpoints

* fix(qa-lab): repair Mantis pnpm bootstrap

* fix(qa-lab): normalize Codex tool result text

* fix(qa-lab): scope Mantis pnpm fallback

* fix(qa-lab): bootstrap pinned pnpm without npm

* fix(qa-lab): terminate pnpm metadata record

* fix(qa-lab): bootstrap official node for mantis

* fix(qa-lab): make mantis pnpm shim executable

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Kevin Lin 2026-07-05 14:47:18 -07:00 committed by GitHub
parent 45f561ab6c
commit 5fdaa04eca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1619 additions and 84 deletions

View file

@ -364,7 +364,6 @@ jobs:
checkpoint_required=false
if [[ "$APPROVAL_CHECKPOINTS" == "true" ]]; then
evidence_summary="Mantis ran Slack native approval QA inside a Crabbox Linux VNC desktop, rendered pending/resolved approval checkpoints from the Slack API messages, and stored Slack QA artifacts."
expected_result="Slack native exec and plugin approval checkpoints pass"
screenshot_required=false
desktop_capture_inline=false
if [[ "$status" == "pass" ]]; then
@ -373,8 +372,10 @@ jobs:
checkpoint_scenarios=()
if [[ "$scenario_label" == "approval-checkpoints" ]]; then
checkpoint_scenarios=("slack-approval-exec-native" "slack-approval-plugin-native")
expected_result="Slack native exec and plugin approval checkpoints pass"
else
checkpoint_scenarios=("$scenario_label")
expected_result="Slack approval checkpoint passes for $scenario_label"
fi
checkpoint_scenarios_json="$(printf '%s\n' "${checkpoint_scenarios[@]}" | jq -R . | jq -s .)"
checkpoint_artifacts="$(
@ -383,12 +384,16 @@ jobs:
--argjson scenario_ids "$checkpoint_scenarios_json" \
'
def scenario_kind($id):
if $id == "slack-approval-exec-native" then "exec"
elif $id == "slack-approval-plugin-native" then "plugin"
if ($id | endswith("-exec-native")) then "exec"
elif ($id | endswith("-plugin-native")) then "plugin"
else error("unsupported approval checkpoint scenario: \($id)")
end;
def scenario_title($id):
if scenario_kind($id) == "exec" then "Exec" else "Plugin" end;
if ($id | startswith("slack-codex-")) then
"Codex \(scenario_kind($id))"
elif scenario_kind($id) == "exec" then "Exec"
else "Plugin"
end;
[
$scenario_ids[] as $id
| ["pending", "resolved"][] as $state

View file

@ -288,6 +288,14 @@ empty. Cold CI leases may still show Slack sign-in in
`slack-desktop-smoke.png`; the approval checkpoint images are the visual
proof for this lane.
The default checkpoint run keeps the two standard Slack approval scenarios.
To capture either opt-in Codex approval route, select it explicitly with
`--scenario slack-codex-approval-exec-native` or
`--scenario slack-codex-approval-plugin-native`; Mantis accepts both and emits
the same pending/resolved screenshot pair. The runner expands its checkpoint
and remote-command deadlines for each selected Codex route so the full
approval, agent completion, and resolved-update sequence can finish.
The operator checklist, GitHub workflow dispatch command, evidence-comment
contract, hydrate-mode decision table, timing interpretation, and failure
handling steps live in
@ -611,6 +619,23 @@ Scenarios (`extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts`):
scenario. Enables exec and plugin approval forwarding together so plugin
events are not suppressed by exec approval routing, then verifies the same
pending/resolved native Slack UI path.
- `slack-codex-approval-exec-native` - opt-in Codex Guardian command approval
scenario. Enables the Codex plugin in Guardian mode, routes a
Slack-originated Gateway agent turn through the Codex app-server harness,
waits for the native Slack plugin approval prompt for
`openclaw-codex-app-server`, resolves it, and verifies the Codex turn
finishes with the expected command-output and assistant markers.
- `slack-codex-approval-plugin-native` - opt-in Codex Guardian file approval
scenario. Uses an outside-workspace `apply_patch` instruction so Codex emits
the app-server file-change approval route, then verifies the same native
Slack pending/resolved approval path, final assistant marker, and exact file
contents before cleanup.
The Codex approval scenarios require an `openai/*` or `codex/*` `--model`, the
normal live model credentials, and Codex auth or API-key auth accepted by the Codex plugin.
The Slack report includes the Codex app-server method, selected Codex model key,
final Codex turn status, and operation-marker verification alongside the
redacted Slack approval metadata.
Output artifacts:

View file

@ -5,6 +5,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CodexAppServerStartOptions } from "./config.js";
import {
resolveCodexAppServerDetachedMode,
resolveCodexAppServerSpawnEnv,
resolveCodexAppServerSpawnInvocation,
} from "./transport-stdio.js";
@ -185,3 +186,19 @@ describe("resolveCodexAppServerSpawnEnv", () => {
expect(Object.hasOwn(env, "prototype")).toBe(false);
});
});
describe("resolveCodexAppServerDetachedMode", () => {
it("detaches normal POSIX app-server processes", () => {
expect(resolveCodexAppServerDetachedMode({}, "darwin")).toBe(true);
});
it("keeps QA app-server processes in the gateway process group", () => {
expect(resolveCodexAppServerDetachedMode({ OPENCLAW_QA_PARENT_PID: "12345" }, "linux")).toBe(
false,
);
});
it("does not detach Windows app-server processes", () => {
expect(resolveCodexAppServerDetachedMode({}, "win32")).toBe(false);
});
});

View file

@ -11,6 +11,7 @@ import type { CodexAppServerStartOptions } from "./config.js";
import type { CodexAppServerTransport } from "./transport.js";
const UNSAFE_ENVIRONMENT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const QA_PARENT_PID_ENV = "OPENCLAW_QA_PARENT_PID";
type CodexAppServerSpawnRuntime = {
platform: NodeJS.Platform;
@ -73,6 +74,14 @@ export function resolveCodexAppServerSpawnEnv(
return env;
}
/** Keeps QA-owned app-server processes inside the gateway process-group cleanup boundary. */
export function resolveCodexAppServerDetachedMode(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform,
): boolean {
return platform !== "win32" && !env[QA_PARENT_PID_ENV]?.trim();
}
function normalizedEnvironmentKeys(rawKeys: readonly string[]): string[] {
const keys: string[] = [];
for (const rawKey of rawKeys) {
@ -106,7 +115,7 @@ export function createStdioTransport(options: CodexAppServerStartOptions): Codex
});
return spawn(invocation.command, invocation.args, {
env,
detached: process.platform !== "win32",
detached: resolveCodexAppServerDetachedMode(env),
shell: invocation.shell,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: invocation.windowsHide,

View file

@ -989,6 +989,9 @@ describe("buildQaRuntimeEnv", () => {
child.signalCode = "SIGKILL";
queueMicrotask(() => child.emit("exit"));
}
if (signal === 0 && child.signalCode) {
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
}
return true;
});
@ -1010,6 +1013,26 @@ describe("buildQaRuntimeEnv", () => {
expect([child.exitCode, child.signalCode]).not.toEqual([null, null]);
});
it.runIf(process.platform !== "win32")(
"fails closed when forced gateway process-group shutdown times out",
async () => {
const child = Object.assign(new EventEmitter(), {
pid: 12345,
exitCode: null as number | null,
signalCode: null as string | null,
kill: vi.fn(() => true),
});
vi.spyOn(process, "kill").mockImplementation(() => true);
await expect(
testing.stopQaGatewayChildProcessTree(child as never, {
gracefulTimeoutMs: 1,
forceTimeoutMs: 1,
}),
).rejects.toThrow("qa gateway process tree remained alive after forced shutdown");
},
);
it("force-kills Windows gateway process trees when graceful taskkill fails", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalSystemRoot = process.env.SystemRoot;

View file

@ -413,11 +413,11 @@ function isQaGatewayChildProcessTreeAlive(child: ChildProcess) {
process.kill(-child.pid, 0);
return true;
} catch (error) {
if (isProcessAlreadyExitedError(error)) {
return false;
if (!isProcessAlreadyExitedError(error) && !hasChildExited(child)) {
return true;
}
return !hasChildExited(child);
}
return false;
}
type QaGatewayTaskkillRunner = typeof spawnSync;
@ -498,7 +498,10 @@ async function stopQaGatewayChildProcessTree(
return;
}
signalQaGatewayChildProcessTree(child, "SIGKILL");
await waitForQaGatewayChildExit(child, opts?.forceTimeoutMs ?? 2_000);
const stopped = await waitForQaGatewayChildExit(child, opts?.forceTimeoutMs ?? 2_000);
if (!stopped) {
throw new Error("qa gateway process tree remained alive after forced shutdown");
}
}
function isQaModelProviderConfig(value: unknown): value is ModelProviderConfig {

View file

@ -2,12 +2,20 @@
import { describe, expect, it } from "vitest";
import {
assertNoGatewayLogSentinels,
extractGatewayMessageText,
formatGatewayLogSentinelSummary,
scanDirectReplyTranscriptSentinels,
scanGatewayLogSentinels,
} from "./gateway-log-sentinel.js";
describe("gateway log sentinels", () => {
it.each([
[{ content: [{ type: "toolResult", content: "codex output" }] }, "codex output"],
[{ content: [{ type: "text", text: "standard output" }] }, "standard output"],
])("extracts message text from tool result shapes", (message, expected) => {
expect(extractGatewayMessageText(message)).toBe(expected);
});
it("classifies May 13 beta.5 operational failure signatures", () => {
const findings = scanGatewayLogSentinels(
[

View file

@ -169,9 +169,13 @@ export function extractGatewayMessageText(message: Record<string, unknown>) {
continue;
}
const nestedText = readNonEmptyString(block.content);
const normalizedType = readNonEmptyString(block.type)?.toLowerCase().replace(/_/g, "");
if (
nestedText &&
(block.type === "output_text" || block.type === "text" || block.type === "message")
(normalizedType === "outputtext" ||
normalizedType === "text" ||
normalizedType === "message" ||
normalizedType === "toolresult")
) {
parts.push(nestedText);
}

View file

@ -268,4 +268,44 @@ describe("startQaLiveLaneGateway", () => {
"failed to stop QA live lane resources:\ngateway stop failed: gateway down\nmock provider stop failed: mock down",
);
});
it("retries only mock cleanup after gateway preservation succeeds", async () => {
mockStop.mockRejectedValueOnce(new Error("mock down"));
const harness = await startQaLiveLaneGateway({
repoRoot: "/tmp/openclaw-repo",
transport: createStubTransport(),
transportBaseUrl: "http://127.0.0.1:43123",
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
alternateModel: "mock-openai/gpt-5.5-alt",
controlUiEnabled: false,
});
const stopOptions = { preserveToDir: ".artifacts/qa-e2e/debug" };
await expect(harness.stop(stopOptions)).rejects.toThrow("mock provider stop failed: mock down");
await expect(harness.stop(stopOptions)).resolves.toBeUndefined();
expect(gatewayStop).toHaveBeenCalledTimes(1);
expect(gatewayStop).toHaveBeenCalledWith(stopOptions);
expect(mockStop).toHaveBeenCalledTimes(2);
});
it("retries only gateway cleanup after mock shutdown succeeds", async () => {
gatewayStop.mockRejectedValueOnce(new Error("gateway down"));
const harness = await startQaLiveLaneGateway({
repoRoot: "/tmp/openclaw-repo",
transport: createStubTransport(),
transportBaseUrl: "http://127.0.0.1:43123",
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
alternateModel: "mock-openai/gpt-5.5-alt",
controlUiEnabled: false,
});
await expect(harness.stop()).rejects.toThrow("gateway stop failed: gateway down");
await expect(harness.stop()).resolves.toBeUndefined();
expect(gatewayStop).toHaveBeenCalledTimes(2);
expect(mockStop).toHaveBeenCalledTimes(1);
});
});

View file

@ -12,20 +12,24 @@ import { appendQaLiveLaneIssue as appendLiveLaneIssue } from "./live-artifacts.j
async function stopQaLiveLaneResources(
resources: {
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>;
gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | null;
mock: { baseUrl: string; stop(): Promise<void> } | null;
},
opts?: { keepTemp?: boolean; preserveToDir?: string },
) {
const errors: string[] = [];
try {
await resources.gateway.stop(opts);
} catch (error) {
appendLiveLaneIssue(errors, "gateway stop failed", error);
if (resources.gateway) {
try {
await resources.gateway.stop(opts);
resources.gateway = null;
} catch (error) {
appendLiveLaneIssue(errors, "gateway stop failed", error);
}
}
if (resources.mock) {
try {
await resources.mock.stop();
resources.mock = null;
} catch (error) {
appendLiveLaneIssue(errors, "mock provider stop failed", error);
}
@ -122,11 +126,12 @@ export async function startQaLiveLaneGateway(params: {
mutateConfig: (cfg) =>
prepareLiveTransportGatewayConfig(params.mutateConfig ? params.mutateConfig(cfg) : cfg),
});
const resources = { gateway, mock };
return {
gateway,
mock,
async stop(opts?: { keepTemp?: boolean; preserveToDir?: string }) {
await stopQaLiveLaneResources({ gateway, mock }, opts);
await stopQaLiveLaneResources(resources, opts);
},
};
} catch (error) {

View file

@ -38,6 +38,11 @@ describe("Slack live QA runtime helpers", () => {
).toThrow("OPENCLAW_QA_SLACK channelId must be a Slack id like C123 or U123.");
});
it("canonicalizes the SUT account before config and approval routing", () => {
expect(testing.resolveSlackQaSutAccountId(" QA-SUT ")).toBe("qa-sut");
expect(testing.resolveSlackQaSutAccountId()).toBe("sut");
});
it("parses Convex credential payloads", () => {
expect(
testing.parseSlackQaCredentialPayload({
@ -75,10 +80,47 @@ describe("Slack live QA runtime helpers", () => {
it("selects native approval scenarios by id without changing standard scenario coverage", () => {
expect(
testing
.findScenario(["slack-approval-exec-native", "slack-approval-plugin-native"])
.findScenario([
"slack-approval-exec-native",
"slack-approval-plugin-native",
"slack-codex-approval-exec-native",
"slack-codex-approval-plugin-native",
])
.map((scenario) => scenario.id),
).toEqual(["slack-approval-exec-native", "slack-approval-plugin-native"]);
).toEqual([
"slack-approval-exec-native",
"slack-approval-plugin-native",
"slack-codex-approval-exec-native",
"slack-codex-approval-plugin-native",
]);
expect(testing.SLACK_QA_STANDARD_SCENARIO_IDS).not.toContain("slack-approval-exec-native");
expect(testing.SLACK_QA_STANDARD_SCENARIO_IDS).not.toContain(
"slack-codex-approval-exec-native",
);
});
it("accepts only Codex harness providers for Codex approval scenarios", () => {
expect(() => testing.assertSlackCodexApprovalModelSupported("openai/gpt-5.5")).not.toThrow();
expect(() => testing.assertSlackCodexApprovalModelSupported("codex/gpt-5.5")).not.toThrow();
expect(() =>
testing.assertSlackCodexApprovalModelSupported("anthropic/claude-sonnet-4-6"),
).toThrow(
'Slack Codex approval scenarios require an openai/* or codex/* model; received "anthropic/claude-sonnet-4-6".',
);
});
it("rejects an incompatible Codex approval model before credential acquisition", async () => {
const outputDir = await fs.mkdtemp(path.join(tmpdir(), "openclaw-slack-codex-model-"));
await expect(
runSlackQaLive({
credentialSource: "convex",
outputDir,
primaryModel: "anthropic/claude-sonnet-4-6",
scenarioIds: ["slack-codex-approval-exec-native"],
}),
).rejects.toThrow(
'Slack Codex approval scenarios require an openai/* or codex/* model; received "anthropic/claude-sonnet-4-6".',
);
});
it("enables Slack native exec and plugin approval delivery for approval scenarios", () => {
@ -112,6 +154,58 @@ describe("Slack live QA runtime helpers", () => {
expect(account?.channels?.C123456789?.users).toEqual(["U999999999"]);
});
it("enables Codex guardian runtime and native plugin approval delivery for Codex approval scenarios", () => {
const cfg = testing.buildSlackQaConfig(
{
agents: {
defaults: {},
list: [
{
id: "qa",
model: { primary: "openai/gpt-5.5" },
},
],
},
},
{
channelId: "C123456789",
driverBotUserId: "U999999999",
overrides: {
approvals: {
exec: true,
plugin: true,
target: "channel",
},
codexApproval: true,
},
primaryModel: "openai/gpt-5.5",
sutAccountId: "sut",
sutAppToken: "xapp-sut",
sutBotToken: "xoxb-sut",
},
);
expect(cfg.plugins?.allow).toEqual(["slack", "codex"]);
expect(cfg.plugins?.entries?.codex).toEqual({
enabled: true,
config: {
appServer: {
mode: "guardian",
},
},
});
expect(cfg.tools?.exec?.mode).toBe("ask");
expect(cfg.agents?.defaults?.models?.["openai/gpt-5.5"]?.agentRuntime).toEqual({
id: "codex",
});
expect(cfg.approvals?.plugin).toEqual({ enabled: true, mode: "session" });
expect(cfg.channels?.slack?.accounts?.sut?.execApprovals).toEqual({
enabled: true,
approvers: ["U999999999"],
target: "channel",
});
});
it("overrides both owner and channel allowlists for block scenarios", () => {
const cfg = testing.buildSlackQaConfig(
{},
@ -150,6 +244,230 @@ describe("Slack live QA runtime helpers", () => {
).toEqual(["/approve plugin:abc allow-once"]);
});
it("extracts plugin approval ids from native Slack approval action values", () => {
expect(
testing.extractSlackNativeApprovalId({
actionValues: ["/approve plugin:abc123 allow-once", "/approve plugin:abc123 deny"],
decision: "allow-once",
}),
).toBe("plugin:abc123");
});
it("builds Codex approval instructions for command and file-change routes", () => {
expect(
testing.buildCodexApprovalInstruction({
appServerMethod: "item/commandExecution/requestApproval",
token: "SLACK_QA_CODEX_EXEC_APPROVAL_ABC123",
}),
).toContain("Use the shell tool exactly once");
expect(
testing.buildCodexApprovalInstruction({
appServerMethod: "item/fileChange/requestApproval",
token: "SLACK_QA_CODEX_FILE_APPROVAL_ABC123",
}),
).toContain("Do not ask for approval in chat");
expect(testing.resolveCodexFileApprovalTargetPath("MARKER")).toMatch(
/\.openclaw-qa-codex-file-approval-marker\.txt$/u,
);
});
it("reads the accepted asynchronous Gateway agent run id", () => {
expect(
testing.readAcceptedAgentRunId({
runId: "run-123",
status: "accepted",
}),
).toBe("run-123");
expect(() =>
testing.readAcceptedAgentRunId({
runId: "run-123",
status: "started",
}),
).toThrow("instead of accepted");
});
it("requires the Codex command transcript to prove the approved operation", () => {
const run = {
approvalKind: "plugin" as const,
appServerMethod: "item/commandExecution/requestApproval" as const,
decision: "allow-once" as const,
kind: "codex-approval" as const,
token: "SLACK_QA_CODEX_EXEC_APPROVAL_ABC123",
};
expect(() =>
testing.assertCodexApprovalTranscriptSucceeded(
[
{
role: "toolResult",
isError: false,
content: [
{
type: "toolResult",
content: "SLACK_QA_CODEX_EXEC_APPROVAL_ABC123",
},
],
},
{
role: "assistant",
content: [{ type: "text", text: "SLACK_QA_CODEX_EXEC_APPROVAL_ABC123" }],
},
],
run,
),
).not.toThrow();
expect(() =>
testing.assertCodexApprovalTranscriptSucceeded(
[
{
role: "assistant",
content: [{ type: "text", text: "SLACK_QA_CODEX_EXEC_APPROVAL_ABC123" }],
},
],
run,
),
).toThrow("Codex command result did not contain marker");
});
it("aborts, awaits terminal cleanup, and stops the gateway process tree before cleanup", async () => {
const call = vi
.fn()
.mockResolvedValueOnce({ aborted: true, runIds: ["run-123"] })
.mockResolvedValueOnce({ endedAt: 123, runId: "run-123", status: "ok" });
const stopGateway = vi.fn();
await testing.quiesceCodexApprovalAgentRun({
context: { gateway: { call } } as never,
preserveDebugArtifacts: false,
runId: "run-123",
sessionKey: "agent:qa:approval",
stopGateway,
});
expect(call).toHaveBeenNthCalledWith(
1,
"chat.abort",
{ runId: "run-123", sessionKey: "agent:qa:approval" },
{ timeoutMs: 10_000 },
);
expect(call).toHaveBeenNthCalledWith(
2,
"agent.wait",
{ runId: "run-123", timeoutMs: 10_000 },
{ timeoutMs: 15_000 },
);
expect(stopGateway).toHaveBeenCalledWith(false);
});
it("preserves debug artifacts when abort and terminal acknowledgements fail", async () => {
const call = vi.fn().mockRejectedValue(new Error("gateway unavailable"));
const stopGateway = vi.fn();
await testing.quiesceCodexApprovalAgentRun({
context: { gateway: { call } } as never,
preserveDebugArtifacts: true,
runId: "run-123",
sessionKey: "agent:qa:approval",
stopGateway,
});
expect(stopGateway).toHaveBeenCalledWith(true);
});
it("matches pending Codex plugin approvals by id, route, and Slack turn source", () => {
expect(
testing.findPendingCodexPluginApprovalRecord({
approvalId: "plugin:abc123",
appServerMethod: "item/fileChange/requestApproval",
channelId: "C123456789",
records: [
{
id: "plugin:abc123",
request: {
pluginId: "openclaw-codex-app-server",
title: "Codex app-server file approval",
toolName: "codex_file_approval",
sessionKey: "agent:qa:slack-codex-approval-plugin-native-token",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123456789",
turnSourceAccountId: "sut",
},
},
],
sessionKey: "agent:qa:slack-codex-approval-plugin-native-token",
sutAccountId: "sut",
}),
).toBeDefined();
expect(
testing.findPendingCodexPluginApprovalRecord({
approvalId: "plugin:abc123",
appServerMethod: "item/commandExecution/requestApproval",
channelId: "C123456789",
records: [
{
id: "plugin:abc123",
request: {
pluginId: "openclaw-codex-app-server",
title: "Codex app-server file approval",
toolName: "codex_file_approval",
sessionKey: "agent:qa:slack-codex-approval-plugin-native-token",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123456789",
turnSourceAccountId: "sut",
},
},
],
sessionKey: "agent:qa:slack-codex-approval-plugin-native-token",
sutAccountId: "sut",
}),
).toBeUndefined();
});
it("matches resolved Codex approvals without pending-only marker text", () => {
expect(
testing.matchesSlackApprovalResolvedUpdate({
actionValues: [],
approvalKind: "plugin",
decision: "allow-once",
extraTextMatches: ["openclaw-codex-app-server", "Codex app-server file approval"],
text: [
"Plugin approval: Allowed once",
"Codex app-server file approval",
"Plugin: openclaw-codex-app-server",
].join("\n"),
}),
).toBe(true);
expect(
testing.matchesSlackApprovalResolvedUpdate({
actionValues: ["/approve plugin:abc allow-once"],
approvalKind: "plugin",
decision: "allow-once",
extraTextMatches: ["Codex app-server file approval"],
text: "Plugin approval: Allowed once\nCodex app-server file approval",
}),
).toBe(false);
});
it("matches pending Codex approvals by stable renderer fields without marker text", () => {
expect(
testing.matchesSlackApprovalPromptText({
approvalKind: "plugin",
extraTextMatches: ["openclaw-codex-app-server", "Codex app-server command approval"],
text: [
"Plugin approval required",
"Codex app-server command approval",
"Plugin: openclaw-codex-app-server",
].join("\n"),
}),
).toBe(true);
expect(
testing.matchesSlackApprovalPromptText({
approvalKind: "plugin",
extraTextMatches: ["Codex app-server file approval"],
text: "Plugin approval required\nCodex app-server command approval",
}),
).toBe(false);
});
it("builds approval checkpoint message evidence from Slack blocks", () => {
expect(
testing.buildSlackApprovalCheckpointMessage({
@ -340,8 +658,12 @@ describe("Slack live QA runtime helpers", () => {
).toEqual({
approvalId: "<redacted>",
approvalKind: "plugin",
appServerMethod: undefined,
channelId: undefined,
codexModelKey: undefined,
decision: "allow-once",
finalCodexTurnStatus: undefined,
operationVerified: undefined,
pendingActionValues: undefined,
pendingCheckpointPath: undefined,
pendingMessageTs: undefined,
@ -356,6 +678,54 @@ describe("Slack live QA runtime helpers", () => {
});
});
it("keeps Codex approval route metadata while redacting Slack metadata", () => {
expect(
testing.toSlackQaScenarioArtifactResults({
includeContent: false,
redactMetadata: true,
scenarios: [
{
approval: {
approvalId: "plugin:abc",
approvalKind: "plugin",
appServerMethod: "item/fileChange/requestApproval",
channelId: "C123456789",
codexModelKey: "openai/gpt-5.5",
decision: "allow-once",
finalCodexTurnStatus: "ok",
operationVerified: true,
pendingActionValues: ["/approve plugin:abc allow-once"],
pendingMessageTs: "1.000000",
pendingText: "Plugin approval required",
resolvedActionValues: [],
resolvedMessageTs: "1.000000",
resolvedText: "Plugin approval: Allowed once",
threadTs: "1.000000",
},
details: "codex plugin approval resolved",
id: "slack-codex-approval-plugin-native",
status: "pass",
title: "Slack native Codex file approval prompt resolves",
},
],
})[0]?.approval,
).toMatchObject({
approvalId: "<redacted>",
appServerMethod: "item/fileChange/requestApproval",
channelId: undefined,
codexModelKey: "openai/gpt-5.5",
finalCodexTurnStatus: "ok",
operationVerified: true,
pendingActionValues: undefined,
pendingMessageTs: undefined,
pendingText: undefined,
resolvedActionValues: undefined,
resolvedMessageTs: undefined,
resolvedText: undefined,
threadTs: undefined,
});
});
it("ignores delayed unrelated SUT replies during mention-gating", async () => {
const observedMessages: Array<unknown> = [];
await expect(

View file

@ -198,6 +198,21 @@ describe("mantis Slack desktop smoke runtime", () => {
expect(remoteScript).toContain("${CHROME_BIN:-}");
expect(remoteScript).toContain("PNPM_STORE_DIR");
expect(remoteScript).toContain("build-essential python3");
expect(remoteScript).toContain("node_supports_type_stripping");
expect(remoteScript).toContain("scripts/crabbox-untrusted-bootstrap.sh");
expect(remoteScript).toContain("https://nodejs.org/dist/v$node_version");
expect(remoteScript).toContain('grep " $node_archive$" SHASUMS256.txt | sha256sum -c -');
expect(remoteScript).toContain('export PATH="$node_root/bin:$PATH"');
expect(remoteScript).toContain("packageManager ??");
expect(remoteScript).toContain("[0-9a-f]{128}");
expect(remoteScript).toContain('console.log(match[1] + " " + match[2])');
expect(remoteScript).toContain('active_pnpm_version="$(pnpm --version 2>/dev/null || true)"');
expect(remoteScript).toContain("https://registry.npmjs.org/pnpm/-/pnpm-$pnpm_version.tgz");
expect(remoteScript).toContain('sha512sum "$pnpm_archive"');
expect(remoteScript).toContain('chmod +x "$pnpm_cli"');
expect(remoteScript).toContain('ln -sfn "$pnpm_cli" "$pnpm_bin_dir/pnpm"');
expect(remoteScript).toContain('export PATH="$pnpm_bin_dir:$PATH"');
expect(remoteScript).toContain("Expected pnpm $pnpm_version, got $active_pnpm_version.");
expect(remoteScript).toContain("pnpm install --frozen-lockfile --prefer-offline");
expect(remoteScript).toContain("pnpm build");
expect(remoteScript).toContain("ffmpeg");
@ -370,6 +385,87 @@ describe("mantis Slack desktop smoke runtime", () => {
).rejects.toThrow("--approval-checkpoints only supports approval checkpoint scenarios");
});
it.each([
{
expectedScenarioIds: ["slack-codex-approval-exec-native"],
label: "command",
remoteTimeoutSeconds: 1_250,
requestedScenarioIds: ["slack-codex-approval-exec-native"],
},
{
expectedScenarioIds: ["slack-codex-approval-plugin-native"],
label: "file",
remoteTimeoutSeconds: 1_250,
requestedScenarioIds: ["slack-codex-approval-plugin-native"],
},
{
expectedScenarioIds: [
"slack-codex-approval-exec-native",
"slack-codex-approval-plugin-native",
],
label: "command and file",
remoteTimeoutSeconds: 1_900,
requestedScenarioIds: [
"slack-codex-approval-plugin-native",
"slack-codex-approval-exec-native",
"slack-codex-approval-plugin-native",
],
},
])(
"accepts the opt-in Codex $label checkpoint scenarios",
async ({ expectedScenarioIds, remoteTimeoutSeconds, requestedScenarioIds }) => {
const commands: { args: readonly string[]; command: string }[] = [];
const runner = vi.fn(async (command: string, args: readonly string[]) => {
commands.push({ command, args });
if (command === "/tmp/crabbox" && args[0] === "warmup") {
return { stdout: "ready lease cbx_123abc\n", stderr: "" };
}
if (command === "/tmp/crabbox" && args[0] === "inspect") {
return {
stdout: `${JSON.stringify({
host: "203.0.113.10",
id: "cbx_123abc",
provider: "hetzner",
sshKey: "/tmp/key",
sshPort: "2222",
sshUser: "crabbox",
state: "active",
})}\n`,
stderr: "",
};
}
if (command === "/tmp/crabbox" && args[0] === "run") {
throw new Error("stop after remote script capture");
}
return { stdout: "", stderr: "" };
});
const result = await runMantisSlackDesktopSmoke({
approvalCheckpoints: true,
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
outputDir: `.artifacts/qa-e2e/mantis/${requestedScenarioIds.join("-")}`,
repoRoot,
scenarioIds: requestedScenarioIds,
});
expect(result.status).toBe("fail");
const remoteScript = commands
.find((entry) => entry.command === "/tmp/crabbox" && entry.args[0] === "run")
?.args.at(-1);
for (const scenarioId of expectedScenarioIds) {
expect(remoteScript?.split(`--scenario '${scenarioId}'`)).toHaveLength(3);
}
expect(remoteScript).toContain(
expectedScenarioIds.map((scenarioId) => `--scenario '${scenarioId}'`).join(" "),
);
expect(remoteScript).toContain("OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_TIMEOUT_MS:-470000");
expect(remoteScript).toContain(
`OPENCLAW_MANTIS_REMOTE_COMMAND_TIMEOUT_SECONDS:-${remoteTimeoutSeconds}`,
);
},
);
it("fails approval checkpoint mode when ack metadata does not match the expected state", async () => {
const expectedScenarios = ["slack-approval-exec-native", "slack-approval-plugin-native"];
const runner = vi.fn(async (command: string, args: readonly string[]) => {

View file

@ -8,6 +8,7 @@ import {
acquireQaCredentialLease,
startQaCredentialLeaseHeartbeat,
} from "../live-transports/shared/credential-lease.runtime.js";
import { listSlackQaScenarioCatalog } from "../live-transports/slack/slack-live.runtime.js";
import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js";
import { createPhaseTimer, type MantisPhaseTimings } from "../mantis-phase-timer.runtime.js";
import {
@ -142,6 +143,27 @@ const DEFAULT_APPROVAL_CHECKPOINT_SCENARIOS = [
"slack-approval-exec-native",
"slack-approval-plugin-native",
] as const;
const SUPPORTED_APPROVAL_CHECKPOINT_SCENARIOS = [
...DEFAULT_APPROVAL_CHECKPOINT_SCENARIOS,
"slack-codex-approval-exec-native",
"slack-codex-approval-plugin-native",
] as const;
const DEFAULT_APPROVAL_CHECKPOINT_TIMEOUT_MS = 120_000;
const CODEX_APPROVAL_PENDING_TIMEOUT_MS = 180_000;
const CODEX_APPROVAL_RESOLVE_TIMEOUT_MS = 35_000;
const CODEX_APPROVAL_AGENT_WAIT_TIMEOUT_MS = 185_000;
const CODEX_APPROVAL_HISTORY_TIMEOUT_MS = 10_000;
const CODEX_APPROVAL_RESOLVED_UPDATE_TIMEOUT_MS = 180_000;
const CODEX_APPROVAL_CAPTURE_HEADROOM_MS = 60_000;
const CODEX_APPROVAL_POST_PENDING_BUDGET_MS =
CODEX_APPROVAL_RESOLVE_TIMEOUT_MS +
CODEX_APPROVAL_AGENT_WAIT_TIMEOUT_MS +
CODEX_APPROVAL_HISTORY_TIMEOUT_MS +
CODEX_APPROVAL_RESOLVED_UPDATE_TIMEOUT_MS +
CODEX_APPROVAL_CAPTURE_HEADROOM_MS;
const CODEX_APPROVAL_SCENARIO_BUDGET_MS =
CODEX_APPROVAL_PENDING_TIMEOUT_MS + CODEX_APPROVAL_POST_PENDING_BUDGET_MS;
const DEFAULT_REMOTE_COMMAND_TIMEOUT_SECONDS = 600;
const CRABBOX_BIN_ENV = "OPENCLAW_MANTIS_CRABBOX_BIN";
const CRABBOX_PROVIDER_ENV = "OPENCLAW_MANTIS_CRABBOX_PROVIDER";
const CRABBOX_CLASS_ENV = "OPENCLAW_MANTIS_CRABBOX_CLASS";
@ -183,15 +205,21 @@ function resolveScenarioIds(params: {
? [...DEFAULT_APPROVAL_CHECKPOINT_SCENARIOS]
: [];
if (params.approvalCheckpoints) {
const allowed = new Set<string>(DEFAULT_APPROVAL_CHECKPOINT_SCENARIOS);
const allowed = new Set<string>(SUPPORTED_APPROVAL_CHECKPOINT_SCENARIOS);
const unsupported = scenarioIds.filter((scenarioId) => !allowed.has(scenarioId));
if (unsupported.length > 0) {
throw new Error(
`--approval-checkpoints only supports approval checkpoint scenarios: ${[
...DEFAULT_APPROVAL_CHECKPOINT_SCENARIOS,
...SUPPORTED_APPROVAL_CHECKPOINT_SCENARIOS,
].join(", ")}. Unsupported: ${unsupported.join(", ")}.`,
);
}
const requested = new Set(scenarioIds);
// Slack selects scenarios from catalog order, not CLI order. The watcher
// must mirror that order or both sides can block on different checkpoints.
return listSlackQaScenarioCatalog()
.map((scenario) => scenario.id)
.filter((scenarioId) => requested.has(scenarioId));
}
return scenarioIds;
}
@ -518,6 +546,20 @@ function renderRemoteScript(params: {
const slackChannelId = shellQuote(params.slackChannelId);
const scenarioArgs = params.scenarioIds.flatMap((id) => ["--scenario", shellQuote(id)]).join(" ");
const checkpointScenarioJson = shellQuote(JSON.stringify(params.scenarioIds));
const codexScenarioCount = params.scenarioIds.filter((id) =>
id.startsWith("slack-codex-approval-"),
).length;
// The watcher starts before gateway setup, then waits for pending and resolved
// sequentially. The resolved phase includes approval, agent, history, and Slack waits.
const approvalCheckpointTimeoutMs =
codexScenarioCount > 0
? CODEX_APPROVAL_POST_PENDING_BUDGET_MS
: DEFAULT_APPROVAL_CHECKPOINT_TIMEOUT_MS;
// Preserve the existing hydration/startup budget, then add every selected
// Codex scenario's complete sequential approval budget.
const remoteCommandTimeoutSeconds =
DEFAULT_REMOTE_COMMAND_TIMEOUT_SECONDS +
Math.ceil((codexScenarioCount * CODEX_APPROVAL_SCENARIO_BUDGET_MS) / 1_000);
return `set -euo pipefail
out=${shellOutputDir}
slack_url_override=${slackUrl}
@ -532,7 +574,7 @@ setup_gateway=${setupGateway}
approval_checkpoints=${approvalCheckpoints}
slack_channel_id=${slackChannelId}
approval_checkpoint_scenarios_json=${checkpointScenarioJson}
remote_command_timeout_seconds="\${OPENCLAW_MANTIS_REMOTE_COMMAND_TIMEOUT_SECONDS:-600}"
remote_command_timeout_seconds="\${OPENCLAW_MANTIS_REMOTE_COMMAND_TIMEOUT_SECONDS:-${remoteCommandTimeoutSeconds}}"
if [ -z "\${OPENCLAW_QA_SLACK_CHANNEL_ID:-}" ] && [ -n "$slack_channel_id" ]; then
export OPENCLAW_QA_SLACK_CHANNEL_ID="$slack_channel_id"
fi
@ -652,7 +694,90 @@ qa_status=0
run_mantis_remote_body() {
set -e
echo "remote pwd: $(pwd)"
sudo corepack enable || sudo npm install -g pnpm@11
node_supports_type_stripping() {
node_probe="$(mktemp --suffix=.ts)"
printf 'const value: number = 1;\nif (value !== 1) process.exit(1);\n' >"$node_probe"
node --experimental-strip-types "$node_probe" >/dev/null 2>&1
probe_status=$?
rm -f "$node_probe"
return "$probe_status"
}
if ! node_supports_type_stripping; then
# Distro Node builds can satisfy the version range while omitting native
# TypeScript stripping, which the repository build requires.
node_version="$(sed -n 's/^node_version="\\([0-9][0-9.]*\\)"$/\\1/p' scripts/crabbox-untrusted-bootstrap.sh)"
case "$node_version" in
''|*[!0-9.]*)
echo "Could not resolve the trusted Crabbox Node version." >&2
exit 3
;;
esac
case "$(uname -m)" in
x86_64) node_arch=x64 ;;
aarch64|arm64) node_arch=arm64 ;;
*)
echo "Unsupported Node bootstrap architecture: $(uname -m)" >&2
exit 3
;;
esac
node_root="$HOME/.cache/openclaw-mantis/node-v$node_version-linux-$node_arch"
if [ ! -x "$node_root/bin/node" ]; then
node_tmp="$(mktemp -d)"
node_archive="node-v$node_version-linux-$node_arch.tar.xz"
node_base_url="https://nodejs.org/dist/v$node_version"
curl -fsSL --retry 3 --retry-all-errors "$node_base_url/SHASUMS256.txt" \
-o "$node_tmp/SHASUMS256.txt"
curl -fsSL --retry 3 --retry-all-errors "$node_base_url/$node_archive" \
-o "$node_tmp/$node_archive"
(cd "$node_tmp" && grep " $node_archive$" SHASUMS256.txt | sha256sum -c -)
rm -rf "$node_root"
mkdir -p "$node_root"
tar -xJf "$node_tmp/$node_archive" -C "$node_root" --strip-components=1
rm -rf "$node_tmp"
fi
export PATH="$node_root/bin:$PATH"
node_supports_type_stripping || {
echo "Official Node $node_version lacks required TypeScript stripping." >&2
exit 3
}
fi
node --version
read -r pnpm_version pnpm_sha512 < <(node -e '
const value = require("./package.json").packageManager ?? "";
const match = /^pnpm@([0-9]+\\.[0-9]+\\.[0-9]+)\\+sha512\\.([0-9a-f]{128})$/.exec(value);
if (!match) process.exit(1);
console.log(match[1] + " " + match[2]);
')
active_pnpm_version="$(pnpm --version 2>/dev/null || true)"
if [ "$active_pnpm_version" != "$pnpm_version" ]; then
# Some desktop images ship an old distro Corepack that enables its shim but
# cannot execute current pnpm and may omit npm. Download the exact package,
# verify the repository-pinned digest, and run its bundled CLI with Node.
pnpm_root="$out/pnpm-$pnpm_version"
pnpm_archive="$pnpm_root/pnpm.tgz"
mkdir -p "$pnpm_root"
curl -fsSL --retry 3 --retry-all-errors \
"https://registry.npmjs.org/pnpm/-/pnpm-$pnpm_version.tgz" \
-o "$pnpm_archive"
downloaded_pnpm_sha512="$(sha512sum "$pnpm_archive" | awk '{print $1}')"
if [ "$downloaded_pnpm_sha512" != "$pnpm_sha512" ]; then
echo "pnpm $pnpm_version SHA-512 mismatch." >&2
exit 3
fi
tar -xzf "$pnpm_archive" -C "$pnpm_root"
pnpm_cli="$pnpm_root/package/bin/pnpm.cjs"
chmod +x "$pnpm_cli"
pnpm_bin_dir="$pnpm_root/bin"
mkdir -p "$pnpm_bin_dir"
ln -sfn "$pnpm_cli" "$pnpm_bin_dir/pnpm"
export PATH="$pnpm_bin_dir:$PATH"
hash -r
active_pnpm_version="$(pnpm --version)"
fi
if [ "$active_pnpm_version" != "$pnpm_version" ]; then
echo "Expected pnpm $pnpm_version, got $active_pnpm_version." >&2
exit 3
fi
if [ "$hydrate_mode" = "source" ]; then
if ! command -v make >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then
sudo apt-get update -y >>"$out/apt.log" 2>&1 || true
@ -739,7 +864,7 @@ MANTIS_SLACK_PATCH
checkpoint_dir="$out/approval-checkpoints"
mkdir -p "$checkpoint_dir"
export OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_DIR="$checkpoint_dir"
export OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_TIMEOUT_MS="\${OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_TIMEOUT_MS:-120000}"
export OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_TIMEOUT_MS="\${OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_TIMEOUT_MS:-${approvalCheckpointTimeoutMs}}"
export OPENCLAW_MANTIS_APPROVAL_CHECKPOINT_SCENARIOS_JSON="$approval_checkpoint_scenarios_json"
export OPENCLAW_MANTIS_APPROVAL_BROWSER_BIN="$browser_bin"
cat >"$out/approval-checkpoint-watcher.mjs" <<'MANTIS_APPROVAL_WATCHER'
@ -749,7 +874,7 @@ MANTIS_SLACK_PATCH
const checkpointDir = process.env.OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_DIR;
const timeoutMs = Number.parseInt(
process.env.OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_TIMEOUT_MS || "120000",
process.env.OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_TIMEOUT_MS || "${approvalCheckpointTimeoutMs}",
10,
);
const scenarioIds = JSON.parse(

View file

@ -1550,6 +1550,17 @@ describe("package artifact reuse", () => {
}
});
it("maps every supported Slack approval checkpoint scenario family", () => {
const workflow = readFileSync(MANTIS_SLACK_DESKTOP_SMOKE_WORKFLOW, "utf8");
expectTextToIncludeAll(workflow, [
'endswith("-exec-native")',
'endswith("-plugin-native")',
'startswith("slack-codex-")',
'expected_result="Slack approval checkpoint passes for $scenario_label"',
]);
});
it("fails Docker E2E release lanes when summary artifacts are missing", () => {
const cases = [
{