Improve ChatGPT login

This commit is contained in:
Arjun Komath
2026-05-02 14:26:42 +10:00
parent 772e14efc2
commit 3cf86a05cf
5 changed files with 316 additions and 46 deletions
+1 -1
View File
@@ -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
+7 -1
View File
@@ -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**
+94 -8
View File
@@ -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 @@
<option :value="o.value" x-text="o.label + (o.hint ? ' — ' + o.hint : '')"></option>
</template>
</select>
<p x-show="authGuidance" class="form-hint" x-text="authGuidance"></p>
</div>
<div class="form-group">
<div class="form-group" x-show="authRequiresSecret">
<label class="label">API Key / Token</label>
<input x-model="authSecret" type="password" placeholder="Paste your API key or token" class="input" />
</div>
@@ -563,7 +639,7 @@
<div class="form-group">
<label class="label">Model</label>
<input x-model="model" type="text" placeholder="e.g. openai/gpt-4.1, anthropic/claude-sonnet-4" class="input" />
<input x-model="model" type="text" placeholder="e.g. openai-codex/gpt-5.5, openai/gpt-4.1" class="input" />
<p class="form-hint">Format: provider/model-name</p>
</div>
</div>
@@ -660,7 +736,17 @@
</button>
</div>
<div x-show="log" class="log-output" x-text="log"></div>
<div x-show="loginUrl || loginCode" class="login-code-panel">
<div>
<p class="login-code-label">ChatGPT Login</p>
<a x-show="loginUrl" :href="loginUrl" target="_blank" rel="noopener noreferrer" class="login-code-url" x-text="loginUrl"></a>
</div>
<div x-show="loginCode" class="login-code-value" x-text="loginCode"></div>
</div>
<div x-show="setupError" class="setup-error-panel" x-text="setupError"></div>
<div x-show="log" x-ref="setupLog" class="log-output" x-text="log"></div>
<p class="text-xs text-muted mt-4">Reset deletes the config file so you can rerun onboarding. Channel access approval grants DM access when dmPolicy=pairing.</p>
</div>
+61
View File
@@ -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;
+153 -36
View File
@@ -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();
}
});