diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ac1954b..a4257ee 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -11,6 +11,7 @@ GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}" 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}" +NATIVE_LOGIN_EXIT_CODE=75 export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}" export DISPLAY=":${DISPLAY_NUM}" @@ -107,13 +108,46 @@ websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \ >/var/log/websockify.log 2>&1 & 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" +BOT_PID="" +GATE_PID="" + +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}" \ --user-data-dir="${BROWSER_SESSION_DIR}" \ --remote-debugging-address=127.0.0.1 \ @@ -133,37 +167,63 @@ if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then 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 + 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 + if [ "${chrome_ready}" != "1" ]; then + echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2 + stop_chrome + unset CHROME_CDP_URL 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() { echo "[entrypoint] shutting down" - if [ -n "${CHROME_PID}" ]; then - kill "${CHROME_PID}" 2>/dev/null || true - fi + [ -n "${BOT_PID}" ] && kill "${BOT_PID}" 2>/dev/null || true + [ -n "${GATE_PID}" ] && kill "${GATE_PID}" 2>/dev/null || true + stop_chrome kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true wait || true } trap cleanup TERM INT EXIT -echo "[entrypoint] launching bot" -exec node dist/index.js +if ! command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then + 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 diff --git a/public/index.html b/public/index.html index c3a5a6c..b56acd1 100644 --- a/public/index.html +++ b/public/index.html @@ -4079,6 +4079,11 @@ const res = await fetch('/api/login/start', { method: 'POST' }); if (!res.ok) { alert('Failed to open Reddit login page. Check the server logs.'); + return; + } + const data = await res.json(); + if (data.restarting) { + window.setTimeout(() => window.location.reload(), 1500); } } catch (err) { alert('Failed to open Reddit login page: ' + err); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 94b676f..a415e8a 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -36,6 +36,7 @@ import { authenticateCurrentAccount, type AuthenticatedAccount, } from './services/account-auth-context.js'; +import { ProcessNativeLoginHandoff } from './services/native-login-handoff.js'; export interface AppContainer { config: Config; @@ -111,7 +112,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise logger.info('Runtime config updated via dashboard')); } diff --git a/src/browser/client.ts b/src/browser/client.ts index 0ef09fc..c48c431 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -216,16 +216,8 @@ export class BrowserClient { async login(): Promise { 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); - } + this.sessionState = 'login-required'; + this.logger.warn('Native Reddit login required. Start it from the dashboard before signing in through VNC.'); return false; } diff --git a/src/native-login-gate.ts b/src/native-login-gate.ts new file mode 100644 index 0000000..e94d7c3 --- /dev/null +++ b/src/native-login-gate.ts @@ -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) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character] ?? character); +} + +function loginPage(publicVncUrl: string): string { + const safeVncUrl = escapeHtml(publicVncUrl); + return ` + + + + + Reddit login required + + +
+
+ Chrome is running without automation. +

Log in to Reddit in the browser below. Only after Reddit shows you as signed in, click I'm logged in - connect the bot. Chrome will restart with the same profile and the bot will verify both Reddit and Matrix before any bot logic can run.

+ + Open VNC in a new tab + +
+ + +
`; +} + +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(); diff --git a/src/services/native-login-handoff.ts b/src/services/native-login-handoff.ts new file mode 100644 index 0000000..3c4de58 --- /dev/null +++ b/src/services/native-login-handoff.ts @@ -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(); + } +} diff --git a/src/web/context.ts b/src/web/context.ts index 5efca2b..2865db7 100644 --- a/src/web/context.ts +++ b/src/web/context.ts @@ -14,6 +14,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js'; import type { StrategySelector } from '../services/strategy-selector.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 { LogBroadcaster } from './log-broadcaster.js'; import type { RuntimeConfig } from './types.js'; @@ -42,6 +43,7 @@ export interface WebContext { strategySelector?: StrategySelector; browser?: BrowserClient; accountAuth?: AccountAuthContext; + nativeLogin?: NativeLoginHandoff; }; /** Bot pause/resume toggle read by the main loop. */ diff --git a/src/web/routes/login.ts b/src/web/routes/login.ts index 120b93c..746a26b 100644 --- a/src/web/routes/login.ts +++ b/src/web/routes/login.ts @@ -41,24 +41,22 @@ export function createLoginRouter(ctx: WebContext): Router { } }); - // Navigate the headed Chromium running under Xvfb to reddit.com/login so - // the human watching the noVNC stream can complete the flow. + // Restart into a completely native Chrome process. The container supervisor + // does not enable DevTools until the human explicitly completes the handoff. router.post('/login/start', async (_req, res) => { - const browser = ctx.services.browser; - if (!browser) { - res.status(503).json({ ok: false, error: 'browser not initialised' }); + const nativeLogin = ctx.services.nativeLogin; + if (!nativeLogin) { + res.status(503).json({ ok: false, error: 'native login unavailable' }); return; } try { - ctx.services.accountAuth?.invalidate('Interactive Reddit login requested'); - const page = await browser.getPage(); - await page.bringToFront().catch(() => undefined); - await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); - ctx.logger.info('Opened Reddit login page for human completion via VNC'); - res.json({ ok: true }); + ctx.services.accountAuth?.invalidate('Native Reddit login requested'); + nativeLogin.request(); + ctx.logger.info('Restarting into native Chrome login mode; bot logic remains locked'); + res.status(202).json({ ok: true, restarting: true }); } catch (error) { - ctx.logger.error('Failed to open login page:', error); - res.status(500).json({ ok: false, error: 'navigation failed' }); + ctx.logger.error('Failed to start native login mode:', error); + res.status(500).json({ ok: false, error: 'handoff failed' }); } }); diff --git a/src/web/server.ts b/src/web/server.ts index cbbd9dd..61c7553 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -18,6 +18,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js'; import type { StrategySelector } from '../services/strategy-selector.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 { LogBroadcaster } from './log-broadcaster.js'; import type { WebContext } from './context.js'; @@ -180,6 +181,10 @@ export class WebServer { this.services.accountAuth = accountAuth; } + setNativeLoginHandoff(nativeLogin: NativeLoginHandoff): void { + this.services.nativeLogin = nativeLogin; + } + logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { this.activity.push(type, message, recipient, imageUrl); } diff --git a/tests/browser-external-chrome.test.ts b/tests/browser-external-chrome.test.ts index fece3d6..1e354b9 100644 --- a/tests/browser-external-chrome.test.ts +++ b/tests/browser-external-chrome.test.ts @@ -57,7 +57,7 @@ describe('BrowserClient external Chrome', () => { 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'; const page = { goto: vi.fn().mockResolvedValue(undefined), @@ -79,11 +79,8 @@ describe('BrowserClient external Chrome', () => { 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.bringToFront).not.toHaveBeenCalled(); + expect(page.goto).not.toHaveBeenCalled(); expect(page.fill).not.toHaveBeenCalled(); expect(client.getSessionState()).toBe('login-required'); }); diff --git a/tests/login-status.test.ts b/tests/login-status.test.ts index 762146e..ae74b20 100644 --- a/tests/login-status.test.ts +++ b/tests/login-status.test.ts @@ -1,6 +1,6 @@ import express from 'express'; 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 { createLoginRouter } from '../src/web/routes/login.js'; @@ -14,6 +14,14 @@ async function request(app: express.Express, path: string): Promise { return fetch(`http://127.0.0.1:${port}${path}`); } +async function post(app: express.Express, path: string): Promise { + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST' }); +} + afterEach(async () => { await Promise.all( servers.splice(0).map( @@ -105,4 +113,27 @@ describe('Reddit session status', () => { ).toBe('unreachable'); 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(); + }); }); diff --git a/tests/native-login-gate.test.ts b/tests/native-login-gate.test.ts new file mode 100644 index 0000000..e3569d2 --- /dev/null +++ b/tests/native-login-gate.test.ts @@ -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> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((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((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(); + }); +}); diff --git a/tests/vnc-entrypoint.test.ts b/tests/vnc-entrypoint.test.ts index f03e5b7..61a3c60 100644 --- a/tests/vnc-entrypoint.test.ts +++ b/tests/vnc-entrypoint.test.ts @@ -23,17 +23,23 @@ describe('VNC container entrypoint', () => { 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'); expect(script).toContain('google-chrome'); 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('export CHROME_CDP_URL='); expect(script).not.toContain('--enable-automation'); - expect(script.indexOf('starting Google Chrome')).toBeLessThan( - script.indexOf('launching bot'), + const nativeFunction = script.slice( + 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', () => {