Files
openclaw/test/scripts/telegram-user-observer.test.ts
Peter Steinberger 89bb601b91 fix(qa): replace Linux-only /proc probes in the Telegram proof harness (#127718)
The Mantis lane and the Telegram user driver reached for /proc to enforce
media containment and to prove a pid was still alive. Off Linux those paths
do not exist, so the containment check compared a literal "/proc/self/fd/N"
string against the output root and refused every file, while both liveness
probes silently reported "gone": the lane stole a held harness lock and the
driver deleted the observer pid file without ever signalling the process.

Containment now uses each runtime's strongest portable construct. Python
descends the media path one component at a time from an open directory
descriptor (O_DIRECTORY|O_NOFOLLOW, dir_fd), which is race-free and also
covers the intermediate directories the old leaf-only O_NOFOLLOW did not.
Node has no openat(2), so publicRelativePath re-walks the resolved
components after the descriptor is open and refuses any that became a
symlink. Liveness moves to process.kill(pid, 0) and to ps state/args, which
keeps the pid-reuse and zombie handling the /proc reads provided.

The compound-command approval binding test asserted an operand count that
only held where the host interpreters live in a root-owned prefix; a
Homebrew python3 is writable, so it correctly binds as a third executable
operand. Assert the script operands by path instead.
2026-08-21 17:45:12 -07:00

316 lines
12 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
const pythonTest = String.raw`
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
module_path = Path("scripts/e2e/telegram-user-driver.py").resolve()
spec = importlib.util.spec_from_file_location("telegram_user_driver", module_path)
module = importlib.util.module_from_spec(spec)
sys.modules["telegram_user_driver"] = module
spec.loader.exec_module(module)
class Client:
def __init__(self):
self.requests = []
self.updates = []
def next_update(self, timeout=0):
return self.updates.pop(0) if self.updates else None
def request(self, payload, timeout=20):
self.requests.append(payload)
return {"@type": "ok"}
class Driver:
def __init__(self):
self.client = Client()
def send_text(self, chat_id, text, reply_to=None, thread_id=0, file_path=None):
self.client.updates.append({
"@type": "updateNewMessage",
"message": {
"id": 127 << 20,
"chat_id": chat_id,
"sender_id": {"@type": "messageSenderUser", "user_id": 99},
"content": {"@type": "messageText", "text": {"text": "fast reply"}},
},
})
return {
"id": 124 << 20,
"chat_id": chat_id,
"is_outgoing": True,
"sender_id": {"@type": "messageSenderUser", "user_id": 77},
"content": {"@type": "messageText", "text": {"text": text}},
}
with tempfile.TemporaryDirectory() as root:
public = Path(root) / "public"
private = Path(root) / "private"
public.mkdir()
private.mkdir()
driver = Driver()
observer = module.UserObserver(driver, -100123, 99, "qa_sut", private / "events.ndjson", public)
(public / "proof.txt").write_text("visible media")
staged_media = Path(observer.resolve_media("proof.txt"))
staged_media_content = staged_media.read_text()
staged_media_private = staged_media.parent.parent == private / "media"
staged_media_name = staged_media.name
(private / "credential.txt").write_text("do not upload")
(public / "credential-link").symlink_to(private / "credential.txt")
try:
observer.resolve_media("credential-link")
media_error = ""
except module.DriverError as error:
media_error = str(error)
(public / "escape").symlink_to(private)
try:
observer.resolve_media("escape/credential.txt")
directory_escape_error = ""
except module.DriverError as error:
directory_escape_error = str(error)
observer.ingest({
"@type": "updateNewMessage",
"message": {
"id": -123456,
"chat_id": -100123,
"is_outgoing": True,
"sending_state": {"@type": "messageSendingStatePending"},
"sender_id": {"@type": "messageSenderUser", "user_id": 77},
"content": {"@type": "messageText", "text": {"text": "pending duplicate"}},
},
})
observer.ingest({
"@type": "updateNewMessage",
"message": {
"id": 123 << 20,
"chat_id": -100123,
"sender_id": {"@type": "messageSenderUser", "user_id": 99},
"content": {"@type": "messageText", "text": {"text": "draft"}},
"reply_markup": {
"@type": "replyMarkupInlineKeyboard",
"rows": [[{
"text": "Continue",
"type": {"@type": "inlineKeyboardButtonTypeCallback", "data": "opaque"},
}]],
},
},
})
observer.ingest({
"@type": "updateMessageContent",
"chat_id": -100123,
"message_id": 123 << 20,
"new_content": {
"@type": "messageRichMessage",
"message": {"blocks": [{"@type": "richTextPlain", "text": "final"}]},
},
})
observer.ingest({
"@type": "updateChatAction",
"chat_id": -100123,
"sender_id": {"@type": "messageSenderUser", "user_id": 99},
"action": {"@type": "chatActionTyping"},
})
observer.ingest({
"@type": "updateMessageEdited",
"chat_id": -100123,
"message_id": 123 << 20,
"edit_date": 1234,
"reply_markup": {
"@type": "replyMarkupInlineKeyboard",
"rows": [[{
"text": "Updated",
"type": {"@type": "inlineKeyboardButtonTypeCallback", "data": "updated-opaque"},
}]],
},
})
observer.ingest({
"@type": "updateNewMessage",
"message": {
"id": 125 << 20,
"chat_id": -100123,
"sender_id": {"@type": "messageSenderUser", "user_id": 1000},
"content": {"@type": "messageText", "text": {"text": "private bystander text"}},
},
})
sent = observer.call({"command": "send", "text": "@{sut} /stop"})
pressed = observer.call({"command": "press", "messageId": "123", "button": 0})
deleted = observer.call({"command": "delete", "messageId": "124"})
try:
observer.call({"command": "press", "messageId": "125", "button": 0})
bystander_error = ""
except module.DriverError as error:
bystander_error = str(error)
observer.ingest({
"@type": "updateDeleteMessages",
"chat_id": -100123,
"message_ids": [123 << 20, 126 << 20],
"is_permanent": True,
"from_cache": False,
})
observer.MAX_EVENTS = len(observer.events)
observer.ingest({
"@type": "updateChatAction",
"chat_id": -100123,
"sender_id": {"@type": "messageSenderUser", "user_id": 99},
"action": {"@type": "chatActionTyping"},
})
observer.close()
socket_path = str(Path(root) / "observer.sock")
pid_file = Path(root) / "observer.pid.json"
terminate_args = type("Args", (), {"pid_file": str(pid_file), "socket": socket_path})()
terminal = subprocess.Popen(
[sys.executable, "-c", "pass", "telegram-user-driver", "serve", socket_path],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
pid_file.write_text(json.dumps({"pid": terminal.pid, "pgid": terminal.pid, "socket": socket_path}))
pid_file.chmod(0o600)
os.waitid(os.P_PID, terminal.pid, os.WEXITED | os.WNOWAIT)
try:
module.command_terminate_observer(terminate_args)
terminal_marker_removed = not pid_file.exists()
module.command_terminate_observer(terminate_args)
finally:
terminal.wait(timeout=10)
pid_file.unlink(missing_ok=True)
child = subprocess.Popen(
[
sys.executable,
"-c",
"import json, os, sys, time; "
"marker = os.fdopen(os.open(sys.argv[1], os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600), 'w'); "
"json.dump({'pid': os.getpid(), 'pgid': os.getpgrp(), 'socket': sys.argv[2]}, marker); "
"marker.close(); print('ready', flush=True); time.sleep(60)",
str(pid_file),
socket_path,
"telegram-user-driver",
"serve",
],
start_new_session=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
try:
if child.stdout.readline() != "ready\n":
raise AssertionError("The observer did not publish its owned marker.")
owned_marker = json.loads(pid_file.read_text())
pid_file.write_text(json.dumps({**owned_marker, "pid": os.getpid()}))
try:
module.command_terminate_observer(terminate_args)
raise AssertionError("Cleanup signaled a process with a mismatched identity.")
except module.DriverError as error:
foreign_error = str(error)
foreign_alive = child.poll() is None
foreign_marker_retained = pid_file.exists()
pid_file.write_text(json.dumps(owned_marker))
module.command_terminate_observer(terminate_args)
child.wait(timeout=10)
module.command_terminate_observer(terminate_args)
finally:
if child.poll() is None:
child.terminate()
child.wait(timeout=10)
child.stdout.close()
print(json.dumps({
"bystanderError": bystander_error,
"deleted": deleted,
"directoryEscapeError": directory_escape_error,
"documentContent": module.UserDriver.document_content(None, "/tmp/proof.txt", "proof"),
"events": observer.events,
"foreignAlive": foreign_alive,
"foreignError": foreign_error,
"foreignMarkerRetained": foreign_marker_retained,
"mediaError": media_error,
"pressed": pressed,
"requests": driver.client.requests,
"sent": sent,
"stagedMediaContent": staged_media_content,
"stagedMediaPrivate": staged_media_private,
"stagedMediaName": staged_media_name,
"truncated": observer.truncated,
"terminalMarkerRemoved": terminal_marker_removed,
"terminated": child.returncode is not None,
}))
`;
describe("Telegram user observer", () => {
it("records streaming, actions, and wipes without exposing bystanders", () => {
const result = spawnSync("python3", ["-"], {
cwd: process.cwd(),
encoding: "utf8",
input: pythonTest,
});
expect(result.status, result.stderr).toBe(0);
const value = JSON.parse(result.stdout);
expect(value.events).toMatchObject([
{
actor: "bot",
buttons: [{ index: 0, text: "Continue", type: "Callback" }],
kind: "message",
messageId: "123",
text: "draft",
},
{ actor: "bot", kind: "edit", messageId: "123", text: "final" },
{ actor: "bot", kind: "typing" },
{
actor: "bot",
buttons: [{ index: 0, text: "Updated", type: "Callback" }],
kind: "edit-meta",
},
{ actor: "user", kind: "message", messageId: "124", text: "@qa_sut /stop" },
{ actor: "bot", kind: "message", messageId: "127", text: "fast reply" },
{ actor: "bot", isPermanent: true, kind: "delete", messageId: "123" },
]);
expect(result.stdout).not.toContain("private bystander text");
expect(result.stdout).not.toContain("pending duplicate");
expect(value.truncated).toBe(true);
expect(value.terminalMarkerRemoved).toBe(true);
expect(value.terminated).toBe(true);
expect(value.foreignAlive).toBe(true);
expect(value.foreignMarkerRetained).toBe(true);
expect(value.foreignError).toBe("Telegram observer process identity changed before cleanup.");
expect(value.bystanderError).toBe("Message 125 was not observed in this session.");
expect(value.mediaError).toBe(
"Media must be a regular file inside the Mantis output directory.",
);
expect(value.directoryEscapeError).toBe(
"Media must be a regular file inside the Mantis output directory.",
);
expect(value.documentContent).toEqual({
"@type": "inputMessageDocument",
caption: { "@type": "formattedText", entities: [], text: "proof" },
disable_content_type_detection: false,
document: { "@type": "inputFileLocal", path: "/tmp/proof.txt" },
thumbnail: null,
});
expect(value.stagedMediaContent).toBe("visible media");
expect(value.stagedMediaPrivate).toBe(true);
expect(value.stagedMediaName).toBe("proof.txt");
expect(value.sent.sent).toMatchObject({ actor: "user", messageId: "124" });
expect(value.sent.events).toMatchObject([
{ actor: "user", messageId: "124" },
{ actor: "bot", messageId: "127", text: "fast reply" },
]);
expect(value.requests).toContainEqual({
"@type": "getCallbackQueryAnswer",
chat_id: -100123,
message_id: 123 << 20,
payload: { "@type": "callbackQueryPayloadData", data: "updated-opaque" },
});
expect(value.requests).toContainEqual({
"@type": "deleteMessages",
chat_id: -100123,
message_ids: [124 << 20],
revoke: true,
});
});
});