Merge branch 'codex/native-login-handoff' into main

# Conflicts:
#	public/index.html
#	src/bootstrap.ts
#	src/services/chat/service.ts
#	tests/chat-service-username-persist.test.ts
This commit is contained in:
jjbrunton committed 2026-08-08 16:46:16 +01:00
commit 50985b52f1
28 files changed
+987 -124

No files matched your search

+86 -26
View File
@@ -11,6 +11,7 @@ GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}"
BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}" BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}"
CHROME_CDP_PORT="${CHROME_CDP_PORT:-9222}" CHROME_CDP_PORT="${CHROME_CDP_PORT:-9222}"
GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}" GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}"
NATIVE_LOGIN_EXIT_CODE=75
export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}" export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}"
export DISPLAY=":${DISPLAY_NUM}" export DISPLAY=":${DISPLAY_NUM}"
@@ -107,13 +108,46 @@ websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \
>/var/log/websockify.log 2>&1 & >/var/log/websockify.log 2>&1 &
NOVNC_PID=$! NOVNC_PID=$!
# Start the installed consumer Google Chrome directly. Playwright attaches only
# after Chrome is running, so the human login is not performed in a browser
# process launched with Playwright's automation arguments.
CHROME_PID="" CHROME_PID=""
CHROME_READY=0 BOT_PID=""
if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then GATE_PID=""
echo "[entrypoint] starting Google Chrome for interactive login"
clear_chrome_locks() {
rm -f \
"${BROWSER_SESSION_DIR}/SingletonLock" \
"${BROWSER_SESSION_DIR}/SingletonSocket" \
"${BROWSER_SESSION_DIR}/SingletonCookie" \
2>/dev/null || true
}
stop_chrome() {
if [ -n "${CHROME_PID}" ]; then
kill "${CHROME_PID}" 2>/dev/null || true
wait "${CHROME_PID}" 2>/dev/null || true
CHROME_PID=""
fi
clear_chrome_locks
}
start_native_login_chrome() {
unset CHROME_CDP_URL
echo "[entrypoint] starting native Google Chrome login with DevTools disabled"
"${GOOGLE_CHROME_BIN}" \
--user-data-dir="${BROWSER_SESSION_DIR}" \
--no-first-run \
--no-default-browser-check \
--disable-dev-shm-usage \
--no-sandbox \
--start-maximized \
https://www.reddit.com/login/ \
>/var/log/google-chrome-native.log 2>&1 &
CHROME_PID=$!
}
start_controlled_chrome() {
export CHROME_CDP_URL="http://127.0.0.1:${CHROME_CDP_PORT}"
local chrome_ready=0
echo "[entrypoint] starting Google Chrome for authenticated bot control"
"${GOOGLE_CHROME_BIN}" \ "${GOOGLE_CHROME_BIN}" \
--user-data-dir="${BROWSER_SESSION_DIR}" \ --user-data-dir="${BROWSER_SESSION_DIR}" \
--remote-debugging-address=127.0.0.1 \ --remote-debugging-address=127.0.0.1 \
@@ -133,37 +167,63 @@ if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then
fi fi
if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then
echo "[entrypoint] Google Chrome is ready after ${i} checks" echo "[entrypoint] Google Chrome is ready after ${i} checks"
CHROME_READY=1 chrome_ready=1
break break
fi fi
sleep 0.2 sleep 0.2
done done
fi
if [ "${CHROME_READY}" != "1" ]; then if [ "${chrome_ready}" != "1" ]; then
echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2 echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2
if [ -n "${CHROME_PID}" ]; then stop_chrome
kill "${CHROME_PID}" 2>/dev/null || true unset CHROME_CDP_URL
wait "${CHROME_PID}" 2>/dev/null || true
fi fi
rm -f \ }
"${BROWSER_SESSION_DIR}/SingletonLock" \
"${BROWSER_SESSION_DIR}/SingletonSocket" \
"${BROWSER_SESSION_DIR}/SingletonCookie" \
2>/dev/null || true
CHROME_PID=""
unset CHROME_CDP_URL
fi
cleanup() { cleanup() {
echo "[entrypoint] shutting down" echo "[entrypoint] shutting down"
if [ -n "${CHROME_PID}" ]; then [ -n "${BOT_PID}" ] && kill "${BOT_PID}" 2>/dev/null || true
kill "${CHROME_PID}" 2>/dev/null || true [ -n "${GATE_PID}" ] && kill "${GATE_PID}" 2>/dev/null || true
fi stop_chrome
kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true
wait || true wait || true
} }
trap cleanup TERM INT EXIT trap cleanup TERM INT EXIT
echo "[entrypoint] launching bot" if ! command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then
exec node dist/index.js echo "[entrypoint] WARNING: Google Chrome is unavailable; using Playwright fallback" >&2
unset CHROME_CDP_URL
else
start_controlled_chrome
fi
while true; do
echo "[entrypoint] launching bot"
set +e
node dist/index.js &
BOT_PID=$!
wait "${BOT_PID}"
BOT_EXIT=$?
BOT_PID=""
set -e
if [ "${BOT_EXIT}" -ne "${NATIVE_LOGIN_EXIT_CODE}" ]; then
exit "${BOT_EXIT}"
fi
echo "[entrypoint] bot requested native Reddit login mode"
stop_chrome
start_native_login_chrome
node dist/native-login-gate.js &
GATE_PID=$!
wait "${GATE_PID}"
GATE_EXIT=$?
GATE_PID=""
if [ "${GATE_EXIT}" -ne 0 ]; then
exit "${GATE_EXIT}"
fi
echo "[entrypoint] human completed login handoff; enabling authenticated control"
stop_chrome
start_controlled_chrome
done
+83 -44
View File
@@ -23,8 +23,6 @@ import {
MatrixClient, MatrixClient,
MatrixMediaDownloader, MatrixMediaDownloader,
MatrixTokenRefresher, MatrixTokenRefresher,
extractMatrixCredentials,
loadCachedCredentials,
type UsernameResolver, type UsernameResolver,
type MatrixCredentials, type MatrixCredentials,
} from './services/chat/index.js'; } from './services/chat/index.js';
@@ -34,6 +32,12 @@ import { RateLimiter } from './utils/rate-limiter.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js'; import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js'; import { reportReply } from './web/event-reporting.js';
import {
AccountAuthContext,
authenticateCurrentAccount,
type AuthenticatedAccount,
} from './services/account-auth-context.js';
import { ProcessNativeLoginHandoff } from './services/native-login-handoff.js';
export interface AppContainer { export interface AppContainer {
config: Config; config: Config;
@@ -42,6 +46,7 @@ export interface AppContainer {
browserClient: BrowserClient; browserClient: BrowserClient;
messagePage: RedditMessagePage; messagePage: RedditMessagePage;
accountAuth: AccountAuthContext;
postsRepo: PostsRepository; postsRepo: PostsRepository;
messagesRepo: MessagesRepository; messagesRepo: MessagesRepository;
@@ -98,6 +103,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
const page = await browserClient.getPage(); const page = await browserClient.getPage();
const messagePage = new RedditMessagePage(page, logger); const messagePage = new RedditMessagePage(page, logger);
messagePage.setDryRun(config.browser.dryRun); messagePage.setDryRun(config.browser.dryRun);
const accountAuth = new AccountAuthContext(logger);
// Login verification. When the web dashboard is enabled we treat a missing // Login verification. When the web dashboard is enabled we treat a missing
// session as recoverable — the dashboard exposes a noVNC console so a human // session as recoverable — the dashboard exposes a noVNC console so a human
@@ -107,7 +113,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
if (!loggedIn) { if (!loggedIn) {
logger.warn('Session invalid or not logged in.'); logger.warn('Session invalid or not logged in.');
if (config.reddit.username && config.reddit.password) { if (!config.web.enabled && config.reddit.username && config.reddit.password) {
logger.info('Credentials found in config/env. Attempting automated login...'); logger.info('Credentials found in config/env. Attempting automated login...');
loggedIn = await browserClient.login(); loggedIn = await browserClient.login();
} }
@@ -155,36 +161,19 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
successfulMessagesRepo, successfulMessagesRepo,
postsRepo, postsRepo,
logger, logger,
accountAuth,
); );
// ---- Matrix chat backend (replaces the Playwright scraper) ---- // ---- Matrix chat backend (replaces the Playwright scraper) ----
// 1. Get credentials: prefer cached, fall back to a fresh sniff via the // A cached bearer token is not accepted as application authentication.
// persistent browser session that we just verified is logged in. // The current Chrome profile must yield a fresh token and Matrix identity
// If the sniff fails (e.g. egress blocked, chat.reddit.com refuses to // before the shared account context unlocks any bot behavior.
// load), we *don't* crash bootstrap — the post-monitoring/processor const matrixCreds: MatrixCredentials = {
// side of the bot doesn't need Matrix, and keeping the web UI up makes homeserver: 'https://matrix.redditspace.com',
// the situation diagnosable. Chat calls will fail with 401 until a accessToken: '',
// subsequent token refresh succeeds. userId: '',
let matrixCreds: MatrixCredentials | null = await loadCachedCredentials(); capturedAt: Date.now(),
if (!matrixCreds) { };
logger.info('No cached Matrix credentials — extracting via browser session...');
try {
matrixCreds = await extractMatrixCredentials(browserClient.getContext()!, logger);
} catch (err) {
logger.error(
'[matrix] credential extraction failed — chat features will be disabled until the next successful refresh:',
err,
);
matrixCreds = {
homeserver: 'https://matrix.redditspace.com',
accessToken: '',
userId: '',
capturedAt: Date.now(),
};
}
} else {
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
}
const matrixClient = new MatrixClient(matrixCreds, logger); const matrixClient = new MatrixClient(matrixCreds, logger);
// 2. Build the media downloader + service on top of the Matrix client so // 2. Build the media downloader + service on top of the Matrix client so
@@ -195,13 +184,32 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
// 3. Token refresher — on 401, restore the persistent Reddit session when // 3. Token refresher — on 401, restore the persistent Reddit session when
// possible, re-sniff once, and swap credentials into the chat service. // possible, re-sniff once, and swap credentials into the chat service.
// Concurrent callers share that refresh rather than opening extra pages. // Concurrent callers share that refresh rather than opening extra pages.
let chatService: ChatService; const credentialState: { latest: MatrixCredentials | null } = { latest: null };
const tokenRefresher = new MatrixTokenRefresher( const tokenRefresher = new MatrixTokenRefresher(
browserClient, browserClient,
logger, logger,
(fresh) => chatService.setMatrixClient(new MatrixClient(fresh, logger)), (fresh) => {
credentialState.latest = fresh;
matrixClient.updateCredentials(fresh);
},
); );
accountAuth.setAuthenticator(() => authenticateCurrentAccount({
getRedditUsername: () => browserClient.getAuthenticatedUsername(),
refreshMatrixCredentials: async () => {
credentialState.latest = null;
await tokenRefresher.refresh();
return credentialState.latest as MatrixCredentials | null;
},
}));
if (loggedIn) {
logger.info('Verifying full Reddit and Matrix account authentication...');
await accountAuth.ensureFullyAuthenticated();
} else {
accountAuth.invalidate('Interactive Reddit login is required');
}
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the // 4. Username resolver — turns t2 IDs into real Reddit usernames so the
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint. // dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
const usernameResolver: UsernameResolver = { const usernameResolver: UsernameResolver = {
@@ -233,7 +241,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
}, },
}; };
chatService = new ChatService( const chatService = new ChatService(
matrixClient, matrixClient,
logger, logger,
successfulMessagesRepo, successfulMessagesRepo,
@@ -243,24 +251,52 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
undefined, undefined,
messagesRepo, messagesRepo,
redditUsersRepo, redditUsersRepo,
accountAuth,
); );
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs); const mediaSyncService = new MediaSyncService(
chatService,
logger,
config.media.syncIntervalMs,
accountAuth,
);
const rateLimiter = new RateLimiter(config, logger); const rateLimiter = new RateLimiter(config, logger);
await chatService.loadCache(); await chatService.loadCache();
// Fire-and-forget startup sync: full if we've never backfilled, delta let startupSyncAccountId: string | null = null;
// otherwise. Non-blocking so the rest of the bot can come up immediately; let startupSyncInFlightAccountId: string | null = null;
// the dashboard's sync-status banner shows progress. const startAuthenticatedAccount = async (account: AuthenticatedAccount): Promise<void> => {
chatService if (
.runStartupSync() startupSyncAccountId === account.accountId ||
.then(async ({ kind, chats }) => { startupSyncInFlightAccountId === account.accountId
logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`); ) return;
// Resolve t2 IDs now that the full chat list is available. startupSyncInFlightAccountId = account.accountId;
try {
const { kind, chats } = await chatService.runStartupSync();
await chatService.enrichUsernames(); await chatService.enrichUsernames();
}) if (accountAuth.isFullyAuthenticated()) {
.catch((err) => logger.error('[chat] startup sync failed:', err)); startupSyncAccountId = account.accountId;
logger.info(`[chat] startup sync (${kind}) complete for ${account.redditUsername}: ${chats} chats`);
}
} finally {
startupSyncInFlightAccountId = null;
}
};
accountAuth.onAuthenticated((account) => startAuthenticatedAccount(account));
const authenticatedAccount = accountAuth.getSnapshot();
if (
authenticatedAccount.status === 'authenticated' &&
authenticatedAccount.accountId &&
authenticatedAccount.redditUsername &&
authenticatedAccount.matrixUserId
) {
void startAuthenticatedAccount({
accountId: authenticatedAccount.accountId,
redditUsername: authenticatedAccount.redditUsername,
matrixUserId: authenticatedAccount.matrixUserId,
}).catch((err) => logger.error('[chat] startup sync failed:', err));
}
let webServer: WebServer | null = null; let webServer: WebServer | null = null;
if (config.web.enabled) { if (config.web.enabled) {
@@ -327,6 +363,8 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setAiClient(aiClient); webServer.setAiClient(aiClient);
webServer.setStrategySelector(strategySelector); webServer.setStrategySelector(strategySelector);
webServer.setBrowserClient(browserClient); webServer.setBrowserClient(browserClient);
webServer.setAccountAuthContext(accountAuth);
webServer.setNativeLoginHandoff(new ProcessNativeLoginHandoff());
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard')); webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
} }
@@ -348,6 +386,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
db, db,
browserClient, browserClient,
messagePage, messagePage,
accountAuth,
postsRepo, postsRepo,
messagesRepo, messagesRepo,
successfulMessagesRepo, successfulMessagesRepo,
+20 -15
View File
@@ -112,6 +112,15 @@ export class BrowserClient {
return this.sessionState; return this.sessionState;
} }
async getAuthenticatedUsername(): Promise<string | null> {
try {
const page = await this.getPage();
return await this.readAuthenticatedUsername(page);
} catch {
return null;
}
}
async close(): Promise<void> { async close(): Promise<void> {
if (this.connectedBrowser) { if (this.connectedBrowser) {
this.logger.info('Closing Google Chrome connection'); this.logger.info('Closing Google Chrome connection');
@@ -185,34 +194,30 @@ export class BrowserClient {
} }
private async isLoggedInViaApi(page: Page): Promise<boolean> { private async isLoggedInViaApi(page: Page): Promise<boolean> {
return (await this.readAuthenticatedUsername(page)) !== null;
}
private async readAuthenticatedUsername(page: Page): Promise<string | null> {
try { try {
return await page.evaluate(async () => { return await page.evaluate(async () => {
try { try {
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' }); const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
if (!res.ok) return false; if (!res.ok) return null;
const data = await res.json(); const data = await res.json() as { name?: unknown };
return Boolean(data && data.name); return typeof data.name === 'string' && data.name ? data.name : null;
} catch { } catch {
return false; return null;
} }
}); });
} catch { } catch {
return false; return null;
} }
} }
async login(): Promise<boolean> { async login(): Promise<boolean> {
if (this.usingExternalChrome) { if (this.usingExternalChrome) {
try { this.sessionState = 'login-required';
const page = await this.getPage(); this.logger.warn('Native Reddit login required. Start it from the dashboard before signing in through VNC.');
await page.bringToFront();
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
this.sessionState = 'login-required';
this.logger.warn('Interactive Reddit login required. Open the VNC page and sign in using Google Chrome.');
} catch (error) {
this.sessionState = classifyRedditSessionError(error);
this.logger.error('Failed to open Google Chrome for interactive login:', error);
}
return false; return false;
} }
+16 -1
View File
@@ -20,6 +20,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
config, config,
logger, logger,
browserClient, browserClient,
accountAuth,
postsRepo, postsRepo,
messagesRepo, messagesRepo,
redditApi, redditApi,
@@ -66,7 +67,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000); const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000);
const checkResponses = async (): Promise<void> => { const checkResponses = async (): Promise<void> => {
if (isShuttingDown) return; if (isShuttingDown || !accountAuth.isFullyAuthenticated()) return;
try { try {
logger.debug('Checking for responses to sent messages (Background Thread)...'); logger.debug('Checking for responses to sent messages (Background Thread)...');
const responses = await responseChecker.checkForResponses(); const responses = await responseChecker.checkForResponses();
@@ -93,12 +94,26 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS); responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS);
let lastPausedLog = 0; let lastPausedLog = 0;
let lastAuthPausedLog = 0;
let lastCooldownLog = 0; let lastCooldownLog = 0;
let reportedCooldownUntil: number | null = null; let reportedCooldownUntil: number | null = null;
const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000; const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000;
while (!isShuttingDown) { while (!isShuttingDown) {
try { try {
if (!accountAuth.isFullyAuthenticated()) {
await accountAuth.ensureFullyAuthenticated();
}
if (!accountAuth.isFullyAuthenticated()) {
if (Date.now() - lastAuthPausedLog > PAUSED_LOG_INTERVAL_MS) {
logger.info('Bot logic is locked until full Reddit and Matrix authentication is verified.');
lastAuthPausedLog = Date.now();
}
await sleep(PAUSE_POLL_MS);
continue;
}
lastAuthPausedLog = 0;
if (webServer && !webServer.isRunning()) { if (webServer && !webServer.isRunning()) {
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) { if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
logger.info('Bot is paused. Use the web dashboard to start.'); logger.info('Bot is paused. Use the web dashboard to start.');
+102
View File
@@ -0,0 +1,102 @@
import http, { type Server } from 'node:http';
import { pathToFileURL } from 'node:url';
export interface NativeLoginGateOptions {
publicVncUrl: string;
onComplete: () => void;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character] ?? character);
}
function loginPage(publicVncUrl: string): string {
const safeVncUrl = escapeHtml(publicVncUrl);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reddit login required</title>
<style>
body { margin: 0; font: 16px system-ui, sans-serif; color: #f5f5f5; background: #111; }
main { max-width: 1100px; margin: 24px auto; padding: 0 20px; }
.notice { padding: 18px; border: 1px solid #3c765f; border-radius: 12px; background: #17251f; }
iframe { width: 100%; height: 70vh; margin-top: 18px; border: 1px solid #444; border-radius: 10px; background: #000; }
button, a { display: inline-block; margin: 14px 8px 0 0; padding: 11px 16px; border: 0; border-radius: 8px; font-weight: 700; }
button { color: white; background: #ff4500; cursor: pointer; }
a { color: white; background: #444; text-decoration: none; }
#status { margin-left: 8px; color: #b9dfcf; }
</style>
</head>
<body><main>
<section class="notice">
<strong>Chrome is running without automation.</strong>
<p>Log in to Reddit in the browser below. Only after Reddit shows you as signed in, click <em>I'm logged in - connect the bot</em>. Chrome will restart with the same profile and the bot will verify both Reddit and Matrix before any bot logic can run.</p>
<button id="complete" type="button">I'm logged in - connect the bot</button>
<a href="${safeVncUrl}" target="_blank" rel="noreferrer">Open VNC in a new tab</a>
<span id="status"></span>
</section>
<iframe src="${safeVncUrl}" title="Reddit login browser"></iframe>
<script>
document.getElementById('complete').addEventListener('click', async () => {
const button = document.getElementById('complete');
const status = document.getElementById('status');
button.disabled = true;
status.textContent = 'Restarting Chrome and verifying your account...';
const response = await fetch('/complete', { method: 'POST' });
if (!response.ok) {
button.disabled = false;
status.textContent = 'Could not start verification. Please try again.';
return;
}
setTimeout(() => window.location.reload(), 3000);
});
</script>
</main></body></html>`;
}
export function createNativeLoginGate(options: NativeLoginGateOptions): Server {
return http.createServer((request, response) => {
if (request.method === 'POST' && request.url === '/complete') {
response.writeHead(202, { 'content-type': 'application/json' });
response.end(JSON.stringify({ ok: true, restarting: true }));
options.onComplete();
return;
}
if (request.method !== 'GET') {
response.writeHead(405).end();
return;
}
response.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
response.end(loginPage(options.publicVncUrl));
});
}
function run(): void {
const port = Number.parseInt(process.env.WEB_PORT ?? '3000', 10);
const publicBase = (process.env.NOVNC_PUBLIC_URL ?? `http://localhost:${process.env.NOVNC_PORT ?? '6080'}`).replace(/\/$/, '');
const publicVncUrl = `${publicBase}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
let server: Server;
server = createNativeLoginGate({
publicVncUrl,
onComplete: () => setTimeout(() => server.close(() => process.exit(0)), 250).unref(),
});
server.listen(port, () => {
process.stdout.write(`[native-login] waiting for human login on port ${port}\n`);
});
}
const entryUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
if (import.meta.url === entryUrl) run();
+177
View File
@@ -0,0 +1,177 @@
import type { Logger } from '../utils/logger.js';
export type AccountAuthStatus =
| 'login-required'
| 'checking'
| 'authenticated';
export interface AuthenticatedAccount {
accountId: string;
redditUsername: string;
matrixUserId: string;
}
export interface AccountAuthSnapshot {
status: AccountAuthStatus;
accountId: string | null;
redditUsername: string | null;
matrixUserId: string | null;
reason: string | null;
}
export interface AccountAuthGate {
isFullyAuthenticated(): boolean;
invalidate(reason: string): void;
}
export type AccountAuthenticator = () => Promise<AuthenticatedAccount | null>;
export type AccountAuthenticatedListener = (account: AuthenticatedAccount) => void | Promise<void>;
export interface AccountAuthContextOptions {
leaseMs?: number;
retryDelayMs?: number;
now?: () => number;
}
export interface CurrentAccountAuthentication {
getRedditUsername(): Promise<string | null>;
refreshMatrixCredentials(): Promise<{ userId: string } | null>;
}
export async function authenticateCurrentAccount(
authentication: CurrentAccountAuthentication,
): Promise<AuthenticatedAccount | null> {
const redditUsername = await authentication.getRedditUsername();
if (!redditUsername) return null;
const credentials = await authentication.refreshMatrixCredentials();
if (!credentials?.userId) return null;
return {
accountId: credentials.userId,
redditUsername,
matrixUserId: credentials.userId,
};
}
export const denyAccountAuth: AccountAuthGate = {
isFullyAuthenticated: () => false,
invalidate: () => undefined,
};
export class AccountAuthContext implements AccountAuthGate {
private snapshot: AccountAuthSnapshot = {
status: 'login-required',
accountId: null,
redditUsername: null,
matrixUserId: null,
reason: 'Full Reddit and Matrix authentication has not been verified',
};
private authenticator: AccountAuthenticator | null = null;
private authenticationInFlight: Promise<boolean> | null = null;
private readonly listeners = new Set<AccountAuthenticatedListener>();
private authenticatedUntil = 0;
private nextAuthenticationAttemptAt = 0;
private readonly leaseMs: number;
private readonly retryDelayMs: number;
private readonly now: () => number;
constructor(
private readonly logger: Logger,
options: AccountAuthContextOptions = {},
) {
this.leaseMs = options.leaseMs ?? 30 * 60 * 1000;
this.retryDelayMs = options.retryDelayMs ?? 60 * 1000;
this.now = options.now ?? Date.now;
}
setAuthenticator(authenticator: AccountAuthenticator): void {
this.authenticator = authenticator;
}
isFullyAuthenticated(): boolean {
if (this.snapshot.status !== 'authenticated') return false;
if (this.now() < this.authenticatedUntil) return true;
this.invalidate('Full account authentication lease expired');
return false;
}
getSnapshot(): AccountAuthSnapshot {
this.isFullyAuthenticated();
return { ...this.snapshot };
}
markAuthenticated(account: AuthenticatedAccount): void {
if (!account.accountId || !account.redditUsername || !account.matrixUserId) {
throw new Error('A fully authenticated account requires Reddit and Matrix identities');
}
if (account.accountId !== account.matrixUserId) {
throw new Error('Authenticated account ID must match the Matrix user ID');
}
const changedAccount = this.snapshot.accountId !== account.accountId;
const wasAuthenticated = this.isFullyAuthenticated();
this.snapshot = {
status: 'authenticated',
accountId: account.accountId,
redditUsername: account.redditUsername,
matrixUserId: account.matrixUserId,
reason: null,
};
this.authenticatedUntil = this.now() + this.leaseMs;
this.nextAuthenticationAttemptAt = 0;
if (!wasAuthenticated || changedAccount) {
for (const listener of this.listeners) {
void Promise.resolve(listener(account)).catch((error) => {
this.logger.error('Authenticated account listener failed:', error);
});
}
}
}
invalidate(reason: string): void {
this.authenticatedUntil = 0;
this.snapshot = {
status: 'login-required',
accountId: null,
redditUsername: null,
matrixUserId: null,
reason,
};
}
onAuthenticated(listener: AccountAuthenticatedListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
ensureFullyAuthenticated(): Promise<boolean> {
if (this.isFullyAuthenticated()) return Promise.resolve(true);
if (this.authenticationInFlight) return this.authenticationInFlight;
if (!this.authenticator) return Promise.resolve(false);
if (this.now() < this.nextAuthenticationAttemptAt) return Promise.resolve(false);
this.snapshot = { ...this.snapshot, status: 'checking', reason: null };
this.authenticationInFlight = this.authenticate().finally(() => {
this.authenticationInFlight = null;
});
return this.authenticationInFlight;
}
private async authenticate(): Promise<boolean> {
try {
const account = await this.authenticator!();
if (!account) {
this.invalidate('Full Reddit and Matrix authentication is required');
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
return false;
}
this.markAuthenticated(account);
return true;
} catch (error) {
this.invalidate(error instanceof Error ? error.message : String(error));
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
this.logger.warn('Full account authentication failed:', error);
return false;
}
}
}
+22 -2
View File
@@ -12,6 +12,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown }
export type { InvitationCooldown } from './types.js'; export type { InvitationCooldown } from './types.js';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js'; import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
import type { AccountAuthGate } from '../account-auth-context.js';
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000; const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/'; const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
@@ -131,6 +132,7 @@ export class ChatService {
cache?: ChatCache, cache?: ChatCache,
private messagesRepo?: MessagesRepository, private messagesRepo?: MessagesRepository,
private redditUsersRepo?: RedditUsersRepository, private redditUsersRepo?: RedditUsersRepository,
private accountAuth?: AccountAuthGate,
) { ) {
this.cache = cache ?? new ChatCache(logger); this.cache = cache ?? new ChatCache(logger);
if (this.redditUsersRepo) { if (this.redditUsersRepo) {
@@ -178,6 +180,11 @@ export class ChatService {
* refresh itself fails, we propagate. * refresh itself fails, we propagate.
*/ */
private async withAuth<T>(fn: () => Promise<T>): Promise<T> { private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
if (!this.accountAuth?.isFullyAuthenticated()) {
throw new MatrixAuthenticationUnavailableError(
new Error('Full account authentication is required'),
);
}
try { try {
return await fn(); return await fn();
} catch (err) { } catch (err) {
@@ -186,9 +193,15 @@ export class ChatService {
try { try {
await this.tokenRefresher.refresh(); await this.tokenRefresher.refresh();
} catch (refreshError) { } catch (refreshError) {
this.accountAuth?.invalidate('Matrix authentication could not be restored');
throw new MatrixAuthenticationUnavailableError(refreshError); throw new MatrixAuthenticationUnavailableError(refreshError);
} }
return await fn(); try {
return await fn();
} catch (retryError) {
this.accountAuth?.invalidate('Matrix authentication was rejected after refresh');
throw retryError;
}
} }
throw err; throw err;
} }
@@ -461,6 +474,7 @@ export class ChatService {
* `chat.messages`. * `chat.messages`.
*/ */
async loadConversationDetails(chat: ActiveChat): Promise<void> { async loadConversationDetails(chat: ActiveChat): Promise<void> {
if (!this.accountAuth?.isFullyAuthenticated()) return;
return this.loadDetails(chat); return this.loadDetails(chat);
} }
@@ -470,6 +484,7 @@ export class ChatService {
* a raw Matrix room ID like `!abc:reddit.com`. * a raw Matrix room ID like `!abc:reddit.com`.
*/ */
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> { async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
if (!this.accountAuth?.isFullyAuthenticated()) return false;
const active = this.getInvitationCooldown(); const active = this.getInvitationCooldown();
if (active) { if (active) {
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000); const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
@@ -510,6 +525,7 @@ export class ChatService {
* line. Returns the new chat (or null on failure). * line. Returns the new chat (or null on failure).
*/ */
async startConversation(username: string, message: string): Promise<ActiveChat | null> { async startConversation(username: string, message: string): Promise<ActiveChat | null> {
if (!this.accountAuth?.isFullyAuthenticated()) return null;
const active = this.getInvitationCooldown(); const active = this.getInvitationCooldown();
if (active) { if (active) {
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000); const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
@@ -573,7 +589,7 @@ export class ChatService {
* old Playwright sender; always returns true. * old Playwright sender; always returns true.
*/ */
async verifyMessageSent(_recipient: string, _messageText: string): Promise<boolean> { async verifyMessageSent(_recipient: string, _messageText: string): Promise<boolean> {
return true; return this.accountAuth?.isFullyAuthenticated() ?? false;
} }
/** /**
@@ -1030,6 +1046,10 @@ export class ChatService {
* cache. Best-effort; safe to await or fire-and-forget. * cache. Best-effort; safe to await or fire-and-forget.
*/ */
async enrichUsernames(): Promise<void> { async enrichUsernames(): Promise<void> {
if (!this.accountAuth?.isFullyAuthenticated()) {
this.logger.debug('[chat] username enrichment skipped until full account authentication');
return;
}
if (!this.usernameResolver) return; if (!this.usernameResolver) return;
let dirty = false; let dirty = false;
+11 -1
View File
@@ -1,5 +1,6 @@
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
import type { ChatService } from './chat.js'; import type { ChatService } from './chat.js';
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
export interface MediaSyncStatus { export interface MediaSyncStatus {
isSyncing: boolean; isSyncing: boolean;
@@ -22,7 +23,8 @@ export class MediaSyncService {
constructor( constructor(
private chatService: ChatService, private chatService: ChatService,
private logger: Logger, private logger: Logger,
intervalMs: number = 30 * 60 * 1000 // default 30 minutes intervalMs: number = 30 * 60 * 1000, // default 30 minutes
private accountAuth: AccountAuthGate = denyAccountAuth,
) { ) {
this.intervalMs = intervalMs; this.intervalMs = intervalMs;
} }
@@ -64,6 +66,10 @@ export class MediaSyncService {
* Returns immediately if a sync is already in progress. * Returns immediately if a sync is already in progress.
*/ */
async triggerSync(full = false): Promise<boolean> { async triggerSync(full = false): Promise<boolean> {
if (!this.accountAuth.isFullyAuthenticated()) {
this.logger.warn('Media sync: blocked until the account is fully authenticated');
return false;
}
if (this.syncing) { if (this.syncing) {
this.logger.info('Media sync: sync already in progress, skipping trigger'); this.logger.info('Media sync: sync already in progress, skipping trigger');
return false; return false;
@@ -86,6 +92,10 @@ export class MediaSyncService {
} }
private async runSync(full = false): Promise<void> { private async runSync(full = false): Promise<void> {
if (!this.accountAuth.isFullyAuthenticated()) {
this.logger.debug('Media sync: skipped because the account is not fully authenticated');
return;
}
if (this.syncing) return; if (this.syncing) return;
this.syncing = true; this.syncing = true;
this.lastError = null; this.lastError = null;
+28
View File
@@ -0,0 +1,28 @@
export interface NativeLoginHandoff {
request(): void;
}
export interface ProcessNativeLoginHandoffOptions {
exitCode?: number;
delayMs?: number;
exit?: (code: number) => void;
}
export class ProcessNativeLoginHandoff implements NativeLoginHandoff {
private requested = false;
private readonly exitCode: number;
private readonly delayMs: number;
private readonly exit: (code: number) => void;
constructor(options: ProcessNativeLoginHandoffOptions = {}) {
this.exitCode = options.exitCode ?? 75;
this.delayMs = options.delayMs ?? 250;
this.exit = options.exit ?? ((code) => process.exit(code));
}
request(): void {
if (this.requested) return;
this.requested = true;
setTimeout(() => this.exit(this.exitCode), this.delayMs).unref();
}
}
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js'; import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js';
import type { PostsRepository } from '../database/repositories.js'; import type { PostsRepository } from '../database/repositories.js';
import type { AccountAuthGate } from './account-auth-context.js';
export interface ResponseCheckResult { export interface ResponseCheckResult {
messageId: number; messageId: number;
@@ -16,11 +17,16 @@ export class ResponseCheckerService {
private messagesRepo: MessagesRepository, private messagesRepo: MessagesRepository,
private successfulMessagesRepo: SuccessfulMessagesRepository, private successfulMessagesRepo: SuccessfulMessagesRepository,
private postsRepo: PostsRepository, private postsRepo: PostsRepository,
private logger: Logger private logger: Logger,
private accountAuth?: AccountAuthGate,
) { } ) { }
async checkForResponses(): Promise<ResponseCheckResult[]> { async checkForResponses(): Promise<ResponseCheckResult[]> {
const results: ResponseCheckResult[] = []; const results: ResponseCheckResult[] = [];
if (!this.accountAuth?.isFullyAuthenticated()) {
this.logger.debug('Response check skipped until full account authentication');
return results;
}
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours) // Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
const minAgeMs = 60 * 60 * 1000; // 1 hour const minAgeMs = 60 * 60 * 1000; // 1 hour
+4
View File
@@ -13,6 +13,8 @@ import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js'; import type { OpenRouterClient } from '../ai/openrouter.js';
import type { StrategySelector } from '../services/strategy-selector.js'; import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js'; import type { BrowserClient } from '../browser/client.js';
import type { AccountAuthContext } from '../services/account-auth-context.js';
import type { NativeLoginHandoff } from '../services/native-login-handoff.js';
import type { ActivityLog } from './activity-log.js'; import type { ActivityLog } from './activity-log.js';
import type { LogBroadcaster } from './log-broadcaster.js'; import type { LogBroadcaster } from './log-broadcaster.js';
import type { RuntimeConfig } from './types.js'; import type { RuntimeConfig } from './types.js';
@@ -40,6 +42,8 @@ export interface WebContext {
ai?: OpenRouterClient; ai?: OpenRouterClient;
strategySelector?: StrategySelector; strategySelector?: StrategySelector;
browser?: BrowserClient; browser?: BrowserClient;
accountAuth?: AccountAuthContext;
nativeLogin?: NativeLoginHandoff;
}; };
/** Bot pause/resume toggle read by the main loop. */ /** Bot pause/resume toggle read by the main loop. */
+5
View File
@@ -5,6 +5,11 @@ export function createControlRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
router.post('/start', (_req, res) => { router.post('/start', (_req, res) => {
if (!ctx.services.accountAuth?.isFullyAuthenticated()) {
ctx.logger.warn('Bot start blocked until full account authentication');
res.status(409).json({ success: false, running: false, authenticated: false });
return;
}
ctx.setBotRunning(true); ctx.setBotRunning(true);
ctx.activity.push('sent', 'Bot started by user'); ctx.activity.push('sent', 'Bot started by user');
ctx.logger.info('Bot started via web UI'); ctx.logger.info('Bot started via web UI');
+24 -15
View File
@@ -18,36 +18,45 @@ export function createLoginRouter(ctx: WebContext): Router {
router.get('/login/status', async (_req, res) => { router.get('/login/status', async (_req, res) => {
const browser = ctx.services.browser; const browser = ctx.services.browser;
if (!browser) { const accountAuth = ctx.services.accountAuth;
if (!browser || !accountAuth) {
res.status(503).json({ ready: false, error: 'browser not initialised' }); res.status(503).json({ ready: false, error: 'browser not initialised' });
return; return;
} }
try { try {
const loggedIn = await browser.isSessionValid(); const loggedIn = await accountAuth.ensureFullyAuthenticated();
res.json({ ready: true, loggedIn, status: browser.getSessionState() }); const browserState = browser.getSessionState();
res.json({
ready: true,
loggedIn,
status: loggedIn
? 'authenticated'
: browserState === 'unreachable'
? 'unreachable'
: 'login-required',
});
} catch (error) { } catch (error) {
ctx.logger.error('Failed to check login status:', error); ctx.logger.error('Failed to check login status:', error);
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' }); res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
} }
}); });
// Navigate the headed Chromium running under Xvfb to reddit.com/login so // Restart into a completely native Chrome process. The container supervisor
// the human watching the noVNC stream can complete the flow. // does not enable DevTools until the human explicitly completes the handoff.
router.post('/login/start', async (_req, res) => { router.post('/login/start', async (_req, res) => {
const browser = ctx.services.browser; const nativeLogin = ctx.services.nativeLogin;
if (!browser) { if (!nativeLogin) {
res.status(503).json({ ok: false, error: 'browser not initialised' }); res.status(503).json({ ok: false, error: 'native login unavailable' });
return; return;
} }
try { try {
const page = await browser.getPage(); ctx.services.accountAuth?.invalidate('Native Reddit login requested');
await page.bringToFront().catch(() => undefined); nativeLogin.request();
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); ctx.logger.info('Restarting into native Chrome login mode; bot logic remains locked');
ctx.logger.info('Opened Reddit login page for human completion via VNC'); res.status(202).json({ ok: true, restarting: true });
res.json({ ok: true });
} catch (error) { } catch (error) {
ctx.logger.error('Failed to open login page:', error); ctx.logger.error('Failed to start native login mode:', error);
res.status(500).json({ ok: false, error: 'navigation failed' }); res.status(500).json({ ok: false, error: 'handoff failed' });
} }
}); });
+10
View File
@@ -18,6 +18,8 @@ import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js'; import type { OpenRouterClient } from '../ai/openrouter.js';
import type { StrategySelector } from '../services/strategy-selector.js'; import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js'; import type { BrowserClient } from '../browser/client.js';
import type { AccountAuthContext } from '../services/account-auth-context.js';
import type { NativeLoginHandoff } from '../services/native-login-handoff.js';
import { ActivityLog } from './activity-log.js'; import { ActivityLog } from './activity-log.js';
import { LogBroadcaster } from './log-broadcaster.js'; import { LogBroadcaster } from './log-broadcaster.js';
import type { WebContext } from './context.js'; import type { WebContext } from './context.js';
@@ -199,6 +201,14 @@ export class WebServer {
this.services.browser = browserClient; this.services.browser = browserClient;
} }
setAccountAuthContext(accountAuth: AccountAuthContext): void {
this.services.accountAuth = accountAuth;
}
setNativeLoginHandoff(nativeLogin: NativeLoginHandoff): void {
this.services.nativeLogin = nativeLogin;
}
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
this.activity.push(type, message, recipient, imageUrl); this.activity.push(type, message, recipient, imageUrl);
} }
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest';
import {
AccountAuthContext,
authenticateCurrentAccount,
} from '../src/services/account-auth-context.js';
import { createMockLogger } from './mockLogger.js';
describe('AccountAuthContext', () => {
it('does not attempt Matrix capture until Reddit identity is authoritative', async () => {
const refreshMatrixCredentials = vi.fn();
await expect(authenticateCurrentAccount({
getRedditUsername: vi.fn().mockResolvedValue(null),
refreshMatrixCredentials,
})).resolves.toBeNull();
expect(refreshMatrixCredentials).not.toHaveBeenCalled();
});
it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => {
const auth = new AccountAuthContext(createMockLogger());
const authenticate = vi.fn().mockResolvedValue({
accountId: '@t2_account:reddit.com',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
auth.setAuthenticator(authenticate);
expect(auth.isFullyAuthenticated()).toBe(false);
expect(auth.getSnapshot().status).toBe('login-required');
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(true);
expect(auth.isFullyAuthenticated()).toBe(true);
expect(auth.getSnapshot()).toMatchObject({
status: 'authenticated',
accountId: '@t2_account:reddit.com',
});
});
it('coalesces concurrent authentication checks globally', async () => {
const auth = new AccountAuthContext(createMockLogger());
let resolveAuth: ((account: {
accountId: string;
redditUsername: string;
matrixUserId: string;
}) => void) | undefined;
const authenticate = vi.fn(() => new Promise((resolve) => { resolveAuth = resolve; }));
auth.setAuthenticator(authenticate);
const first = auth.ensureFullyAuthenticated();
const second = auth.ensureFullyAuthenticated();
await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce());
resolveAuth?.({
accountId: '@t2_account:reddit.com',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
});
it('invalidates the whole account context when either login layer expires', async () => {
const auth = new AccountAuthContext(createMockLogger());
auth.markAuthenticated({
accountId: '@t2_account:reddit.com',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
auth.invalidate('matrix token rejected');
expect(auth.isFullyAuthenticated()).toBe(false);
expect(auth.getSnapshot()).toMatchObject({
status: 'login-required',
accountId: null,
reason: 'matrix token rejected',
});
});
it('fails closed when the authentication lease expires', () => {
let now = 1_000;
const auth = new AccountAuthContext(createMockLogger(), {
leaseMs: 100,
retryDelayMs: 1_000,
now: () => now,
});
auth.markAuthenticated({
accountId: '@t2_account:reddit.com',
redditUsername: 'account',
matrixUserId: '@t2_account:reddit.com',
});
now = 1_101;
expect(auth.isFullyAuthenticated()).toBe(false);
expect(auth.getSnapshot().status).toBe('login-required');
});
it('throttles failed global authentication checks', async () => {
let now = 1_000;
const auth = new AccountAuthContext(createMockLogger(), {
leaseMs: 100,
retryDelayMs: 1_000,
now: () => now,
});
const authenticate = vi.fn().mockResolvedValue(null);
auth.setAuthenticator(authenticate);
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
expect(authenticate).toHaveBeenCalledOnce();
now = 2_001;
await auth.ensureFullyAuthenticated();
expect(authenticate).toHaveBeenCalledTimes(2);
});
});
+3 -6
View File
@@ -57,7 +57,7 @@ describe('BrowserClient external Chrome', () => {
expect(runtime.launchPersistentContext).toHaveBeenCalledOnce(); expect(runtime.launchPersistentContext).toHaveBeenCalledOnce();
}); });
it('opens the login page for a human without submitting credentials', async () => { it('does not control external Chrome during native human login', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222'; process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const page = { const page = {
goto: vi.fn().mockResolvedValue(undefined), goto: vi.fn().mockResolvedValue(undefined),
@@ -79,11 +79,8 @@ describe('BrowserClient external Chrome', () => {
await client.initialize(); await client.initialize();
await expect(client.login()).resolves.toBe(false); await expect(client.login()).resolves.toBe(false);
expect(page.bringToFront).toHaveBeenCalledOnce(); expect(page.bringToFront).not.toHaveBeenCalled();
expect(page.goto).toHaveBeenCalledWith( expect(page.goto).not.toHaveBeenCalled();
'https://www.reddit.com/login',
{ waitUntil: 'domcontentloaded' },
);
expect(page.fill).not.toHaveBeenCalled(); expect(page.fill).not.toHaveBeenCalled();
expect(client.getSessionState()).toBe('login-required'); expect(client.getSessionState()).toBe('login-required');
}); });
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import os from 'node:os';
import path from 'node:path';
import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
describe('ChatService account authentication gate', () => {
it('does not make Matrix requests before full account authentication', async () => {
const logger = createMockLogger();
const matrix = new MatrixClient({
homeserver: 'https://matrix.example.test',
accessToken: 'cached-but-unverified',
userId: '@t2_account:reddit.com',
capturedAt: 1,
}, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn();
const cache = new ChatCache(
logger,
path.join(os.tmpdir(), `chat-auth-${Date.now()}-${Math.random()}.json`),
);
const auth = { isFullyAuthenticated: vi.fn(() => false) };
const service = new ChatService(
matrix,
logger,
undefined,
undefined,
undefined,
undefined,
cache,
undefined,
undefined,
auth,
);
await service.refreshTopChats();
expect(matrix.sync).not.toHaveBeenCalled();
});
});
+6 -5
View File
@@ -6,6 +6,7 @@ import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js'; import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js'; import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js'; import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
const creds = { const creds = {
homeserver: 'https://matrix.example.test', homeserver: 'https://matrix.example.test',
@@ -29,7 +30,7 @@ function buildService() {
logger, logger,
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
); );
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
return { svc, logger }; return { svc, logger };
} }
@@ -97,7 +98,7 @@ describe('ChatService invitation cooldown', () => {
try { try {
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 }); const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
const cache1 = new ChatCache(logger, cacheFile); const cache1 = new ChatCache(logger, cacheFile);
const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1); const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1, undefined, undefined, authenticatedAccountAuth);
svc1.setInvitationCooldown( svc1.setInvitationCooldown(
new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null), new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null),
); );
@@ -105,7 +106,7 @@ describe('ChatService invitation cooldown', () => {
await cache1.save(); await cache1.save();
const cache2 = new ChatCache(logger, cacheFile); const cache2 = new ChatCache(logger, cacheFile);
const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2); const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2, undefined, undefined, authenticatedAccountAuth);
await svc2.loadCache(); await svc2.loadCache();
const restored = svc2.getInvitationCooldown(); const restored = svc2.getInvitationCooldown();
expect(restored).not.toBeNull(); expect(restored).not.toBeNull();
@@ -144,7 +145,7 @@ describe('ChatService invitation cooldown', () => {
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 }); const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
const cache = new ChatCache(logger, cacheFile); const cache = new ChatCache(logger, cacheFile);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
// Cooldown fires before loadCache() — the in-memory chats are still []. // Cooldown fires before loadCache() — the in-memory chats are still [].
svc.setInvitationCooldown( svc.setInvitationCooldown(
@@ -178,7 +179,7 @@ describe('ChatService invitation cooldown', () => {
logger, logger,
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
); );
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
const result = await svc.sendChatMessage('!room:example.test', 'hi'); const result = await svc.sendChatMessage('!room:example.test', 'hi');
expect(result).toBe(false); expect(result).toBe(false);
+4 -3
View File
@@ -8,6 +8,7 @@ import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { MediaService } from '../src/services/media.js'; import { MediaService } from '../src/services/media.js';
import type { MediaDownloader } from '../src/services/media.js'; import type { MediaDownloader } from '../src/services/media.js';
import { createMockLogger } from './mockLogger.js'; import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
const creds = { const creds = {
homeserver: 'https://matrix.example.test', homeserver: 'https://matrix.example.test',
@@ -60,7 +61,7 @@ describe('ChatService delta sync — media attachment', () => {
const downloader = buildDownloader(); const downloader = buildDownloader();
const media = new MediaService(downloader, logger, tmpDir); const media = new MediaService(downloader, logger, tmpDir);
const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')); const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json'));
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache); const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
await svc.getActiveChats(); await svc.getActiveChats();
@@ -128,7 +129,7 @@ describe('ChatService delta sync — media attachment', () => {
const media = new MediaService(buildDownloader(), logger, tmpDir); const media = new MediaService(buildDownloader(), logger, tmpDir);
const cache = new ChatCache(logger, cacheFile); const cache = new ChatCache(logger, cacheFile);
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache); const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
await svc.loadCache(); await svc.loadCache();
// refreshTopChats always runs a /sync (unlike getActiveChats, which can // refreshTopChats always runs a /sync (unlike getActiveChats, which can
@@ -176,7 +177,7 @@ describe('ChatService delta sync — media attachment', () => {
logger, logger,
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
); );
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
await expect(svc.getActiveChats()).resolves.toBeDefined(); await expect(svc.getActiveChats()).resolves.toBeDefined();
}); });
}); });
@@ -8,6 +8,7 @@ import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js'; import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixAuthError, MatrixClient } from '../src/services/chat/matrix/client.js'; import { MatrixAuthError, MatrixClient } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js'; import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
const creds = { const creds = {
homeserver: 'https://matrix.example.test', homeserver: 'https://matrix.example.test',
@@ -47,6 +48,7 @@ describe('ChatService username persistence', () => {
tmpCache(logger), tmpCache(logger),
undefined, undefined,
repo, repo,
authenticatedAccountAuth,
); );
expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice'); expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice');
@@ -61,6 +63,9 @@ describe('ChatService username persistence', () => {
logger, logger,
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
tmpCache(logger), tmpCache(logger),
undefined,
undefined,
authenticatedAccountAuth,
); );
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown'); expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
}); });
@@ -95,6 +100,7 @@ describe('ChatService username persistence', () => {
cache, cache,
undefined, undefined,
repo, repo,
authenticatedAccountAuth,
); );
await svc.loadCache(); await svc.loadCache();
@@ -145,6 +151,9 @@ describe('ChatService username persistence', () => {
tokenRefresher, tokenRefresher,
usernameResolver, usernameResolver,
cache, cache,
undefined,
undefined,
authenticatedAccountAuth,
); );
await svc.enrichUsernames(); await svc.enrichUsernames();
+35
View File
@@ -0,0 +1,35 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createControlRouter } from '../src/web/routes/control.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map(
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
));
});
describe('bot control account authentication gate', () => {
it('refuses to start the bot until full account authentication', async () => {
const setBotRunning = vi.fn();
const app = express();
app.use(createControlRouter({
services: { accountAuth: { isFullyAuthenticated: () => false } },
setBotRunning,
activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: vi.fn() },
} as never));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' });
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ success: false, authenticated: false });
expect(setBotRunning).not.toHaveBeenCalled();
});
});
+59 -1
View File
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { classifyRedditSessionError } from '../src/browser/client.js'; import { classifyRedditSessionError } from '../src/browser/client.js';
import { createLoginRouter } from '../src/web/routes/login.js'; import { createLoginRouter } from '../src/web/routes/login.js';
@@ -14,6 +14,14 @@ async function request(app: express.Express, path: string): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${path}`); return fetch(`http://127.0.0.1:${port}${path}`);
} }
async function post(app: express.Express, path: string): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST' });
}
afterEach(async () => { afterEach(async () => {
await Promise.all( await Promise.all(
servers.splice(0).map( servers.splice(0).map(
@@ -28,6 +36,9 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
app.use( app.use(
createLoginRouter({ createLoginRouter({
services: { services: {
accountAuth: {
ensureFullyAuthenticated: async () => state === 'authenticated',
},
browser: { browser: {
isSessionValid: async () => state === 'authenticated', isSessionValid: async () => state === 'authenticated',
getSessionState: () => state, getSessionState: () => state,
@@ -39,6 +50,20 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
return app; return app;
} }
function appWithPartialAuthentication() {
const app = express();
app.use(createLoginRouter({
services: {
accountAuth: { ensureFullyAuthenticated: async () => false },
browser: {
getSessionState: () => 'authenticated',
},
},
logger: { error: () => undefined },
} as never));
return app;
}
describe('Reddit session status', () => { describe('Reddit session status', () => {
it('reports when Reddit is unreachable instead of presenting it as logged out', async () => { it('reports when Reddit is unreachable instead of presenting it as logged out', async () => {
const response = await request(appWithBrowser('unreachable'), '/login/status'); const response = await request(appWithBrowser('unreachable'), '/login/status');
@@ -60,6 +85,16 @@ describe('Reddit session status', () => {
}); });
}); });
it('does not report authenticated when only the Reddit browser session is valid', async () => {
const response = await request(appWithPartialAuthentication(), '/login/status');
expect(await response.json()).toEqual({
ready: true,
loggedIn: false,
status: 'login-required',
});
});
it('returns the configured public noVNC URL', async () => { it('returns the configured public noVNC URL', async () => {
process.env.NOVNC_PUBLIC_URL = 'https://red-vnc.apps.jbrunton.co.uk'; process.env.NOVNC_PUBLIC_URL = 'https://red-vnc.apps.jbrunton.co.uk';
@@ -78,4 +113,27 @@ describe('Reddit session status', () => {
).toBe('unreachable'); ).toBe('unreachable');
expect(classifyRedditSessionError(new Error('login form was not found'))).toBe('login-required'); expect(classifyRedditSessionError(new Error('login form was not found'))).toBe('login-required');
}); });
it('starts a native Chrome login handoff without controlling the attached page', async () => {
const requestNativeLogin = vi.fn();
const getPage = vi.fn();
const invalidate = vi.fn();
const app = express();
app.use(createLoginRouter({
services: {
accountAuth: { invalidate },
browser: { getPage },
nativeLogin: { request: requestNativeLogin },
},
logger: { info: vi.fn(), error: vi.fn() },
} as never));
const response = await post(app, '/login/start');
expect(response.status).toBe(202);
expect(await response.json()).toEqual({ ok: true, restarting: true });
expect(invalidate).toHaveBeenCalledWith('Native Reddit login requested');
expect(requestNativeLogin).toHaveBeenCalledOnce();
expect(getPage).not.toHaveBeenCalled();
});
}); });
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { MediaSyncService } from '../src/services/media-sync.js';
import { createMockLogger } from './mockLogger.js';
describe('MediaSyncService account authentication gate', () => {
it('does not touch chat while the global account context is unauthenticated', async () => {
const chat = {
refreshTopChats: vi.fn(),
fetchAllMessages: vi.fn(),
};
const auth = { isFullyAuthenticated: vi.fn(() => false) };
const service = new MediaSyncService(
chat as never,
createMockLogger(),
30 * 60 * 1000,
auth,
);
await expect(service.triggerSync()).resolves.toBe(false);
expect(chat.refreshTopChats).not.toHaveBeenCalled();
expect(chat.fetchAllMessages).not.toHaveBeenCalled();
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createNativeLoginGate } from '../src/native-login-gate.js';
const servers: Array<ReturnType<typeof createNativeLoginGate>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => {
server.close(() => resolve());
})));
});
describe('native Reddit login gate', () => {
it('serves VNC guidance and completes only after the explicit handoff', async () => {
const onComplete = vi.fn();
const server = createNativeLoginGate({
publicVncUrl: 'https://red-vnc.example/vnc.html',
onComplete,
}).listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const page = await fetch(`http://127.0.0.1:${port}/`);
const html = await page.text();
expect(html).toContain('Chrome is running without automation');
expect(html).toContain('https://red-vnc.example/vnc.html');
expect(html).toContain("fetch('/complete', { method: 'POST' })");
expect(onComplete).not.toHaveBeenCalled();
const completed = await fetch(`http://127.0.0.1:${port}/complete`, { method: 'POST' });
expect(completed.status).toBe(202);
expect(await completed.json()).toEqual({ ok: true, restarting: true });
expect(onComplete).toHaveBeenCalledOnce();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { ResponseCheckerService } from '../src/services/response-checker.js';
import { createMockLogger } from './mockLogger.js';
describe('ResponseCheckerService account authentication gate', () => {
it('does not query repositories or navigate while unauthenticated', async () => {
const page = { goto: vi.fn() };
const messages = { getUncheckedMessages: vi.fn() };
const auth = { isFullyAuthenticated: vi.fn(() => false) };
const checker = new ResponseCheckerService(
page as never,
messages as never,
{} as never,
{} as never,
createMockLogger(),
auth,
);
await expect(checker.checkForResponses()).resolves.toEqual([]);
expect(messages.getUncheckedMessages).not.toHaveBeenCalled();
expect(page.goto).not.toHaveBeenCalled();
});
});
+21 -1
View File
@@ -1,9 +1,29 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Page } from 'playwright'; import type { Page } from 'playwright';
import { ResponseCheckerService } from '../src/services/response-checker.js'; import { ResponseCheckerService as BaseResponseCheckerService } from '../src/services/response-checker.js';
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ProcessedPostRecord } from '../src/database/repositories.js'; import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ProcessedPostRecord } from '../src/database/repositories.js';
import type { PostsRepository } from '../src/database/repositories.js'; import type { PostsRepository } from '../src/database/repositories.js';
import { createMockLogger } from '../tests/mockLogger.js'; import { createMockLogger } from '../tests/mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js';
class ResponseCheckerService extends BaseResponseCheckerService {
constructor(
page: Page,
messagesRepo: MessagesRepository,
successfulMessagesRepo: SuccessfulMessagesRepository,
postsRepo: PostsRepository,
logger: ReturnType<typeof createMockLogger>,
) {
super(
page,
messagesRepo,
successfulMessagesRepo,
postsRepo,
logger,
authenticatedAccountAuth,
);
}
}
function createMockPage(): Page { function createMockPage(): Page {
return { return {
+5
View File
@@ -3,6 +3,11 @@ import type { Config } from '../src/config/index.js';
import { runMigrations } from '../src/database/client.js'; import { runMigrations } from '../src/database/client.js';
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../src/database/repositories.js'; import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../src/database/repositories.js';
export const authenticatedAccountAuth = {
isFullyAuthenticated: () => true,
invalidate: () => undefined,
};
export function createTestDb(): Database.Database { export function createTestDb(): Database.Database {
const db = new Database(':memory:'); const db = new Database(':memory:');
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
+9 -3
View File
@@ -23,17 +23,23 @@ describe('VNC container entrypoint', () => {
expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot')); expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot'));
}); });
it('launches regular Google Chrome before the bot and exposes its local CDP endpoint', () => { it('supports a native login phase with no DevTools before the controlled browser starts', () => {
const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8'); const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8');
expect(script).toContain('google-chrome'); expect(script).toContain('google-chrome');
expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"'); expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"');
expect(script).toContain('start_native_login_chrome');
expect(script).toContain('https://www.reddit.com/login/');
expect(script).toContain('node dist/native-login-gate.js');
expect(script).toContain('NATIVE_LOGIN_EXIT_CODE');
expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"'); expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"');
expect(script).toContain('export CHROME_CDP_URL='); expect(script).toContain('export CHROME_CDP_URL=');
expect(script).not.toContain('--enable-automation'); expect(script).not.toContain('--enable-automation');
expect(script.indexOf('starting Google Chrome')).toBeLessThan( const nativeFunction = script.slice(
script.indexOf('launching bot'), script.indexOf('start_native_login_chrome()'),
script.indexOf('start_controlled_chrome()'),
); );
expect(nativeFunction).not.toContain('--remote-debugging');
}); });
it('installs the stable Google Chrome package rather than Chrome for Testing', () => { it('installs the stable Google Chrome package rather than Chrome for Testing', () => {