From 3cf86a05cf2529d75d94b82813ec5df8bfb0ec35 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 2 May 2026 14:26:42 +1000 Subject: [PATCH] Improve ChatGPT login --- Dockerfile | 2 +- README.md | 8 +- src/public/setup.html | 102 +++++++++++++++++++++-- src/public/styles.css | 61 ++++++++++++++ src/server.js | 189 ++++++++++++++++++++++++++++++++++-------- 5 files changed, 316 insertions(+), 46 deletions(-) diff --git a/Dockerfile b/Dockerfile index c6caaa1..e696e6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update \ zip \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g openclaw@2026.4.23 clawhub@latest +RUN npm install -g openclaw@2026.4.29 clawhub@latest WORKDIR /app diff --git a/README.md b/README.md index 076e8eb..5c3d28b 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,17 @@ - The container runs a wrapper web server. - The wrapper protects `/setup` with `SETUP_PASSWORD`. -- During setup, the wrapper runs `openclaw onboard --non-interactive ...` inside the container, writes state to the volume, and then starts the gateway. +- During setup, the wrapper runs `openclaw onboard ...` inside the container, writes state to the volume, and then starts the gateway. API-key providers use non-interactive setup; ChatGPT/Codex device pairing uses OpenClaw's interactive device-code flow so the login URL and code can be streamed to `/setup`. - After setup, **`/` is OpenClaw**. The wrapper reverse-proxies all traffic (including WebSockets) to the local gateway process. ## Getting chat tokens (so you don't have to scramble) +### ChatGPT / OpenAI Codex login + +In `/setup`, choose **OpenAI → OpenAI Codex device pairing**. Setup will stream a ChatGPT/Codex URL and a short device code; open the URL in your browser, enter the code, and keep the setup page open until OpenClaw finishes saving the OAuth profile. + +This uses OpenClaw's `openai-codex-device-code` onboarding flow, so you do not need to paste an OpenAI API key. + ### Telegram bot token 1. Open Telegram and message **@BotFather** diff --git a/src/public/setup.html b/src/public/setup.html index 2a6db20..b12726c 100644 --- a/src/public/setup.html +++ b/src/public/setup.html @@ -32,6 +32,10 @@ slackAppToken: '', model: '', log: '', + loginUrl: '', + loginCode: '', + setupError: '', + setupStreamTail: '', loading: false, pairingChannel: '', pairingCode: '', @@ -50,6 +54,31 @@ return this.authGroups.find(g => g.value === this.selectedGroup); }, + get authRequiresSecret() { + return ![ + 'openai-codex', + 'openai-codex-device-code', + 'google-gemini-cli', + 'github-copilot', + 'ollama', + 'vllm', + 'sglang' + ].includes(this.selectedAuth); + }, + + get authGuidance() { + if (this.selectedAuth === 'openai-codex-device-code') { + return 'Run setup, then open the ChatGPT/Codex URL and enter the device code shown in the log.'; + } + if (this.selectedAuth === 'openai-codex') { + return 'Browser login needs an interactive terminal to paste the redirect URL. Device pairing is recommended here.'; + } + if (!this.authRequiresSecret) { + return 'No API key is needed for this auth method.'; + } + return ''; + }, + async init() { await this.refreshStatus(); }, @@ -101,6 +130,10 @@ }, async runSetup() { + this.loginUrl = ''; + this.loginCode = ''; + this.setupError = ''; + this.setupStreamTail = ''; const payload = { authChoice: this.selectedAuth, authSecret: this.authSecret, @@ -126,18 +159,60 @@ headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }); - const text = await res.text(); - let j; - try { j = JSON.parse(text); } catch (_e) { j = { ok: false, output: text }; } - this.log += (j.output || JSON.stringify(j, null, 2)); + if (res.body) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + this.processSetupChunk(decoder.decode(value, { stream: true })); + this.$nextTick(() => { + const el = this.$refs.setupLog; + if (el) el.scrollTop = el.scrollHeight; + }); + } + this.processSetupChunk(decoder.decode()); + } else { + this.processSetupChunk(await res.text()); + } + if (!res.ok && !this.setupError) { + this.setupError = 'Setup request failed with HTTP ' + res.status + '.'; + } await this.refreshStatus(); } catch (e) { - this.log += '\nError: ' + String(e) + '\n'; + this.setupError = 'Setup request failed.'; + this.processSetupChunk('\nError: ' + String(e) + '\n'); } finally { this.loading = false; } }, + processSetupChunk(chunk) { + if (!chunk) return; + this.log += chunk; + const scanText = this.setupStreamTail + chunk; + this.setupStreamTail = scanText.slice(-1000); + this.extractLoginDetails(scanText); + this.extractSetupFailure(scanText); + }, + + extractLoginDetails(text) { + const urlMatch = text.match(/https:\/\/auth\.openai\.com\/codex\/device[^\s)]*/); + if (urlMatch) this.loginUrl = urlMatch[0]; + + const codeMatches = [...text.matchAll(/\bCode:\s*([A-Z0-9][A-Z0-9-]{3,})\b/g)]; + const visibleCode = codeMatches.map(m => m[1]).find(code => code !== 'shown'); + if (visibleCode) this.loginCode = visibleCode; + }, + + extractSetupFailure(text) { + if (text.includes('[setup] Internal error')) { + this.setupError = 'Setup hit an internal error. Review the log below.'; + } else if (text.includes('[setup] Failed')) { + this.setupError = 'Setup failed. Review the log below.'; + } + }, + openPairingModal() { this.pairingChannel = ''; this.pairingCode = ''; @@ -521,9 +596,10 @@ +

-
+
@@ -563,7 +639,7 @@
- +

Format: provider/model-name

@@ -660,7 +736,17 @@ -
+
+
+ + +
+ +
+ +
+ +

Reset deletes the config file so you can rerun onboarding. Channel access approval grants DM access when dmPolicy=pairing.

diff --git a/src/public/styles.css b/src/public/styles.css index 7fe9e5c..41d5063 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -546,6 +546,67 @@ img { max-width: 100%; height: auto; } overflow-y: auto; } +.login-code-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: rgba(14, 165, 233, 0.08); + border: 1px solid rgba(14, 165, 233, 0.24); + border-radius: 0.5rem; + padding: 1rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.login-code-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.login-code-url { + color: #0284c7; + font-size: 0.875rem; + word-break: break-all; +} + +.login-code-value { + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.08em; + color: #0369a1; + white-space: nowrap; +} + +.setup-error-panel { + background: rgba(220, 38, 38, 0.08); + border: 1px solid rgba(220, 38, 38, 0.24); + border-radius: 0.5rem; + color: #b91c1c; + font-size: 0.875rem; + font-weight: 500; + padding: 0.875rem 1rem; + margin-bottom: 1rem; +} + +@media (prefers-color-scheme: dark) { + .login-code-panel { + background: rgba(14, 165, 233, 0.12); + border-color: rgba(56, 189, 248, 0.32); + } + .login-code-url { color: #38bdf8; } + .login-code-value { color: #7dd3fc; } + .setup-error-panel { + background: rgba(220, 38, 38, 0.12); + border-color: rgba(248, 113, 113, 0.32); + color: #fca5a5; + } +} + .danger-card { background: var(--bg-card); border: 1px solid #fecaca; diff --git a/src/server.js b/src/server.js index dcbdb9b..ba551fc 100644 --- a/src/server.js +++ b/src/server.js @@ -132,6 +132,26 @@ function clawArgs(args) { return [OPENCLAW_ENTRY, ...args]; } +function stripAnsi(value) { + return String(value) + .replace(/\x1b\]8;;.*?\x1b\\|\x1b\]8;;\x1b\\/g, "") + .replace(/\x1b\[[\x20-\x3f]*[\x40-\x7e]/g, ""); +} + +function isTransientProgressLine(line) { + return /^[\s◐◓◑◒⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏.-]*(Requesting device code|Waiting for device authorization|Exchanging device code)/.test( + line, + ); +} + +function cleanPtyOutput(value) { + const cleaned = stripAnsi(value) + .split(/\r|\n/) + .filter((line) => line && !isTransientProgressLine(line)) + .join("\n"); + return cleaned ? `${cleaned}\n` : ""; +} + function configPath() { return ( process.env.OPENCLAW_CONFIG_PATH?.trim() || @@ -488,7 +508,11 @@ app.get("/setup/api/status", requireSetupAuth, async (_req, res) => { hint: "API key / Codex", options: [ { value: "openai-api-key", label: "OpenAI API key" }, - { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, + { + value: "openai-codex-device-code", + label: "OpenAI Codex device pairing", + hint: "ChatGPT login without an API key", + }, ], }, { @@ -709,12 +733,15 @@ app.get("/setup/api/status", requireSetupAuth, async (_req, res) => { }); }); +function requiresInteractiveOnboarding(payload) { + return payload.authChoice === "openai-codex-device-code"; +} + function buildOnboardArgs(payload) { + const interactive = requiresInteractiveOnboarding(payload); const args = [ "onboard", - "--non-interactive", "--accept-risk", - "--json", "--no-install-daemon", "--skip-health", "--workspace", @@ -731,6 +758,19 @@ function buildOnboardArgs(payload) { "quickstart", ]; + if (interactive) { + args.push( + "--mode", + "local", + "--skip-channels", + "--skip-skills", + "--skip-search", + "--skip-ui", + ); + } else { + args.push("--non-interactive", "--json"); + } + if (payload.authChoice) { args.push("--auth-choice", payload.authChoice); @@ -798,8 +838,9 @@ function buildOnboardArgs(payload) { function runCmd(cmd, args, opts = {}) { return new Promise((resolve) => { + const { onOutput, stripOutput, ...spawnOpts } = opts; const proc = childProcess.spawn(cmd, args, { - ...opts, + ...spawnOpts, env: { ...process.env, OPENCLAW_STATE_DIR: STATE_DIR, @@ -808,8 +849,14 @@ function runCmd(cmd, args, opts = {}) { }); let out = ""; - proc.stdout?.on("data", (d) => (out += d.toString("utf8"))); - proc.stderr?.on("data", (d) => (out += d.toString("utf8"))); + const append = (d) => { + const rawChunk = d.toString("utf8"); + const streamChunk = stripOutput ? stripAnsi(rawChunk) : rawChunk; + out += rawChunk; + onOutput?.(streamChunk); + }; + proc.stdout?.on("data", append); + proc.stderr?.on("data", append); proc.on("error", (err) => { out += `\n[spawn error] ${String(err)}\n`; @@ -820,10 +867,56 @@ function runCmd(cmd, args, opts = {}) { }); } +function runPtyCmd(cmd, args, opts = {}) { + return new Promise((resolve) => { + let out = ""; + let proc; + try { + proc = pty.spawn(cmd, args, { + name: "xterm-color", + cols: 100, + rows: 30, + cwd: opts.cwd ?? process.cwd(), + env: { + ...process.env, + OPENCLAW_STATE_DIR: STATE_DIR, + OPENCLAW_WORKSPACE_DIR: WORKSPACE_DIR, + // Force OpenClaw's local device-code branch so Railway setup can show + // the short code in the web UI instead of hiding it as remote-only. + DISPLAY: process.env.DISPLAY || ":0", + WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY || "wayland-0", + SSH_CLIENT: "", + SSH_TTY: "", + SSH_CONNECTION: "", + FORCE_COLOR: "0", + NO_COLOR: "1", + }, + }); + } catch (err) { + out += `\n[spawn error] ${String(err)}\n`; + opts.onOutput?.(out); + resolve({ code: 127, output: out }); + return; + } + + proc.onData((data) => { + const chunk = opts.cleanOutput ? cleanPtyOutput(data) : stripAnsi(data); + if (!chunk) return; + out += chunk; + opts.onOutput?.(chunk); + }); + + proc.onExit(({ exitCode }) => { + resolve({ code: exitCode ?? 0, output: out }); + }); + }); +} + const VALID_AUTH_CHOICES = [ "apiKey", "openai-api-key", "openai-codex", + "openai-codex-device-code", "gemini-api-key", "google-gemini-cli", "deepseek-api-key", @@ -875,6 +968,9 @@ function validatePayload(payload) { if (payload.authChoice && !VALID_AUTH_CHOICES.includes(payload.authChoice)) { return `Invalid authChoice: ${payload.authChoice}`; } + if (payload.authChoice === "openai-codex") { + return "OpenAI Codex browser login needs redirect-url input in an interactive terminal. Choose OpenAI Codex device pairing in web setup."; + } const stringFields = [ "telegramToken", "discordToken", @@ -897,14 +993,16 @@ function validatePayload(payload) { } app.post("/setup/api/run", requireSetupAuth, async (req, res) => { + const stream = (chunk) => { + if (chunk) res.write(chunk); + }; + try { if (isConfigured()) { await ensureGatewayRunning(); - return res.json({ - ok: true, - output: - "Already configured.\nUse Reset setup if you want to rerun onboarding.\n", - }); + return res + .type("text/plain") + .send("Already configured.\nUse Reset setup if you want to rerun onboarding.\n"); } fs.mkdirSync(STATE_DIR, { recursive: true }); @@ -913,18 +1011,34 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { const payload = req.body || {}; const validationError = validatePayload(payload); if (validationError) { - return res.status(400).json({ ok: false, output: validationError }); + return res.status(400).type("text/plain").send(`${validationError}\n`); } - const onboardArgs = buildOnboardArgs(payload); - const onboard = await runCmd(OPENCLAW_NODE, clawArgs(onboardArgs)); - let extra = ""; - extra += `\n[setup] Onboarding exit=${onboard.code} configured=${isConfigured()}\n`; + res.set({ + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + }); + + const onboardArgs = buildOnboardArgs(payload); + const interactive = requiresInteractiveOnboarding(payload); + stream( + interactive + ? "Starting OpenAI Codex device pairing. Use the URL and code below, then keep this page open until it completes.\n\n" + : "Starting OpenClaw onboarding...\n\n", + ); + + const onboardRunner = interactive ? runPtyCmd : runCmd; + const onboard = await onboardRunner(OPENCLAW_NODE, clawArgs(onboardArgs), { + onOutput: stream, + cleanOutput: interactive, + stripOutput: !interactive, + }); const ok = onboard.code === 0 && isConfigured(); + stream(`\n[setup] Onboarding exit=${onboard.code} configured=${isConfigured()}\n`); if (ok) { - extra += "\n[setup] Configuring gateway settings...\n"; + stream("\n[setup] Configuring gateway settings...\n"); const allowInsecureResult = await runCmd( OPENCLAW_NODE, @@ -935,7 +1049,9 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { "true", ]), ); - extra += `[config] gateway.controlUi.allowInsecureAuth=true exit=${allowInsecureResult.code}\n`; + stream( + `[config] gateway.controlUi.allowInsecureAuth=true exit=${allowInsecureResult.code}\n`, + ); const tokenResult = await runCmd( OPENCLAW_NODE, @@ -946,7 +1062,7 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { OPENCLAW_GATEWAY_TOKEN, ]), ); - extra += `[config] gateway.auth.token exit=${tokenResult.code}\n`; + stream(`[config] gateway.auth.token exit=${tokenResult.code}\n`); const proxiesResult = await runCmd( OPENCLAW_NODE, @@ -958,15 +1074,16 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { '["127.0.0.1"]', ]), ); - extra += `[config] gateway.trustedProxies exit=${proxiesResult.code}\n`; + stream(`[config] gateway.trustedProxies exit=${proxiesResult.code}\n`); if (payload.model?.trim()) { - extra += `[setup] Setting model to ${payload.model.trim()}...\n`; + stream(`[setup] Setting model to ${payload.model.trim()}...\n`); const modelResult = await runCmd( OPENCLAW_NODE, clawArgs(["models", "set", payload.model.trim()]), + { onOutput: stream, stripOutput: true }, ); - extra += `[models set] exit=${modelResult.code}\n${modelResult.output || ""}`; + stream(`[models set] exit=${modelResult.code}\n`); } async function configureChannel(name, cfgObj) { @@ -984,14 +1101,14 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { OPENCLAW_NODE, clawArgs(["config", "get", `channels.${name}`]), ); - return ( + stream( `\n[${name} config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}` + - `\n[${name} verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}` + `\n[${name} verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}\n`, ); } if (payload.telegramToken?.trim()) { - extra += await configureChannel("telegram", { + await configureChannel("telegram", { enabled: true, dmPolicy: "pairing", botToken: payload.telegramToken.trim(), @@ -1001,7 +1118,7 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { } if (payload.discordToken?.trim()) { - extra += await configureChannel("discord", { + await configureChannel("discord", { enabled: true, token: payload.discordToken.trim(), groupPolicy: "open", @@ -1010,27 +1127,27 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { } if (payload.slackBotToken?.trim() || payload.slackAppToken?.trim()) { - extra += await configureChannel("slack", { + await configureChannel("slack", { enabled: true, botToken: payload.slackBotToken?.trim() || undefined, appToken: payload.slackAppToken?.trim() || undefined, }); } - extra += "\n[setup] Starting gateway...\n"; + stream("\n[setup] Starting gateway...\n"); await restartGateway(); - extra += "[setup] Gateway started.\n"; + stream("[setup] Gateway started.\n"); } - return res.status(ok ? 200 : 500).json({ - ok, - output: `${onboard.output}${extra}`, - }); + stream(ok ? "\n[setup] Complete.\n" : "\n[setup] Failed. Review the output above.\n"); + return res.end(); } catch (err) { log.error("setup", `run error: ${String(err)}`); - return res - .status(500) - .json({ ok: false, output: `Internal error: ${String(err)}` }); + if (!res.headersSent) { + return res.status(500).type("text/plain").send(`Internal error: ${String(err)}\n`); + } + stream(`\n[setup] Internal error: ${String(err)}\n`); + return res.end(); } });