perf(e2e): reuse one authorized Telegram Desktop session per Mantis run (#127835)

* refactor(mantis): reuse authorized desktop captures

* fix(mantis): budget desktop authorization failures

* chore(mantis): bound desktop proof retries

* fix(e2e): drop unused recorder failure fact type export

* fix(ci): route Mantis desktop teardown through the recorder wrapper

Cleanup invoked the internal recorder executable as mantis-sut, which is
deliberately kept out of the docker group and cannot read the
recorder-owned session file; teardown therefore failed and blocked
safe_to_release. The cleanup step already runs as the recorder user, so
call the public wrapper whose exec shim cds into the session root.

* fix(e2e): make recorder failure fact lane-readable; document v2 lifecycle

The Mantis workflow runs the recorder as the desktop user while the lane
reads the authorization-failure fact as mantis-sut; 0600 made that read
fail EACCES and silently disabled the two-attempt retry budget. Write the
fact 0644 — the 0770 attempt directory bounds visibility.

Update the mantis doc's recorder section for the v2 session lifecycle:
required --session handle with healthy-session reuse, capture-only stop,
and teardown owning authorization termination and lease release.
This commit is contained in:
Ayaan Zaidi
2026-08-22 15:58:12 +05:30
committed by GitHub
parent 2d0fff4ac5
commit da0cb592dd
9 changed files with 797 additions and 253 deletions
@@ -139,7 +139,9 @@ behavior, call `block`; do not call `finish` and describe the block only in pros
Inspect `mantis-lane-facts.json`, every returned event/request, the inspection
PNG, final PNG, and cropped GIF. Confirm the evaluated message is fully visible
near the bottom and the recording covers the behavior—not only its final state.
Iterate as needed; all attempts remain recorded.
Iteration is allowed, but if `start` reports `desktop-unavailable`, record that
fact and use `block`; never retry that lane. Two non-advancing repeats of the
same failing step mean classify and stop, not retry. All attempts remain recorded.
If you design a novel working scenario worth reusing, optionally write
`MANTIS_OUTPUT_DIR/recipe-suggestion.md` with its trigger, exact commands, and
@@ -273,7 +273,7 @@ jobs:
needs: resolve_request
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 360
timeout-minutes: 120
environment: qa-live-shared
outputs:
comparison_status: ${{ steps.inspect.outputs.comparison_status }}
@@ -934,6 +934,11 @@ jobs:
abort --lane "$lane" || result=1
fi
done
# Teardown goes through the public wrapper: this step already runs as the
# recorder user, and the internal exec shim cds into the session root where
# the recorder-owned session file lives. mantis-sut can do neither.
/usr/local/bin/openclaw-telegram-desktop-recorder \
teardown --session desktop-recorder.json || result=1
if sudo test -f "$lock"; then
echo "Mantis harness lock remained after cleanup." >&2
result=1
+10 -4
View File
@@ -218,10 +218,14 @@ The `Mantis Telegram Desktop Proof` workflow invokes the recorder with its
local Docker provider. Its OpenClaw SUT remains isolated in the lane-attested
container boundary while Telegram Desktop runs in the prebaked local image.
Start a fresh authorized desktop and begin recording:
Start recording. `--session` names the run-scoped session handle: when it
already points at a healthy authorized desktop, `start` reuses it and only
begins a fresh capture in the new output directory; otherwise it provisions
and QR-authorizes a desktop first.
```bash
pnpm qa:telegram-desktop-recorder start \
--session .artifacts/qa-e2e/desktop-recorder.json \
--output-dir .artifacts/qa-e2e/telegram-desktop \
--chat -1001234567890 \
--user-driver "python3 /path/to/telegram-user-driver.py" \
@@ -231,9 +235,11 @@ pnpm qa:telegram-desktop-recorder start \
Use `view --session <recorder.json> --message-id <id>` to open a recorded
group post. Use `screenshot --session <recorder.json>` for a still image. Run
`stop --session <recorder.json> --crop telegram-window` to copy the recording
and logs, build motion GIFs, terminate the Telegram Desktop authorization, and
release the Crabbox lease. Add `--keep-box` only when the lease must remain
available for WebVNC inspection.
and logs and build motion GIFs; the authorized desktop stays alive for the next
`start`, so repeated captures skip provisioning and QR login. When the run is
finished, run `teardown --session <recorder.json>` to terminate the Telegram
Desktop authorization and release the Crabbox lease; the box stays inspectable
over WebVNC until then.
The recorder defaults to Crabbox's local Docker desktop path. Build the pinned
image once, then run `start` without coordinator access:
@@ -4,16 +4,33 @@ import { z } from "zod";
export const TELEGRAM_DESKTOP_VERSION = "7.0.9";
export const TELEGRAM_DESKTOP_AWS_IMAGE = "telegram-desktop=7.0.9";
export const TELEGRAM_DESKTOP_DOCKER_IMAGE = "openclaw-telegram-desktop:7.0.9";
export const RECORDER_AUTHORIZATION_FAILURE_FILENAME =
"telegram-desktop-authorization-failure.json";
export const recorderAuthorizationFailureSchema = z.object({
acceptedTokenCount: z.number().int().nonnegative(),
classification: z.enum(["main-window-timeout", "qr-unreadable", "token-accepted-no-transition"]),
failedAt: z.string(),
loginScreenshotPath: z.string().optional(),
qrAttemptCount: z.number().int().positive(),
});
export const recorderAuthorizationFailureFactSchema = z.object({
failures: z.array(recorderAuthorizationFailureSchema).min(1),
schemaVersion: z.literal(1),
});
export type RecorderAuthorizationFailure = z.infer<typeof recorderAuthorizationFailureSchema>;
const recorderSessionBaseSchema = z.object({
artifacts: z.record(z.string(), z.string()).optional(),
chat: z.string().regex(/^-100\d+$/u),
cleanupErrors: z.array(z.string()).optional(),
desktopSessionId: z.string().min(1),
keepBox: z.boolean(),
leaseId: z.string().min(1),
/** False when `--lease-id` borrowed an existing box: the recorder never stops it. */
leaseOwned: z.boolean(),
outputDir: z.string().min(1),
recordFps: z.number().int().positive(),
remotePaths: z.object({
desktopLog: z.string(),
@@ -22,7 +39,7 @@ const recorderSessionBaseSchema = z.object({
finalScreenshot: z.string(),
video: z.string(),
}),
schemaVersion: z.literal(1),
schemaVersion: z.literal(2),
startedAt: z.string(),
/** Telegram Desktop window as placed on the recorded desktop; the crop uses it. */
window: z.object({
@@ -61,6 +78,7 @@ export type StartOptions = {
outputDir: string;
provider: RecorderProvider;
recordFps: number;
sessionPath: string;
ttl: string;
userDriver: string[];
};
@@ -87,7 +105,11 @@ export type ScreenshotOptions = {
export type StopOptions = {
command: "stop";
crop?: "telegram-window";
keepBox: boolean;
sessionPath: string;
};
export type TeardownOptions = {
command: "teardown";
sessionPath: string;
};
@@ -114,18 +136,20 @@ type RecorderOptions =
| StartOptions
| StatusOptions
| StopOptions
| TeardownOptions
| ViewOptions;
export function recorderUsageText(): string {
return [
"Usage:",
" pnpm qa:telegram-desktop-recorder artifacts --session <recorder.json>",
' pnpm qa:telegram-desktop-recorder start --output-dir <dir> --chat <-100groupId> --user-driver "<space-separated cmd prefix>" [options]',
' pnpm qa:telegram-desktop-recorder start --session <recorder.json> --output-dir <dir> --chat <-100groupId> --user-driver "<space-separated cmd prefix>" [options]',
" pnpm qa:telegram-desktop-recorder view --session <recorder.json> --message-id <id>",
" pnpm qa:telegram-desktop-recorder actions --session <recorder.json> --actions-file <json> [--timeout-seconds <seconds>]",
" pnpm qa:telegram-desktop-recorder screenshot --session <recorder.json> [--output <png>]",
" pnpm qa:telegram-desktop-recorder recover --session <recorder.json>",
" pnpm qa:telegram-desktop-recorder stop --session <recorder.json> [--crop telegram-window] [--keep-box]",
" pnpm qa:telegram-desktop-recorder stop --session <recorder.json> [--crop telegram-window]",
" pnpm qa:telegram-desktop-recorder teardown --session <recorder.json>",
" pnpm qa:telegram-desktop-recorder status --session <recorder.json>",
"",
"Start options:",
@@ -173,7 +197,17 @@ export function parseRecorderArgs(argv: string[]): RecorderOptions {
throw new Error(recorderUsageText());
}
const parsedCommand = z
.enum(["actions", "artifacts", "recover", "screenshot", "start", "status", "stop", "view"])
.enum([
"actions",
"artifacts",
"recover",
"screenshot",
"start",
"status",
"stop",
"teardown",
"view",
])
.safeParse(rawCommand);
if (!parsedCommand.success) {
throw new Error(`Unknown command: ${rawCommand}\n\n${recorderUsageText()}`);
@@ -186,7 +220,7 @@ export function parseRecorderArgs(argv: string[]): RecorderOptions {
if (!flag) {
break;
}
if (flag === "--json" || flag === "--keep-box") {
if (flag === "--json") {
switches.add(flag);
continue;
}
@@ -210,6 +244,7 @@ export function parseRecorderArgs(argv: string[]): RecorderOptions {
"--output-dir",
"--provider",
"--record-fps",
"--session",
"--ttl",
"--user-driver",
])
@@ -230,9 +265,6 @@ export function parseRecorderArgs(argv: string[]): RecorderOptions {
if (switches.has("--json") && command !== "start") {
throw new Error(`--json is not available for ${command}.`);
}
if (switches.has("--keep-box") && command !== "stop") {
throw new Error(`--keep-box is not available for ${command}.`);
}
if (command === "start") {
const chat = requiredString(values, "--chat");
if (!/^-100\d+$/u.test(chat)) {
@@ -268,6 +300,7 @@ export function parseRecorderArgs(argv: string[]): RecorderOptions {
outputDir: requiredString(values, "--output-dir"),
provider,
recordFps: positiveInteger(values.get("--record-fps") ?? "24", "--record-fps"),
sessionPath: requiredString(values, "--session"),
ttl: values.get("--ttl") ?? "2h",
userDriver,
};
@@ -292,12 +325,12 @@ export function parseRecorderArgs(argv: string[]): RecorderOptions {
if (command === "stop") {
const crop = values.get("--crop");
if (crop === undefined) {
return { command, keepBox: switches.has("--keep-box"), sessionPath };
return { command, sessionPath };
}
if (crop !== "telegram-window") {
throw new Error("--crop must be telegram-window.");
}
return { command, crop, keepBox: switches.has("--keep-box"), sessionPath };
return { command, crop, sessionPath };
}
return { command, sessionPath };
}
+280 -115
View File
@@ -26,6 +26,9 @@ import {
import {
parseRecorderArgs,
readRecorderSession,
RECORDER_AUTHORIZATION_FAILURE_FILENAME,
recorderAuthorizationFailureFactSchema,
type RecorderAuthorizationFailure,
type ActionsOptions,
type ArtifactsOptions,
type RecoverOptions,
@@ -39,6 +42,7 @@ import {
type StartOptions,
type StatusOptions,
type StopOptions,
type TeardownOptions,
type ViewOptions,
writeRecorderSession,
} from "./telegram-desktop-recorder-contract.ts";
@@ -90,7 +94,14 @@ const confirmedQrSchema = z.object({
}),
});
class FreshDesktopRequiredError extends Error {}
class DesktopAuthorizationError extends Error {
readonly failure: RecorderAuthorizationFailure;
constructor(message: string, failure: RecorderAuthorizationFailure, options?: ErrorOptions) {
super(`${failure.classification}: ${message}`, options);
this.failure = failure;
}
}
const recorderStartupSchema = z.object({
desktopSessionId: z.string().min(1).optional(),
@@ -331,7 +342,9 @@ async function authorizeDesktop(params: {
let lastFailure: unknown;
let lastLink = "";
let acceptedWithoutTransition = 0;
let qrAttemptCount = 0;
for (let attempt = 1; attempt <= 6; attempt += 1) {
qrAttemptCount = attempt;
let link: string;
try {
const qr = await params.operations.sshRun({
@@ -390,6 +403,7 @@ async function authorizeDesktop(params: {
// an absent screenshot.
const evidencePath = path.join(params.outputDir, "telegram-login-screen.png");
let evidence: string;
let loginScreenshotPath: string | undefined;
try {
await params.operations.scpFromRemote({
cwd: params.cwd,
@@ -398,18 +412,34 @@ async function authorizeDesktop(params: {
remote: `${REMOTE_ROOT}/telegram-login-qr.png`,
run: params.operations.runCommand,
});
loginScreenshotPath = evidencePath;
evidence = ` Login screen: ${evidencePath}`;
} catch (error) {
evidence = ` Login screen could not be fetched: ${coerceErrorMessage(error)}`;
}
const message =
const classification =
acceptedWithoutTransition >= 2
? "token-accepted-no-transition"
: acceptedWithoutTransition === 1
? "main-window-timeout"
: "qr-unreadable";
const message =
classification === "token-accepted-no-transition"
? `Telegram server accepted ${acceptedWithoutTransition} login tokens, but Telegram Desktop stayed on the QR screen${detail}.${evidence}`
: `Telegram Desktop did not leave the login screen after 6 attempts${detail}.${evidence}`;
if (acceptedWithoutTransition >= 2) {
throw new FreshDesktopRequiredError(message, { cause: lastFailure });
}
throw new Error(message, { cause: lastFailure });
: classification === "main-window-timeout"
? `Telegram Desktop did not reach the main window after an accepted login token${detail}.${evidence}`
: `Telegram Desktop did not leave the login screen after 6 attempts${detail}.${evidence}`;
throw new DesktopAuthorizationError(
message,
{
acceptedTokenCount: acceptedWithoutTransition,
classification,
failedAt: new Date().toISOString(),
loginScreenshotPath,
qrAttemptCount,
},
{ cause: lastFailure },
);
}
// The recorder runs as a different user than the agent that drives it, so an output dir
@@ -448,6 +478,27 @@ function resolveOutputDir(cwd: string, outputDir: string): string {
return resolveRecorderPath(cwd, outputDir, "--output-dir");
}
function appendAuthorizationFailure(
outputDir: string,
failure: RecorderAuthorizationFailure,
): void {
const file = path.join(outputDir, RECORDER_AUTHORIZATION_FAILURE_FILENAME);
const current = fs.existsSync(file)
? recorderAuthorizationFailureFactSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")))
: { failures: [], schemaVersion: 1 as const };
const fact = recorderAuthorizationFailureFactSchema.parse({
failures: [...current.failures, failure],
schemaVersion: 1,
});
const temporary = `${file}.${process.pid}.tmp`;
// 0644: cross-identity evidence — the Mantis workflow runs the recorder as the
// desktop user while the lane reads this fact as mantis-sut to enforce its
// retry budget; the 0770 attempt directory bounds visibility. 0600 would make
// the lane's read fail EACCES and silently disable the budget.
fs.writeFileSync(temporary, `${JSON.stringify(fact, null, 2)}\n`, { mode: 0o644 });
fs.renameSync(temporary, file);
}
async function stopBox(params: {
crabboxBin: string;
cwd: string;
@@ -489,6 +540,40 @@ async function terminateDesktopSessions(params: {
z.object({ ok: z.literal(true) }).parse(JSON.parse(result.stdout));
}
async function destroyRecorderSessionResources(
cwd: string,
session: RecorderSession,
operations: Pick<RecorderOperations, "runCommand">,
): Promise<string[]> {
const errors: string[] = [];
try {
await terminateDesktopSession({
cwd,
desktopSessionId: session.desktopSessionId,
run: operations.runCommand,
userDriver: session.userDriver,
});
} catch (error) {
errors.push(`terminate Telegram Desktop session: ${coerceErrorMessage(error)}`);
}
if (session.leaseOwned) {
try {
await stopBox({
crabboxBin: process.env.OPENCLAW_TELEGRAM_USER_CRABBOX_BIN?.trim() || "crabbox",
cwd,
leaseId: session.leaseId,
provider: session.provider,
run: operations.runCommand,
});
} catch (error) {
if (!coerceErrorMessage(error).includes("lease not found")) {
errors.push(`stop Crabbox: ${coerceErrorMessage(error)}`);
}
}
}
return errors;
}
async function assertLocalTelegramImage(params: { cwd: string; run: RunCommand }): Promise<void> {
try {
await params.run({
@@ -507,17 +592,109 @@ async function assertLocalTelegramImage(params: { cwd: string; run: RunCommand }
}
}
async function startRecorderAttempt(
type RecorderResource = Pick<
RecorderSession,
"desktopSessionId" | "leaseId" | "leaseOwned" | "provider" | "userDriver"
>;
async function beginRecorderCapture(params: {
cwd: string;
inspect: CrabboxInspect;
operations: RecorderOperations;
opts: StartOptions;
outputDir: string;
resource: RecorderResource;
sessionPath: string;
}): Promise<{ session: RecorderSession; sessionPath: string }> {
await params.operations.sshRun({
command: renderTelegramViewCommand({
binary: TELEGRAM_BINARY,
link: telegramPrivatePostLink(params.opts.chat, params.opts.messageId),
workdir: TELEGRAM_WORKDIR,
}),
cwd: params.cwd,
inspect: params.inspect,
run: params.operations.runCommand,
});
const geometry = await params.operations.sshRun({
command: renderReadWindowGeometry(),
cwd: params.cwd,
inspect: params.inspect,
run: params.operations.runCommand,
stdio: "pipe",
});
const windowGeometry = parseWindowGeometry(geometry.stdout);
await params.operations.sshRun({
command: renderHideTelegramWindow(windowGeometry.id),
cwd: params.cwd,
inspect: params.inspect,
run: params.operations.runCommand,
});
const sessionBase = {
chat: params.opts.chat,
desktopSessionId: params.resource.desktopSessionId,
leaseId: params.resource.leaseId,
leaseOwned: params.resource.leaseOwned,
outputDir: params.outputDir,
recordFps: params.opts.recordFps,
remotePaths,
schemaVersion: 2 as const,
startedAt: new Date().toISOString(),
userDriver: params.resource.userDriver,
window: windowGeometry,
};
const session: RecorderSession =
params.resource.provider === "docker"
? {
...sessionBase,
imageSource: TELEGRAM_DESKTOP_DOCKER_IMAGE,
provider: "docker",
}
: {
...sessionBase,
imageSource: TELEGRAM_DESKTOP_AWS_IMAGE,
provider: "aws",
};
// Publish the current capture destination before ffmpeg starts so crash recovery
// exports or stops only this attempt, never an earlier attempt's artifacts.
writeRecorderSession(params.sessionPath, session);
await params.operations.sshRun({
command: renderStartRemoteRecording({ paths: remotePaths, recordFps: params.opts.recordFps }),
cwd: params.cwd,
inspect: params.inspect,
run: params.operations.runCommand,
});
return { session, sessionPath: params.sessionPath };
}
async function inspectHealthySession(params: {
crabboxBin: string;
cwd: string;
operations: RecorderOperations;
session: RecorderSession;
}): Promise<CrabboxInspect | undefined> {
try {
const inspect = await sessionInspect(params);
const mainWindow = await desktopReachedMainWindow({
cwd: params.cwd,
inspect,
operations: params.operations,
seconds: 5,
});
return mainWindow.reached ? inspect : undefined;
} catch {
return undefined;
}
}
async function provisionRecorder(
cwd: string,
opts: StartOptions,
operations: RecorderOperations,
freshContainerAttempt: number,
outputDir: string,
sessionPath: string,
): Promise<{ session: RecorderSession; sessionPath: string }> {
const crabboxBin = process.env.OPENCLAW_TELEGRAM_USER_CRABBOX_BIN?.trim() || "crabbox";
const outputDir = resolveOutputDir(cwd, opts.outputDir);
fs.mkdirSync(outputDir, { recursive: true });
assertOutputDirWritable(outputDir);
const sessionPath = path.join(outputDir, "recorder.json");
const startupPath = recorderStartupPath(sessionPath);
let leaseId = opts.leaseId;
const leaseOwned = !opts.leaseId;
@@ -532,7 +709,7 @@ async function startRecorderAttempt(
};
// Provisioning crosses process and provider boundaries. Persist each acquired
// handle so a later workflow step can reclaim it after cancellation or SIGKILL.
writeRecorderStartup(startupPath, startup, freshContainerAttempt === 1);
writeRecorderStartup(startupPath, startup, true);
try {
if (!leaseId) {
if (opts.provider === "docker") {
@@ -593,71 +770,27 @@ async function startRecorderAttempt(
outputDir,
userDriver: opts.userDriver,
});
// Always open the target chat before recording: at the recorder's width Telegram shows
// either the chat list or one conversation, and the list is the QA account's own.
await operations.sshRun({
command: renderTelegramViewCommand({
binary: TELEGRAM_BINARY,
link: telegramPrivatePostLink(opts.chat, opts.messageId),
workdir: TELEGRAM_WORKDIR,
}),
const result = await beginRecorderCapture({
cwd,
inspect,
run: operations.runCommand,
operations,
opts,
outputDir,
resource: {
desktopSessionId,
leaseId,
leaseOwned,
provider: opts.provider,
userDriver: opts.userDriver,
},
sessionPath,
});
// Crop from the window Telegram actually got: window managers and providers
// place it differently, and a fixed crop silently cuts the chat pane.
const geometry = await operations.sshRun({
command: renderReadWindowGeometry(),
cwd,
inspect,
run: operations.runCommand,
stdio: "pipe",
});
const windowGeometry = parseWindowGeometry(geometry.stdout);
// The lane clears prior history before recorder start. Keep the empty chat hidden until
// the first session-owned send is ready, so setup frames reveal neither account UI nor chat.
await operations.sshRun({
command: renderHideTelegramWindow(windowGeometry.id),
cwd,
inspect,
run: operations.runCommand,
});
await operations.sshRun({
command: renderStartRemoteRecording({ paths: remotePaths, recordFps: opts.recordFps }),
cwd,
inspect,
run: operations.runCommand,
});
const sessionBase: Omit<RecorderSession, "imageSource" | "provider"> = {
chat: opts.chat,
desktopSessionId,
keepBox: false,
leaseId,
leaseOwned,
recordFps: opts.recordFps,
remotePaths,
schemaVersion: 1,
window: windowGeometry,
startedAt: new Date().toISOString(),
userDriver: opts.userDriver,
};
const session: RecorderSession =
opts.provider === "docker"
? {
...sessionBase,
imageSource: TELEGRAM_DESKTOP_DOCKER_IMAGE,
provider: opts.provider,
}
: {
...sessionBase,
imageSource: TELEGRAM_DESKTOP_AWS_IMAGE,
provider: opts.provider,
};
writeRecorderSession(sessionPath, session);
fs.rmSync(startupPath);
return { session, sessionPath };
return result;
} catch (error) {
if (error instanceof DesktopAuthorizationError) {
appendAuthorizationFailure(outputDir, error.failure);
}
const cleanupErrors: string[] = [];
if (desktopAuthorizationRequested) {
try {
@@ -688,17 +821,9 @@ async function startRecorderAttempt(
cleanupErrors.push(coerceErrorMessage(cleanupError));
}
}
if (
error instanceof FreshDesktopRequiredError &&
cleanupErrors.length === 0 &&
leaseOwned &&
opts.provider === "docker" &&
freshContainerAttempt === 1
) {
return await startRecorderAttempt(cwd, opts, operations, freshContainerAttempt + 1);
}
if (cleanupErrors.length === 0) {
fs.rmSync(startupPath, { force: true });
fs.rmSync(sessionPath, { force: true });
}
const suffix = cleanupErrors.length ? ` Cleanup also failed: ${cleanupErrors.join("; ")}` : "";
throw new Error(`${coerceErrorMessage(error)}${suffix}`, { cause: error });
@@ -710,7 +835,40 @@ export async function startRecorder(
opts: StartOptions,
operations: RecorderOperations = defaultOperations,
): Promise<{ session: RecorderSession; sessionPath: string }> {
return await startRecorderAttempt(cwd, opts, operations, 1);
const outputDir = resolveOutputDir(cwd, opts.outputDir);
fs.mkdirSync(outputDir, { recursive: true });
assertOutputDirWritable(outputDir);
const sessionPath = resolveRecorderPath(cwd, opts.sessionPath, "--session");
if (!fs.existsSync(sessionPath)) {
return await provisionRecorder(cwd, opts, operations, outputDir, sessionPath);
}
const session = readRecorderSession(sessionPath);
if (
session.chat !== opts.chat ||
session.provider !== opts.provider ||
session.userDriver.join("\0") !== opts.userDriver.join("\0")
) {
throw new Error("Existing recorder session does not match this start request.");
}
const crabboxBin = process.env.OPENCLAW_TELEGRAM_USER_CRABBOX_BIN?.trim() || "crabbox";
const inspect = await inspectHealthySession({ crabboxBin, cwd, operations, session });
if (inspect) {
return await beginRecorderCapture({
cwd,
inspect,
operations,
opts,
outputDir,
resource: session,
sessionPath,
});
}
const cleanupErrors = await destroyRecorderSessionResources(cwd, session, operations);
if (cleanupErrors.length) {
throw new Error(`Unhealthy recorder session cleanup failed:\n${cleanupErrors.join("\n")}`);
}
fs.rmSync(sessionPath);
return await provisionRecorder(cwd, opts, operations, outputDir, sessionPath);
}
export async function recoverRecorderStartup(
@@ -767,8 +925,8 @@ export function recorderArtifacts(
opts: ArtifactsOptions,
): { artifacts: Record<string, string> } {
const sessionPath = resolveRecorderPath(cwd, opts.sessionPath, "--session");
const outputDir = path.dirname(sessionPath);
const session = readRecorderSession(sessionPath);
const outputDir = session.outputDir;
const artifacts: Record<string, string> = {};
for (const [name, file] of Object.entries(session.artifacts ?? {})) {
const resolved = path.resolve(file);
@@ -942,7 +1100,7 @@ export async function screenshotRecorder(
const output =
opts.output ??
path.join(
path.dirname(opts.sessionPath),
path.relative(cwd, session.outputDir),
`telegram-desktop-recorder-screenshot-${new Date().toISOString().replace(/[:.]/gu, "-")}.png`,
);
const outputPath = resolveRecorderPath(cwd, output, "--output");
@@ -967,7 +1125,7 @@ export async function stopRecorder(
const sessionPath = resolveRecorderPath(cwd, opts.sessionPath, "--session");
const session = readRecorderSession(sessionPath);
const crabboxBin = process.env.OPENCLAW_TELEGRAM_USER_CRABBOX_BIN?.trim() || "crabbox";
const outputDir = path.dirname(sessionPath);
const outputDir = session.outputDir;
const errors: string[] = [];
const artifacts: Record<string, string> = {};
const attempt = async (label: string, action: () => Promise<void>) => {
@@ -978,14 +1136,12 @@ export async function stopRecorder(
}
};
let inspect: CrabboxInspect | undefined;
let leaseGone = false;
await attempt("inspect", async () => {
try {
inspect = await sessionInspect({ crabboxBin, cwd, operations, session });
} catch (error) {
// A lease that no longer exists is the desired end state, not a failure.
if (coerceErrorMessage(error).includes("lease not found")) {
leaseGone = true;
return;
}
throw error;
@@ -1077,36 +1233,12 @@ export async function stopRecorder(
});
}
}
// --keep-box keeps the whole debugging surface: the Desktop authorization stays
// valid for WebVNC until the operator finishes; a later `stop` without it revokes.
if (!opts.keepBox) {
await attempt("terminate Telegram Desktop session", async () => {
await terminateDesktopSession({
cwd,
desktopSessionId: session.desktopSessionId,
run: operations.runCommand,
userDriver: session.userDriver,
});
});
}
if (!opts.keepBox && session.leaseOwned && !leaseGone) {
await attempt("stop Crabbox", async () => {
await stopBox({
crabboxBin,
cwd,
leaseId: session.leaseId,
provider: session.provider,
run: operations.runCommand,
});
});
}
const stopped: RecorderSession = {
...session,
// Keep paths recorded by an earlier stop (--keep-box, then a later stop once
// the lease expired); fresh copies overwrite their own entries.
// Keep paths recorded by an earlier stop if a later cleanup sees an expired lease;
// fresh copies overwrite their own entries.
artifacts: { ...session.artifacts, ...artifacts },
cleanupErrors: errors.length ? errors : undefined,
keepBox: opts.keepBox,
stoppedAt: new Date().toISOString(),
};
writeRecorderSession(sessionPath, stopped);
@@ -1116,6 +1248,35 @@ export async function stopRecorder(
return stopped;
}
export async function teardownRecorder(
cwd: string,
opts: TeardownOptions,
operations: Pick<RecorderOperations, "runCommand"> = defaultOperations,
): Promise<{ tornDown: boolean }> {
const sessionPath = resolveRecorderPath(cwd, opts.sessionPath, "--session");
const startupPath = recorderStartupPath(sessionPath);
if (fs.existsSync(startupPath)) {
await recoverRecorderStartup(
cwd,
{ command: "recover", sessionPath: opts.sessionPath },
operations,
);
fs.rmSync(sessionPath, { force: true });
return { tornDown: true };
}
if (!fs.existsSync(sessionPath)) {
return { tornDown: false };
}
const session = readRecorderSession(sessionPath);
const errors = await destroyRecorderSessionResources(cwd, session, operations);
if (errors.length) {
writeRecorderSession(sessionPath, { ...session, cleanupErrors: errors });
throw new Error(`Recorder teardown completed with errors:\n${errors.join("\n")}`);
}
fs.rmSync(sessionPath);
return { tornDown: true };
}
async function statusRecorder(
cwd: string,
opts: StatusOptions,
@@ -1173,6 +1334,10 @@ async function main(): Promise<void> {
console.log(JSON.stringify(await stopRecorder(cwd, opts), null, 2));
return;
}
if (opts.command === "teardown") {
console.log(JSON.stringify(await teardownRecorder(cwd, opts), null, 2));
return;
}
console.log(JSON.stringify(await statusRecorder(cwd, opts, defaultOperations), null, 2));
}
+116 -13
View File
@@ -11,6 +11,12 @@ import { z } from "zod";
import { coerceErrorMessage } from "../lib/error-format.mts";
import { sleep } from "../lib/sleep.mjs";
import { telegramBotApi } from "./telegram-bot-api.ts";
import {
RECORDER_AUTHORIZATION_FAILURE_FILENAME,
recorderAuthorizationFailureSchema,
recorderAuthorizationFailureFactSchema,
type RecorderAuthorizationFailure,
} from "./telegram-desktop-recorder-contract.ts";
import {
destroyMantisSut,
type MantisSutRecovery,
@@ -119,6 +125,13 @@ const invocationSchema = z.object({
const recorderArtifactsSchema = z.object({
artifacts: z.record(z.string(), z.string()),
});
const desktopRecorderFailureBudgetSchema = z.object({
attemptCount: z.number().int().positive(),
classification: recorderAuthorizationFailureSchema.shape.classification,
loginScreenshotPath: z.string().optional(),
schemaVersion: z.literal(1),
unavailable: z.boolean(),
});
const activeSessionSchema = z.object({
attempt: z.number().int().positive(),
config: configSchema,
@@ -151,6 +164,7 @@ type ObserverResponse = {
ok: boolean;
truncated?: boolean;
} & Record<string, unknown>;
type DesktopRecorderFailureBudget = z.infer<typeof desktopRecorderFailureBudgetSchema>;
const MAX_SENDS = 12;
const MAX_RPC_BYTES = 4 * 1024 * 1024;
@@ -315,6 +329,98 @@ function writeJsonAtomic(file: string, value: unknown, mode = 0o600): void {
fs.chmodSync(file, mode);
}
function desktopRecorderFailureBudgetFile(sessionRoot: string): string {
return path.join(sessionRoot, "desktop-recorder-failures.json");
}
function readDesktopRecorderFailureBudget(
sessionRoot: string,
): DesktopRecorderFailureBudget | undefined {
const file = desktopRecorderFailureBudgetFile(sessionRoot);
return fs.existsSync(file) ? desktopRecorderFailureBudgetSchema.parse(readJson(file)) : undefined;
}
function desktopUnavailableMessage(fact: DesktopRecorderFailureBudget, factFile: string): string {
const screenshotDetail = fact.loginScreenshotPath
? `, loginScreenshotPath=${fact.loginScreenshotPath}`
: "";
return (
`desktop-unavailable: stop retrying; this run's desktop is unavailable ` +
`(attemptCount=${fact.attemptCount}, classification=${fact.classification}${screenshotDetail}, ` +
`fact=${factFile})`
);
}
function assertDesktopRecorderAvailable(sessionRoot: string): void {
const fact = readDesktopRecorderFailureBudget(sessionRoot);
if (fact?.unavailable) {
throw new Error(desktopUnavailableMessage(fact, desktopRecorderFailureBudgetFile(sessionRoot)));
}
}
function recordDesktopRecorderFailures(
sessionRoot: string,
failures: RecorderAuthorizationFailure[],
): DesktopRecorderFailureBudget | undefined {
if (failures.length === 0) {
return readDesktopRecorderFailureBudget(sessionRoot);
}
const prior = readDesktopRecorderFailureBudget(sessionRoot);
const latest = failures.at(-1);
if (!latest) {
return prior;
}
const attemptCount = (prior?.attemptCount ?? 0) + failures.length;
const fact = desktopRecorderFailureBudgetSchema.parse({
attemptCount,
classification: latest.classification,
loginScreenshotPath: latest.loginScreenshotPath,
schemaVersion: 1,
unavailable: attemptCount >= 2,
});
writeJsonAtomic(desktopRecorderFailureBudgetFile(sessionRoot), fact);
return fact;
}
export async function startDesktopRecorder(params: {
chat: string;
outputDir: string;
recorderCommand: string;
sessionPath: string;
sessionRoot: string;
userDriver: string;
}): Promise<void> {
assertDesktopRecorderAvailable(params.sessionRoot);
try {
await runCommand(params.recorderCommand, [
"start",
"--provider",
"docker",
"--session",
recorderRelativePath(params.sessionPath),
"--output-dir",
recorderRelativePath(params.outputDir),
"--chat",
params.chat,
"--user-driver",
params.userDriver,
]);
} catch (startError) {
const failureFile = path.join(params.outputDir, RECORDER_AUTHORIZATION_FAILURE_FILENAME);
const failures = fs.existsSync(failureFile)
? recorderAuthorizationFailureFactSchema.parse(readJson(failureFile)).failures
: [];
const budget = recordDesktopRecorderFailures(params.sessionRoot, failures);
if (budget?.unavailable) {
throw new Error(
`${desktopUnavailableMessage(budget, desktopRecorderFailureBudgetFile(params.sessionRoot))}\n${coerceErrorMessage(startError)}`,
{ cause: startError },
);
}
throw startError;
}
}
function publicRelativePath(root: string, file: string, label: string): string {
const resolvedRoot = fs.realpathSync(root);
const relative = path.relative(resolvedRoot, file);
@@ -801,17 +907,17 @@ async function startLane(values: Map<string, string>, roots: Roots): Promise<voi
) {
throw new Error(`Finish or abort the active ${otherLane} session first.`);
}
assertDesktopRecorderAvailable(roots.sessionRoot);
const attemptsRoot = path.join(roots.sessionRoot, "attempts", lane);
fs.mkdirSync(attemptsRoot, { recursive: true });
const attempt = fs.readdirSync(attemptsRoot).filter((entry) => /^\d+$/u.test(entry)).length + 1;
const privateDir = path.join(attemptsRoot, String(attempt));
fs.mkdirSync(privateDir, { mode: 0o770 });
const recorderSession = path.join(privateDir, "recorder.json");
const recorderSession = path.join(roots.sessionRoot, "desktop-recorder.json");
const observerSocket = path.join(privateDir, "observer.sock");
const observerJournal = path.join(privateDir, "telegram-events.ndjson");
const observerLog = path.join(privateDir, "observer.log");
const observerPidFile = path.join(privateDir, "observer.pid.json");
const recorderOutputDir = recorderRelativePath(privateDir);
const startup: StartupSession = {
attempt,
lane,
@@ -859,17 +965,14 @@ async function startLane(values: Map<string, string>, roots: Roots): Promise<voi
saveStartup(roots.sessionRoot, startup);
},
}),
runCommand(requiredEnv("OPENCLAW_TELEGRAM_DESKTOP_RECORDER_CMD"), [
"start",
"--provider",
"docker",
"--output-dir",
recorderOutputDir,
"--chat",
credential.groupId,
"--user-driver",
requiredEnv("OPENCLAW_TELEGRAM_USER_DRIVER_CMD"),
]),
startDesktopRecorder({
chat: credential.groupId,
outputDir: privateDir,
recorderCommand: requiredEnv("OPENCLAW_TELEGRAM_DESKTOP_RECORDER_CMD"),
sessionPath: recorderSession,
sessionRoot: roots.sessionRoot,
userDriver: requiredEnv("OPENCLAW_TELEGRAM_USER_DRIVER_CMD"),
}),
]);
if (sutResult.status === "fulfilled") {
sut = sutResult.value;
@@ -37,6 +37,7 @@ type WorkflowJob = {
if?: string;
needs?: string | string[];
steps?: WorkflowStep[];
"timeout-minutes"?: number;
};
type Workflow = {
@@ -265,6 +266,7 @@ describe("Mantis Telegram Desktop proof workflow", () => {
expect(privateCleanupIndex).toBeGreaterThan(cleanupIndex);
expect(inspectIndex).toBeGreaterThan(cleanupIndex);
const abandoned = workflowStep("Clean up abandoned Mantis sessions");
expect(workflow.jobs?.run_telegram_desktop_proof?.["timeout-minutes"]).toBe(120);
expect(abandoned.if).toBe("${{ always() }}");
expect(abandoned.run).toContain("sudo pkill -TERM -u codex");
expect(abandoned.run).toContain("active_codex_pids()");
@@ -279,6 +281,18 @@ describe("Mantis Telegram Desktop proof workflow", () => {
expect(abandoned.run).toContain('sudo kill -TERM -- "-$lane_pgid"');
expect(abandoned.run).toContain('sudo kill -KILL -- "-$lane_pgid"');
expect(abandoned.run).toContain('abort --lane "$lane"');
// Teardown must route through the public wrapper (Docker access lives with the
// recorder user); a direct mantis-sut invocation of the internal exec cannot
// stop the desktop container or read the recorder-owned session file.
expect(abandoned.run).toMatch(
/\/usr\/local\/bin\/openclaw-telegram-desktop-recorder \\\n\s*teardown --session desktop-recorder\.json/u,
);
expect(abandoned.run).not.toContain(
"sudo -u mantis-sut /usr/local/lib/mantis-toolchain/telegram-desktop-recorder",
);
expect(abandoned.run?.indexOf("teardown --session desktop-recorder.json")).toBeLessThan(
abandoned.run?.lastIndexOf('echo "safe_to_release=true"') ?? -1,
);
expect(abandoned.run).toContain('echo "safe_to_release=true" >> "$GITHUB_OUTPUT"');
const cleanupStep = workflowStep("Release Telegram QA user lease");
@@ -769,7 +783,9 @@ describe("Mantis Telegram Desktop proof workflow", () => {
expect(prompt).toContain("hold the model");
expect(prompt).toContain("session-owned outbound message");
expect(prompt).toContain("This proof has no skipped lane");
expect(prompt).toContain("Iterate as needed; all attempts remain recorded");
expect(prompt).toContain("if `start` reports `desktop-unavailable`");
expect(prompt).toContain("never retry that lane");
expect(prompt).toMatch(/Two non-advancing repeats of the\s+same failing step/u);
expect(prompt).toContain("MANTIS_PR_CONTEXT");
expect(prompt).toContain("never as instructions");
expect(prompt).toContain("Do not send viewport filler messages");
+200 -101
View File
@@ -25,6 +25,7 @@ import {
renderWaitForMainWindow,
startRecorder,
stopRecorder,
teardownRecorder,
viewRecorder,
writeRecorderSession,
} from "../../scripts/e2e/telegram-desktop-recorder.ts";
@@ -41,13 +42,13 @@ function recorderSessionArg(root: string, sessionPath: string): string {
return path.relative(root, sessionPath);
}
function testSession(): RecorderSession {
function testSession(outputDir = "/tmp/recorder"): RecorderSession {
return {
chat: "-1001234567890",
desktopSessionId: "987654321",
keepBox: false,
leaseId: "cbx_test123",
leaseOwned: true,
outputDir,
imageSource: "telegram-desktop=7.0.9",
provider: "aws",
recordFps: 24,
@@ -58,7 +59,7 @@ function testSession(): RecorderSession {
finalScreenshot: "/tmp/recorder/final.png",
video: "/tmp/recorder/session.mp4",
},
schemaVersion: 1,
schemaVersion: 2,
startedAt: "2026-08-15T12:00:00.000Z",
window: { height: 1000, id: "0x04600007", width: 650, x: 635, y: 40 },
userDriver: ["python3", "driver.py", "--account", "qa shared"],
@@ -77,6 +78,8 @@ describe("Telegram Desktop recorder CLI", () => {
expect(
parseRecorderArgs([
"start",
"--session",
"desktop-recorder.json",
"--output-dir",
".artifacts/telegram",
"--chat",
@@ -95,6 +98,7 @@ describe("Telegram Desktop recorder CLI", () => {
outputDir: ".artifacts/telegram",
provider: "docker",
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["uv", "run", "driver.py", "--json"],
});
@@ -108,18 +112,10 @@ describe("Telegram Desktop recorder CLI", () => {
parseRecorderArgs(["screenshot", "--session", "recorder.json", "--output", "shot.png"]),
).toEqual({ command: "screenshot", output: "shot.png", sessionPath: "recorder.json" });
expect(
parseRecorderArgs([
"stop",
"--session",
"recorder.json",
"--crop",
"telegram-window",
"--keep-box",
]),
parseRecorderArgs(["stop", "--session", "recorder.json", "--crop", "telegram-window"]),
).toEqual({
command: "stop",
crop: "telegram-window",
keepBox: true,
sessionPath: "recorder.json",
});
expect(parseRecorderArgs(["status", "--session", "recorder.json"])).toEqual({
@@ -130,6 +126,10 @@ describe("Telegram Desktop recorder CLI", () => {
command: "recover",
sessionPath: "recorder.json",
});
expect(parseRecorderArgs(["teardown", "--session", "recorder.json"])).toEqual({
command: "teardown",
sessionPath: "recorder.json",
});
expect(parseRecorderArgs(["artifacts", "--session", "recorder.json"])).toEqual({
command: "artifacts",
sessionPath: "recorder.json",
@@ -156,7 +156,7 @@ describe("Telegram Desktop recorder CLI", () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
const actionsPath = path.join(root, "click.json");
writeRecorderSession(sessionPath, testSession());
writeRecorderSession(sessionPath, testSession(root));
fs.writeFileSync(
actionsPath,
JSON.stringify([
@@ -253,6 +253,20 @@ describe("Telegram Desktop recorder CLI", () => {
]),
).toThrow("--user-driver is required");
});
it("requires a run-scoped session handle", () => {
expect(() =>
parseRecorderArgs([
"start",
"--output-dir",
".artifacts/telegram",
"--chat",
"-1001234",
"--user-driver",
"driver",
]),
).toThrow("--session is required");
});
});
describe("Telegram Desktop recorder remote contract", () => {
@@ -333,6 +347,7 @@ describe("Telegram Desktop recorder remote contract", () => {
outputDir: "out",
provider: testCase.provider,
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
@@ -382,6 +397,7 @@ describe("Telegram Desktop recorder remote contract", () => {
outputDir: "out",
provider: "docker",
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
@@ -428,6 +444,7 @@ describe("Telegram Desktop recorder remote contract", () => {
outputDir: "out",
provider: "docker",
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
@@ -479,50 +496,44 @@ describe("Telegram Desktop recorder remote contract", () => {
outputDir: "out",
provider: "docker",
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
operations,
),
).rejects.toThrow(
"Telegram server accepted 2 login tokens, but Telegram Desktop stayed on the QR screen: permission denied reading the remote Docker socket",
"token-accepted-no-transition: Telegram server accepted 2 login tokens, but Telegram Desktop stayed on the QR screen: permission denied reading the remote Docker socket",
);
expect(
runCommand.mock.calls.filter(([call]) => call.args.includes("terminate-session")),
).toHaveLength(2);
expect(
JSON.parse(
fs.readFileSync(
path.join(root, "out", "telegram-desktop-authorization-failure.json"),
"utf8",
),
),
).toMatchObject({ failures: [{ classification: "token-accepted-no-transition" }] });
});
it("reprovisions one fresh local desktop after an accepted-token wedge", async () => {
it("reuses one authorized desktop across sequential captures", async () => {
const root = makeTempDir();
let container = 0;
let qrAttempt = 0;
const runCommand = vi.fn<RunCommand>(async (call) => {
if (call.command === "docker") {
return { stderr: "", stdout: "[]" };
}
if (call.args[0] === "warmup") {
container += 1;
return {
stderr: "",
stdout: `leased ${container === 1 ? "cbx_0a1b2c" : "cbx_0a1b2d"} slug=quiet-crab`,
};
return { stderr: "", stdout: "leased cbx_0a1b2c slug=quiet-crab" };
}
if (call.args.includes("confirm-qr")) {
return {
stderr: "",
stdout: JSON.stringify({
ok: true,
session: { id: `${container}${qrAttempt}`, isPasswordPending: false },
}),
stdout: JSON.stringify({ ok: true, session: { id: "91234", isPasswordPending: false } }),
};
}
if (
call.args.includes("terminate-session") ||
call.args.includes("terminate-desktop-sessions")
) {
return { stderr: "", stdout: JSON.stringify({ ok: true }) };
}
return { stderr: "", stdout: "" };
return { stderr: "", stdout: JSON.stringify({ ok: true }) };
});
const inspectCrabbox = vi.fn(async () => ({
sshHost: "host",
@@ -538,11 +549,7 @@ describe("Telegram Desktop recorder remote contract", () => {
scpFromRemote: vi.fn(async () => undefined),
sshRun: vi.fn(async ({ command }: { command: string }) => {
if (command.includes("telegram-login-qr.png")) {
qrAttempt += 1;
return { stderr: "", stdout: `tg://login?token=attempt-${qrAttempt}` };
}
if (command.includes("Telegram Desktop did not reach the main window") && container === 1) {
throw new Error("first desktop stayed on QR");
return { stderr: "", stdout: "tg://login?token=first-capture" };
}
if (command.includes("getwindowgeometry")) {
return { stderr: "", stdout: "0x04600007 635 40 650 1000" };
@@ -551,30 +558,35 @@ describe("Telegram Desktop recorder remote contract", () => {
}),
} satisfies RecorderOperations;
const result = await startRecorder(
root,
{
command: "start",
chat: "-1001234567890",
crabboxClass: "standard",
idleTimeout: "1h",
json: false,
outputDir: "out",
provider: "docker",
recordFps: 24,
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
operations,
);
const options = {
command: "start" as const,
chat: "-1001234567890",
crabboxClass: "standard",
idleTimeout: "1h",
json: false,
outputDir: "attempt-1",
provider: "docker" as const,
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
};
const first = await startRecorder(root, options, operations);
await stopRecorder(root, { command: "stop", sessionPath: "desktop-recorder.json" }, operations);
const second = await startRecorder(root, { ...options, outputDir: "attempt-2" }, operations);
expect(inspectCrabbox).toHaveBeenCalledTimes(2);
expect(runCommand.mock.calls.filter(([call]) => call.args[0] === "warmup")).toHaveLength(2);
expect(runCommand.mock.calls).toContainEqual([
expect.objectContaining({ args: ["stop", "--provider", "docker", "cbx_0a1b2c"] }),
]);
expect(result.session).toMatchObject({ leaseId: "cbx_0a1b2d", leaseOwned: true });
expect(readRecorderSession(result.sessionPath)).toMatchObject({ leaseId: "cbx_0a1b2d" });
expect(runCommand.mock.calls.filter(([call]) => call.args[0] === "warmup")).toHaveLength(1);
expect(runCommand.mock.calls.filter(([call]) => call.args.includes("confirm-qr"))).toHaveLength(
1,
);
expect(
operations.sshRun.mock.calls.filter(([call]) => call.command.includes("x11grab")),
).toHaveLength(2);
expect(first.sessionPath).toBe(second.sessionPath);
expect(readRecorderSession(second.sessionPath)).toMatchObject({
leaseId: "cbx_0a1b2c",
outputDir: path.join(root, "attempt-2"),
});
});
it("hides the prepared chat before recording starts", async () => {
@@ -617,6 +629,7 @@ describe("Telegram Desktop recorder remote contract", () => {
outputDir: "out",
provider: "docker",
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
@@ -674,6 +687,7 @@ describe("Telegram Desktop recorder remote contract", () => {
outputDir: "out",
provider: "docker" as const,
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
};
@@ -683,10 +697,24 @@ describe("Telegram Desktop recorder remote contract", () => {
vi.useFakeTimers();
try {
const exhausted = expect(startRecorder(root, options, operations)).rejects.toThrow(
"telegram-login-screen.png",
"qr-unreadable: Telegram Desktop did not leave the login screen after 6 attempts",
);
await vi.runAllTimersAsync();
await exhausted;
expect(
JSON.parse(
fs.readFileSync(
path.join(root, "out", "telegram-desktop-authorization-failure.json"),
"utf8",
),
),
).toMatchObject({ failures: [{ classification: "qr-unreadable" }] });
// The lane reads this fact as a different OS user than the recorder; 0600
// would break the retry budget with EACCES.
expect(
fs.statSync(path.join(root, "out", "telegram-desktop-authorization-failure.json")).mode &
0o777,
).toBe(0o644);
expect(scpFromRemote).toHaveBeenCalledWith(
expect.objectContaining({ remote: expect.stringContaining("telegram-login-qr.png") }),
);
@@ -702,6 +730,77 @@ describe("Telegram Desktop recorder remote contract", () => {
}
});
it("classifies one accepted token without a main-window transition", async () => {
const root = makeTempDir();
let qrAttempt = 0;
const operations = {
createCroppedMotionPreview: vi.fn(async () => ({ crop: "", fps: 24, outputWidth: 430 })),
createMotionPreview: vi.fn(async () => ({})),
inspectCrabbox: vi.fn(async () => ({
sshHost: "host",
sshKey: "/tmp/key",
sshPort: "22",
sshUser: "user",
})),
runCommand: vi.fn<RunCommand>(async (call) => ({
stderr: "",
stdout: call.args.includes("confirm-qr")
? JSON.stringify({ ok: true, session: { id: 91234, isPasswordPending: false } })
: JSON.stringify({ ok: true }),
})),
scpFromRemote: vi.fn(async () => undefined),
sshRun: vi.fn(async ({ command }: { command: string }) => {
if (command.includes("telegram-login-qr.png")) {
qrAttempt += 1;
if (qrAttempt === 1) {
return { stderr: "", stdout: "tg://login?token=accepted-once" };
}
throw new Error("zbarimg: no barcode detected");
}
if (command.includes("Telegram Desktop did not reach the main window")) {
throw new Error("Telegram Desktop did not reach the main window");
}
return { stderr: "", stdout: "" };
}),
} satisfies RecorderOperations;
vi.useFakeTimers();
try {
const failed = expect(
startRecorder(
root,
{
command: "start",
chat: "-1001234567890",
crabboxClass: "standard",
idleTimeout: "1h",
json: false,
leaseId: "cbx_borrowed",
outputDir: "out",
provider: "docker",
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
operations,
),
).rejects.toThrow("main-window-timeout: Telegram Desktop did not reach the main window");
await vi.runAllTimersAsync();
await failed;
expect(
JSON.parse(
fs.readFileSync(
path.join(root, "out", "telegram-desktop-authorization-failure.json"),
"utf8",
),
),
).toMatchObject({ failures: [{ classification: "main-window-timeout" }] });
} finally {
vi.useRealTimers();
}
});
it("reports the blocked user when the output dir is not writable", async () => {
const root = makeTempDir();
// The agent and the recorder run as different users, so this fails in the lane and not
@@ -738,6 +837,7 @@ describe("Telegram Desktop recorder remote contract", () => {
outputDir: "out",
provider: "docker",
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
@@ -880,7 +980,7 @@ describe("Telegram Desktop recorder window geometry", () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
writeRecorderSession(sessionPath, {
...testSession(),
...testSession(root),
window: { height: 995, id: "0x04600007", width: 648, x: 636, y: 45 },
});
const cropped = vi.fn(async () => ({ crop: "", fps: 24, outputWidth: 648 }));
@@ -907,7 +1007,6 @@ describe("Telegram Desktop recorder window geometry", () => {
{
command: "stop",
crop: "telegram-window",
keepBox: false,
sessionPath: recorderSessionArg(root, sessionPath),
},
operations,
@@ -927,9 +1026,10 @@ describe("Telegram Desktop recorder window geometry", () => {
});
describe("Telegram Desktop recorder session lifecycle", () => {
it("round-trips recorder.json schema version 1", () => {
const sessionPath = path.join(makeTempDir(), "recorder.json");
const session = testSession();
it("round-trips recorder.json schema version 2", () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
const session = testSession(root);
writeRecorderSession(sessionPath, session);
@@ -943,7 +1043,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
const screenshot = path.join(root, "screenshot.png");
fs.writeFileSync(screenshot, "proof", { mode: 0o600 });
writeRecorderSession(sessionPath, {
...testSession(),
...testSession(root),
artifacts: { screenshot },
});
@@ -961,7 +1061,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
it("keeps every recorder path inside its fixed working directory", async () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
writeRecorderSession(sessionPath, testSession());
writeRecorderSession(sessionPath, testSession(root));
expect(() =>
recorderArtifacts(root, { command: "artifacts", sessionPath: "../recorder.json" }),
).toThrow("--session must stay inside the recorder root");
@@ -991,11 +1091,13 @@ describe("Telegram Desktop recorder session lifecycle", () => {
).rejects.toThrow("--output must be relative");
});
it("writes the default screenshot beside a relative session path", async () => {
it("writes the default screenshot in the current capture directory", async () => {
const root = makeTempDir();
const sessionPath = path.join(root, "attempt", "recorder.json");
fs.mkdirSync(path.dirname(sessionPath));
writeRecorderSession(sessionPath, testSession());
const captureDir = path.join(root, "capture");
fs.mkdirSync(captureDir);
writeRecorderSession(sessionPath, testSession(captureDir));
const scpFromRemote = vi.fn(async () => undefined);
const output = await screenshotRecorder(
@@ -1016,7 +1118,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
},
);
expect(path.dirname(output)).toBe(path.dirname(sessionPath));
expect(path.dirname(output)).toBe(captureDir);
expect(scpFromRemote).toHaveBeenCalledWith(expect.objectContaining({ local: output }));
});
@@ -1057,7 +1159,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
expect(fs.existsSync(`${sessionPath}.starting`)).toBe(false);
});
it("never stops a borrowed --lease-id box, on failure or on stop", async () => {
it("never stops a borrowed --lease-id box, on failure or teardown", async () => {
const root = makeTempDir();
const calls: Array<{ args: string[]; command: string }> = [];
const mockedRun: RunCommand = async (params) => {
@@ -1088,6 +1190,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
outputDir: "out",
provider: "aws",
recordFps: 24,
sessionPath: "desktop-recorder.json",
ttl: "2h",
userDriver: ["python3", "driver.py"],
},
@@ -1099,7 +1202,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
const sessionPath = path.join(root, "recorder.json");
writeRecorderSession(sessionPath, {
...testSession(),
...testSession(root),
leaseId: "cbx_borrowed",
leaseOwned: false,
});
@@ -1112,19 +1215,19 @@ describe("Telegram Desktop recorder session lifecycle", () => {
sshUser: "user",
})),
} satisfies RecorderOperations;
await stopRecorder(
await teardownRecorder(
root,
{ command: "stop", keepBox: false, sessionPath: recorderSessionArg(root, sessionPath) },
operations,
{ command: "teardown", sessionPath: recorderSessionArg(root, sessionPath) },
{ runCommand: operations.runCommand },
);
expect(calls.some((call) => call.args.includes("terminate-session"))).toBe(true);
expect(calls.some((call) => call.args[0] === "stop")).toBe(false);
});
it("keeps the Desktop authorization and the box alive with --keep-box", async () => {
it("keeps the Desktop authorization and box alive when a capture stops", async () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
writeRecorderSession(sessionPath, testSession());
writeRecorderSession(sessionPath, testSession(root));
const calls: Array<{ args: string[]; command: string }> = [];
const mockedRun: RunCommand = async (params) => {
calls.push({ args: params.args, command: params.command });
@@ -1144,25 +1247,20 @@ describe("Telegram Desktop recorder session lifecycle", () => {
sshRun: vi.fn(async () => ({ stderr: "", stdout: "" })),
} satisfies RecorderOperations;
const stopped = await stopRecorder(
await stopRecorder(
root,
{
command: "stop",
keepBox: true,
sessionPath: recorderSessionArg(root, sessionPath),
},
{ command: "stop", sessionPath: recorderSessionArg(root, sessionPath) },
operations,
);
expect(stopped.keepBox).toBe(true);
expect(calls.some((call) => call.args.includes("terminate-session"))).toBe(false);
expect(calls.some((call) => call.args[0] === "stop")).toBe(false);
});
it("uses the recorded provider for view, inspect, and owned-lease stop", async () => {
it("uses the recorded provider for view, capture stop, and teardown", async () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
writeRecorderSession(sessionPath, {
...testSession(),
...testSession(root),
imageSource: "openclaw-telegram-desktop:7.0.9",
provider: "docker",
});
@@ -1198,9 +1296,14 @@ describe("Telegram Desktop recorder session lifecycle", () => {
);
await stopRecorder(
root,
{ command: "stop", keepBox: false, sessionPath: recorderSessionArg(root, sessionPath) },
{ command: "stop", sessionPath: recorderSessionArg(root, sessionPath) },
operations,
);
await teardownRecorder(
root,
{ command: "teardown", sessionPath: recorderSessionArg(root, sessionPath) },
{ runCommand: operations.runCommand },
);
expect(inspectCrabbox).toHaveBeenCalledTimes(2);
expect(sshCommands[0]).toContain('xdotool windowmap "$win"');
@@ -1222,7 +1325,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
it("finishes cleanly without previews when the lease is already gone", async () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
writeRecorderSession(sessionPath, testSession());
writeRecorderSession(sessionPath, testSession(root));
const preview = vi.fn(async () => ({}));
const cropped = vi.fn(async () => ({ crop: "", fps: 24, outputWidth: 650 }));
const operations = {
@@ -1244,7 +1347,6 @@ describe("Telegram Desktop recorder session lifecycle", () => {
{
command: "stop",
crop: "telegram-window",
keepBox: false,
sessionPath: recorderSessionArg(root, sessionPath),
},
operations,
@@ -1254,13 +1356,12 @@ describe("Telegram Desktop recorder session lifecycle", () => {
expect(cropped).not.toHaveBeenCalled();
});
it("keeps artifacts recorded by an earlier keep-box stop", async () => {
it("keeps artifacts recorded by an earlier capture stop", async () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
writeRecorderSession(sessionPath, {
...testSession(),
...testSession(root),
artifacts: { previewGif: "/kept/motion.gif", video: "/kept/session.mp4" },
keepBox: true,
});
const operations = {
createCroppedMotionPreview: vi.fn(async () => ({ crop: "", fps: 24, outputWidth: 650 })),
@@ -1280,7 +1381,6 @@ describe("Telegram Desktop recorder session lifecycle", () => {
root,
{
command: "stop",
keepBox: false,
sessionPath: recorderSessionArg(root, sessionPath),
},
operations,
@@ -1291,10 +1391,10 @@ describe("Telegram Desktop recorder session lifecycle", () => {
});
});
it("still stops Crabbox and reports failure when local session termination fails", async () => {
it("still stops Crabbox and reports teardown failure when session termination fails", async () => {
const root = makeTempDir();
const sessionPath = path.join(root, "recorder.json");
writeRecorderSession(sessionPath, testSession());
writeRecorderSession(sessionPath, testSession(root));
const calls: Array<{ args: string[]; command: string }> = [];
const mockedRun: RunCommand = async (params) => {
calls.push({ args: params.args, command: params.command });
@@ -1318,10 +1418,10 @@ describe("Telegram Desktop recorder session lifecycle", () => {
} satisfies RecorderOperations;
await expect(
stopRecorder(
teardownRecorder(
root,
{ command: "stop", keepBox: false, sessionPath: recorderSessionArg(root, sessionPath) },
operations,
{ command: "teardown", sessionPath: recorderSessionArg(root, sessionPath) },
{ runCommand: operations.runCommand },
),
).rejects.toThrow("terminate Telegram Desktop session: terminate failed");
@@ -1342,7 +1442,6 @@ describe("Telegram Desktop recorder session lifecycle", () => {
command: "crabbox",
});
const stopped = readRecorderSession(sessionPath);
expect(stopped.stoppedAt).toBeDefined();
expect(stopped.cleanupErrors).toContain("terminate Telegram Desktop session: terminate failed");
});
});
+120 -5
View File
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
publishableRecorderArtifacts,
publishStartupFailure,
startDesktopRecorder,
} from "../../scripts/e2e/telegram-mantis-lane.ts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
@@ -77,7 +78,7 @@ async function setupHarness(
observerPidFile: path.join(root, "observer.pid.json"),
observerSocket,
privateDir: path.join(sessionRoot, "attempt"),
recorderSession: path.join(sessionRoot, "attempt", "recorder.json"),
recorderSession: path.join(sessionRoot, "desktop-recorder.json"),
repoRoot: "/prepared/candidate",
sendCount: 0,
startedAt: new Date().toISOString(),
@@ -177,6 +178,120 @@ async function runLane(env: NodeJS.ProcessEnv, args: string[]) {
}
describe("Telegram Mantis free-form lane", () => {
it("passes one run-scoped recorder session across sequential lane starts", async () => {
const root = tempDirs.make("telegram-mantis-recorder-reuse-");
const sessionRoot = path.join(root, "private");
const recorderCommand = path.join(root, "recorder");
const recorderLog = path.join(root, "recorder.log");
const provisionLog = path.join(root, "provision.log");
fs.mkdirSync(sessionRoot);
fs.writeFileSync(
recorderCommand,
`#!/bin/sh
session=
while [ "$#" -gt 0 ]; do
if [ "$1" = --session ]; then session=$2; break; fi
shift
done
printf '%s\\n' "$session" >> ${JSON.stringify(recorderLog)}
if [ ! -f ${JSON.stringify(sessionRoot)}/"$session" ]; then
printf 'provision\\n' >> ${JSON.stringify(provisionLog)}
: > ${JSON.stringify(sessionRoot)}/"$session"
fi
`,
{ mode: 0o755 },
);
const priorSessionRoot = process.env.OPENCLAW_MANTIS_SESSION_ROOT;
process.env.OPENCLAW_MANTIS_SESSION_ROOT = sessionRoot;
try {
for (const [lane, attempt] of [
["baseline", "1"],
["candidate", "1"],
] as const) {
await startDesktopRecorder({
chat: "-100123456789",
outputDir: path.join(sessionRoot, "attempts", lane, attempt),
recorderCommand,
sessionPath: path.join(sessionRoot, "desktop-recorder.json"),
sessionRoot,
userDriver: "/usr/local/bin/telegram-user-driver",
});
}
} finally {
if (priorSessionRoot === undefined) {
delete process.env.OPENCLAW_MANTIS_SESSION_ROOT;
} else {
process.env.OPENCLAW_MANTIS_SESSION_ROOT = priorSessionRoot;
}
}
expect(fs.readFileSync(recorderLog, "utf8").trim().split("\n")).toEqual([
"desktop-recorder.json",
"desktop-recorder.json",
]);
expect(fs.readFileSync(provisionLog, "utf8").trim().split("\n")).toEqual(["provision"]);
});
it("stops invoking the recorder after two authorization failures", async () => {
const root = tempDirs.make("telegram-mantis-recorder-budget-");
const sessionRoot = path.join(root, "private");
const recorderCommand = path.join(root, "recorder");
const recorderLog = path.join(root, "recorder.log");
fs.mkdirSync(sessionRoot);
fs.writeFileSync(
recorderCommand,
`#!/bin/sh
printf '%s\\n' "$*" >> ${JSON.stringify(recorderLog)}
output=
while [ "$#" -gt 0 ]; do
if [ "$1" = --output-dir ]; then output=$2; break; fi
shift
done
count=$(wc -l < ${JSON.stringify(recorderLog)})
classification=qr-unreadable
accepted=0
if [ "$count" -eq 2 ]; then classification=token-accepted-no-transition; accepted=2; fi
mkdir -p ${JSON.stringify(sessionRoot)}/"$output"
printf '{"failures":[{"acceptedTokenCount":%s,"classification":"%s","failedAt":"2026-08-22T00:00:00.000Z","loginScreenshotPath":"%s/login.png","qrAttemptCount":6}],"schemaVersion":1}\n' "$accepted" "$classification" ${JSON.stringify(sessionRoot)}/"$output" > ${JSON.stringify(sessionRoot)}/"$output"/telegram-desktop-authorization-failure.json
exit 1
`,
{ mode: 0o755 },
);
const priorSessionRoot = process.env.OPENCLAW_MANTIS_SESSION_ROOT;
process.env.OPENCLAW_MANTIS_SESSION_ROOT = sessionRoot;
const start = (attempt: number) =>
startDesktopRecorder({
chat: "-100123456789",
outputDir: path.join(sessionRoot, "attempts", "candidate", String(attempt)),
recorderCommand,
sessionPath: path.join(sessionRoot, "desktop-recorder.json"),
sessionRoot,
userDriver: "/usr/local/bin/telegram-user-driver",
});
try {
await expect(start(1)).rejects.toThrow();
await expect(start(2)).rejects.toThrow(
"desktop-unavailable: stop retrying; this run's desktop is unavailable",
);
await expect(start(3)).rejects.toThrow(
/attemptCount=2, classification=token-accepted-no-transition.*loginScreenshotPath=.*login\.png/u,
);
} finally {
if (priorSessionRoot === undefined) {
delete process.env.OPENCLAW_MANTIS_SESSION_ROOT;
} else {
process.env.OPENCLAW_MANTIS_SESSION_ROOT = priorSessionRoot;
}
}
expect(fs.readFileSync(recorderLog, "utf8").trim().split("\n")).toHaveLength(2);
expect(
JSON.parse(fs.readFileSync(path.join(sessionRoot, "desktop-recorder-failures.json"), "utf8")),
).toMatchObject({
attemptCount: 2,
classification: "token-accepted-no-transition",
unavailable: true,
});
});
it("publishes only cropped visual evidence", () => {
expect(
publishableRecorderArtifacts({
@@ -223,7 +338,7 @@ describe("Telegram Mantis free-form lane", () => {
observerSocket: path.join(sessionRoot, "observer.sock"),
privateDir: path.join(sessionRoot, "attempts", "candidate", "1"),
recorderRequested: true,
recorderSession: path.join(sessionRoot, "attempts", "candidate", "1", "recorder.json"),
recorderSession: path.join(sessionRoot, "desktop-recorder.json"),
repoRoot: "/prepared/candidate",
startedAt,
},
@@ -321,7 +436,7 @@ describe("Telegram Mantis free-form lane", () => {
"observe",
]);
expect(fs.readFileSync(harness.recorderLog, "utf8")).toContain(
"view --session attempt/recorder.json --message-id 101",
"view --session desktop-recorder.json --message-id 101",
);
expect(JSON.parse(fs.readFileSync(harness.recorderControlLog, "utf8"))).toMatchObject({
hold: true,
@@ -404,7 +519,7 @@ describe("Telegram Mantis free-form lane", () => {
results: [{ command: "click", stdout: "clicked\n" }],
});
expect(fs.readFileSync(harness.recorderLog, "utf8")).toContain(
"actions --session attempt/recorder.json --actions-file attempt/desktop-actions-2.json --timeout-seconds 90",
"actions --session desktop-recorder.json --actions-file attempt/desktop-actions-2.json --timeout-seconds 90",
);
const state = JSON.parse(
fs.readFileSync(path.join(harness.sessionRoot, "candidate.active.json"), "utf8"),
@@ -869,7 +984,7 @@ describe("Telegram Mantis free-form lane", () => {
observerSocket: path.join(harness.sessionRoot, "observer.sock"),
privateDir: harness.sessionRoot,
recorderRequested: false,
recorderSession: path.join(harness.sessionRoot, "recorder.json"),
recorderSession: path.join(harness.sessionRoot, "desktop-recorder.json"),
repoRoot: "/prepared/candidate",
startedAt: new Date().toISOString(),
});