diff --git a/.github/codex/prompts/mantis-telegram-desktop-proof.md b/.github/codex/prompts/mantis-telegram-desktop-proof.md index 7facc2d26d10..ce944468a953 100644 --- a/.github/codex/prompts/mantis-telegram-desktop-proof.md +++ b/.github/codex/prompts/mantis-telegram-desktop-proof.md @@ -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 diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index 7abe59443feb..f01e449c93d4 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -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 diff --git a/docs/concepts/mantis.md b/docs/concepts/mantis.md index 32440b4df479..bb51e4e40d3e 100644 --- a/docs/concepts/mantis.md +++ b/docs/concepts/mantis.md @@ -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 --message-id ` to open a recorded group post. Use `screenshot --session ` for a still image. Run `stop --session --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 ` 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: diff --git a/scripts/e2e/telegram-desktop-recorder-contract.ts b/scripts/e2e/telegram-desktop-recorder-contract.ts index 47c65ff0413c..42fe7cd04171 100644 --- a/scripts/e2e/telegram-desktop-recorder-contract.ts +++ b/scripts/e2e/telegram-desktop-recorder-contract.ts @@ -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; 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 ", - ' pnpm qa:telegram-desktop-recorder start --output-dir --chat <-100groupId> --user-driver "" [options]', + ' pnpm qa:telegram-desktop-recorder start --session --output-dir --chat <-100groupId> --user-driver "" [options]', " pnpm qa:telegram-desktop-recorder view --session --message-id ", " pnpm qa:telegram-desktop-recorder actions --session --actions-file [--timeout-seconds ]", " pnpm qa:telegram-desktop-recorder screenshot --session [--output ]", " pnpm qa:telegram-desktop-recorder recover --session ", - " pnpm qa:telegram-desktop-recorder stop --session [--crop telegram-window] [--keep-box]", + " pnpm qa:telegram-desktop-recorder stop --session [--crop telegram-window]", + " pnpm qa:telegram-desktop-recorder teardown --session ", " pnpm qa:telegram-desktop-recorder status --session ", "", "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 }; } diff --git a/scripts/e2e/telegram-desktop-recorder.ts b/scripts/e2e/telegram-desktop-recorder.ts index 1d1e3a223576..e628db78cb65 100644 --- a/scripts/e2e/telegram-desktop-recorder.ts +++ b/scripts/e2e/telegram-desktop-recorder.ts @@ -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, +): Promise { + 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 { 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 { + 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 = { - 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 } { const sessionPath = resolveRecorderPath(cwd, opts.sessionPath, "--session"); - const outputDir = path.dirname(sessionPath); const session = readRecorderSession(sessionPath); + const outputDir = session.outputDir; const artifacts: Record = {}; 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 = {}; const attempt = async (label: string, action: () => Promise) => { @@ -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 = 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 { 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)); } diff --git a/scripts/e2e/telegram-mantis-lane.ts b/scripts/e2e/telegram-mantis-lane.ts index 135e3297f113..72fc28aba9af 100644 --- a/scripts/e2e/telegram-mantis-lane.ts +++ b/scripts/e2e/telegram-mantis-lane.ts @@ -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; +type DesktopRecorderFailureBudget = z.infer; 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 { + 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, roots: Roots): Promise /^\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, roots: Roots): Promise { 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"); diff --git a/test/scripts/telegram-desktop-recorder.test.ts b/test/scripts/telegram-desktop-recorder.test.ts index 0798dd068495..ada70f762fac 100644 --- a/test/scripts/telegram-desktop-recorder.test.ts +++ b/test/scripts/telegram-desktop-recorder.test.ts @@ -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(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(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"); }); }); diff --git a/test/scripts/telegram-mantis-lane.test.ts b/test/scripts/telegram-mantis-lane.test.ts index 9d3d608a6467..2b6bc4b0adb8 100644 --- a/test/scripts/telegram-mantis-lane.test.ts +++ b/test/scripts/telegram-mantis-lane.test.ts @@ -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(), });