Use full Chrome for interactive Reddit login

This commit is contained in:
jjbrunton committed 2026-08-08 15:30:24 +01:00
1 parent 70d62b2da3
commit 674cd89cd7
5 files changed
+241 -3

No files matched your search

+7
View File
@@ -14,6 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
websockify \ websockify \
fluxbox \ fluxbox \
procps \ procps \
ca-certificates \
curl \
&& curl -fsSLo /tmp/google-chrome-stable_current_amd64.deb \
https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
&& apt-get install -y --no-install-recommends \
/tmp/google-chrome-stable_current_amd64.deb \
&& rm -f /tmp/google-chrome-stable_current_amd64.deb \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+54
View File
@@ -9,6 +9,9 @@ VNC_PORT="${VNC_PORT:-5900}"
NOVNC_PORT="${NOVNC_PORT:-6080}" NOVNC_PORT="${NOVNC_PORT:-6080}"
GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}" 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}"
GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}"
export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}"
export DISPLAY=":${DISPLAY_NUM}" export DISPLAY=":${DISPLAY_NUM}"
# noVNC ships under different paths depending on the distro; find the web root once. # noVNC ships under different paths depending on the distro; find the web root once.
@@ -104,8 +107,59 @@ 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_READY=0
if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then
echo "[entrypoint] starting Google Chrome for interactive login"
"${GOOGLE_CHROME_BIN}" \
--user-data-dir="${BROWSER_SESSION_DIR}" \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port="${CHROME_CDP_PORT}" \
--no-first-run \
--no-default-browser-check \
--disable-dev-shm-usage \
--no-sandbox \
--start-maximized \
about:blank \
>/var/log/google-chrome.log 2>&1 &
CHROME_PID=$!
for i in $(seq 1 100); do
if ! kill -0 "${CHROME_PID}" 2>/dev/null; then
break
fi
if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then
echo "[entrypoint] Google Chrome is ready after ${i} checks"
CHROME_READY=1
break
fi
sleep 0.2
done
fi
if [ "${CHROME_READY}" != "1" ]; then
echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2
if [ -n "${CHROME_PID}" ]; then
kill "${CHROME_PID}" 2>/dev/null || true
wait "${CHROME_PID}" 2>/dev/null || true
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
kill "${CHROME_PID}" 2>/dev/null || true
fi
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
} }
+69 -3
View File
@@ -4,6 +4,25 @@ import type { Logger } from '../utils/logger.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
export interface ConnectedBrowser {
contexts(): BrowserContext[];
close(): Promise<void>;
}
export interface BrowserRuntime {
connectOverCDP(endpoint: string): Promise<ConnectedBrowser>;
launchPersistentContext(
userDataDir: string,
options: Parameters<typeof chromium.launchPersistentContext>[1]
): Promise<BrowserContext>;
}
const defaultBrowserRuntime: BrowserRuntime = {
connectOverCDP: (endpoint) => chromium.connectOverCDP(endpoint),
launchPersistentContext: (userDataDir, options) =>
chromium.launchPersistentContext(userDataDir, options),
};
export type RedditSessionState = export type RedditSessionState =
| 'unknown' | 'unknown'
| 'authenticated' | 'authenticated'
@@ -19,13 +38,16 @@ export function classifyRedditSessionError(error: unknown): Exclude<RedditSessio
export class BrowserClient { export class BrowserClient {
private context: BrowserContext | null = null; private context: BrowserContext | null = null;
private connectedBrowser: ConnectedBrowser | null = null;
private usingExternalChrome = false;
private sessionDir: string | null = null; private sessionDir: string | null = null;
private sessionValidityInFlight: Promise<boolean> | null = null; private sessionValidityInFlight: Promise<boolean> | null = null;
private sessionState: RedditSessionState = 'unknown'; private sessionState: RedditSessionState = 'unknown';
constructor( constructor(
private config: Config, private config: Config,
private logger: Logger private logger: Logger,
private browserRuntime: BrowserRuntime = defaultBrowserRuntime
) { } ) { }
async initialize(): Promise<BrowserContext> { async initialize(): Promise<BrowserContext> {
@@ -37,10 +59,31 @@ export class BrowserClient {
fs.mkdirSync(sessionDir, { recursive: true }); fs.mkdirSync(sessionDir, { recursive: true });
} }
this.logger.info(`Launching browser with session dir: ${sessionDir}`); const cdpEndpoint = process.env.CHROME_CDP_URL?.trim();
if (cdpEndpoint) {
try {
this.logger.info(`Attaching to independently launched Google Chrome at ${cdpEndpoint}`);
const browser = await this.browserRuntime.connectOverCDP(cdpEndpoint);
const context = browser.contexts()[0];
if (!context) {
await browser.close().catch(() => undefined);
throw new Error('External Chrome did not expose a default browser context');
}
this.connectedBrowser = browser;
this.usingExternalChrome = true;
this.context = context;
this.logger.info('Attached to Google Chrome; interactive login is available through VNC');
return context;
} catch (error) {
this.logger.warn('Could not attach to Google Chrome; falling back to Playwright browser launch:', error);
}
}
this.logger.info(`Launching fallback Playwright browser with session dir: ${sessionDir}`);
// Use launchPersistentContext to maintain cookies/localStorage // Use launchPersistentContext to maintain cookies/localStorage
this.context = await chromium.launchPersistentContext(sessionDir, { this.context = await this.browserRuntime.launchPersistentContext(sessionDir, {
headless: this.config.browser.headless, headless: this.config.browser.headless,
viewport: { width: 1280, height: 720 }, viewport: { width: 1280, height: 720 },
userAgent: this.config.reddit.userAgent, userAgent: this.config.reddit.userAgent,
@@ -70,6 +113,15 @@ export class BrowserClient {
} }
async close(): Promise<void> { async close(): Promise<void> {
if (this.connectedBrowser) {
this.logger.info('Closing Google Chrome connection');
await this.connectedBrowser.close();
this.connectedBrowser = null;
this.context = null;
this.usingExternalChrome = false;
return;
}
if (this.context) { if (this.context) {
this.logger.info('Closing browser'); this.logger.info('Closing browser');
await this.context.close(); await this.context.close();
@@ -150,6 +202,20 @@ export class BrowserClient {
} }
async login(): Promise<boolean> { async login(): Promise<boolean> {
if (this.usingExternalChrome) {
try {
const page = await this.getPage();
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;
}
if (!this.config.reddit.username || !this.config.reddit.password) { if (!this.config.reddit.username || !this.config.reddit.password) {
this.sessionState = 'login-required'; this.sessionState = 'login-required';
this.logger.error('Login failed: Missing credentials in config/env'); this.logger.error('Login failed: Missing credentials in config/env');
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import {
BrowserClient,
type BrowserRuntime,
type ConnectedBrowser,
} from '../src/browser/client.js';
import type { Logger } from '../src/utils/logger.js';
import { getTestConfig } from './testUtils.js';
function createLogger(): Logger {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as Logger;
}
afterEach(() => {
delete process.env.CHROME_CDP_URL;
});
describe('BrowserClient external Chrome', () => {
it('attaches to independently launched Chrome before considering Playwright launch', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const context = { pages: () => [] } as unknown as BrowserContext;
const connectedBrowser = {
contexts: () => [context],
close: vi.fn(),
} as unknown as ConnectedBrowser;
const runtime: BrowserRuntime = {
connectOverCDP: vi.fn().mockResolvedValue(connectedBrowser),
launchPersistentContext: vi.fn(),
};
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
const result = await client.initialize();
expect(result).toBe(context);
expect(runtime.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:9222');
expect(runtime.launchPersistentContext).not.toHaveBeenCalled();
});
it('falls back to Playwright only when external Chrome cannot be reached', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const fallbackContext = { pages: () => [] } as unknown as BrowserContext;
const runtime: BrowserRuntime = {
connectOverCDP: vi.fn().mockRejectedValue(new Error('connection refused')),
launchPersistentContext: vi.fn().mockResolvedValue(fallbackContext),
};
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
const result = await client.initialize();
expect(result).toBe(fallbackContext);
expect(runtime.launchPersistentContext).toHaveBeenCalledOnce();
});
it('opens the login page for a human without submitting credentials', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const page = {
goto: vi.fn().mockResolvedValue(undefined),
bringToFront: vi.fn().mockResolvedValue(undefined),
fill: vi.fn(),
} as unknown as Page;
const context = { pages: () => [page] } as unknown as BrowserContext;
const runtime: BrowserRuntime = {
connectOverCDP: vi.fn().mockResolvedValue({
contexts: () => [context],
close: vi.fn(),
}),
launchPersistentContext: vi.fn(),
};
const client = new BrowserClient(getTestConfig({
reddit: { username: 'human', password: 'secret' },
}), createLogger(), runtime);
await client.initialize();
await expect(client.login()).resolves.toBe(false);
expect(page.bringToFront).toHaveBeenCalledOnce();
expect(page.goto).toHaveBeenCalledWith(
'https://www.reddit.com/login',
{ waitUntil: 'domcontentloaded' },
);
expect(page.fill).not.toHaveBeenCalled();
expect(client.getSessionState()).toBe('login-required');
});
});
+21
View File
@@ -22,4 +22,25 @@ describe('VNC container entrypoint', () => {
expect(script).toContain('"${BROWSER_SESSION_DIR}/SingletonCookie"'); expect(script).toContain('"${BROWSER_SESSION_DIR}/SingletonCookie"');
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', () => {
const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8');
expect(script).toContain('google-chrome');
expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"');
expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"');
expect(script).toContain('export CHROME_CDP_URL=');
expect(script).not.toContain('--enable-automation');
expect(script.indexOf('starting Google Chrome')).toBeLessThan(
script.indexOf('launching bot'),
);
});
it('installs the stable Google Chrome package rather than Chrome for Testing', () => {
const dockerfile = fs.readFileSync(path.resolve('Dockerfile'), 'utf8');
expect(dockerfile).toContain('google-chrome-stable_current_amd64.deb');
expect(dockerfile).toContain('google-chrome-stable');
expect(dockerfile).not.toContain('chrome-for-testing');
});
}); });