mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user