Use native Chrome for Reddit login handoff
This commit is contained in:
1 parent
2c533fa52a
commit
dbb1842ef4
13 files changed
+324
-60
No files matched your search
+3
-1
@@ -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<AppCont
|
||||
if (!loggedIn) {
|
||||
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...');
|
||||
loggedIn = await browserClient.login();
|
||||
}
|
||||
@@ -327,6 +328,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
webServer.setStrategySelector(strategySelector);
|
||||
webServer.setBrowserClient(browserClient);
|
||||
webServer.setAccountAuthContext(accountAuth);
|
||||
webServer.setNativeLoginHandoff(new ProcessNativeLoginHandoff());
|
||||
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -216,16 +216,8 @@ export class BrowserClient {
|
||||
|
||||
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);
|
||||
}
|
||||
this.sessionState = 'login-required';
|
||||
this.logger.warn('Native Reddit login required. Start it from the dashboard before signing in through VNC.');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<!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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
+11
-13
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user