ask-pro 0.2.80 → 0.2.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/SKILL.md +11 -0
- package/dist/bin/ask-pro-cli.js +36 -1
- package/dist/src/ask-pro/browserRunner.js +4 -0
- package/dist/src/ask-pro/loginPage.js +30 -0
- package/dist/src/ask-pro/remoteLogin.js +278 -0
- package/dist/src/browser/chromeLifecycle.js +5 -1
- package/dist/src/browser/config.js +5 -2
- package/dist/src/browser/index.js +2 -2
- package/dist/src/browser/linuxChrome.js +82 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -27,8 +27,8 @@ Both installs run the same bundled launcher. The `ask-pro` npm package
|
|
|
27
27
|
(`npm i -g ask-pro`) still works too. All three share the same state.
|
|
28
28
|
|
|
29
29
|
You need Node.js 24+, npm, Chrome, and a ChatGPT account with Pro access. The
|
|
30
|
-
browser runtime
|
|
31
|
-
|
|
30
|
+
browser runtime supports Windows and Linux. Linux without `DISPLAY` or
|
|
31
|
+
`WAYLAND_DISPLAY` uses headless Chrome. See [Linux setup and remote sign-in](docs/linux.md).
|
|
32
32
|
|
|
33
33
|
**Upgrading from an earlier version:** nothing to do. On its first run,
|
|
34
34
|
ask-pro moves your signed-in browser profiles from `~/.codex/state/ask-pro` to
|
package/SKILL.md
CHANGED
|
@@ -11,6 +11,17 @@ The calling agent still owns the work. Use Pro for judgment, architecture, risk
|
|
|
11
11
|
|
|
12
12
|
## Running ask-pro
|
|
13
13
|
|
|
14
|
+
On a display-less Linux host, install Xvfb (`sudo apt install xvfb`); ask-pro
|
|
15
|
+
manages a private display for headed Chrome during login and queries. Set
|
|
16
|
+
`CHROME_PATH` if Chrome is not discovered, and
|
|
17
|
+
`ASK_PRO_LOGIN_HOST` to the host's private Tailscale IP. Before the first query,
|
|
18
|
+
run `ask-pro --login` and give the human its `login_url`. Keep that command alive
|
|
19
|
+
while they sign in from their desktop or phone. The link expires after 15 minutes
|
|
20
|
+
or Done. Never drive the login page or capture its frames during real sign-in.
|
|
21
|
+
After the human confirms the ChatGPT composer is visible, resume the saved
|
|
22
|
+
session or start the requested query. A later `needs_auth` result includes a
|
|
23
|
+
`login` command for the same profile. See [Linux setup](docs/linux.md).
|
|
24
|
+
|
|
14
25
|
This skill folder contains ask-pro itself; nothing else needs installing. In
|
|
15
26
|
the commands below, `ask-pro` stands for:
|
|
16
27
|
|
package/dist/bin/ask-pro-cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "dotenv/config";
|
|
3
|
+
import { runRemoteLogin } from "../src/ask-pro/remoteLogin.js";
|
|
3
4
|
import { AssistantStoppedError } from "../src/browser/errors.js";
|
|
4
5
|
import { AssistantQuestionsError } from "../src/browser/actions/questions.js";
|
|
5
6
|
import { readAskProQuestions } from "../src/ask-pro/questions.js";
|
|
@@ -8,7 +9,7 @@ import fs from "node:fs/promises";
|
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { Command, Option } from "commander";
|
|
10
11
|
import { createAskProSession, getAskProSessionPaths, pruneExpiredAskProSessions, readAskProAnswer, readAskProStatus, updateAskProResumeCommand, updateAskProStatus, } from "../src/ask-pro/session.js";
|
|
11
|
-
import { AskProNeedsAuthError, isSuspiciousPreambleAnswer, resumeAskProBrowserSession, runAskProBrowserSession, } from "../src/ask-pro/browserRunner.js";
|
|
12
|
+
import { AskProNeedsAuthError, resolveAskProLoginProfile, isSuspiciousPreambleAnswer, resumeAskProBrowserSession, runAskProBrowserSession, } from "../src/ask-pro/browserRunner.js";
|
|
12
13
|
import { renderToonRecord } from "../src/ask-pro/toon.js";
|
|
13
14
|
import { askProAgentIdForLegacyBrowserProfileDir, askProAgentIdForManagedBrowserProfileDir, defaultAskProBrowserProfileDir, isLegacyAskProManagedBrowserProfileDir, } from "../src/browser/profilePaths.js";
|
|
14
15
|
import { migrateLegacyAskProProfiles } from "../src/browser/profileMigration.js";
|
|
@@ -20,6 +21,20 @@ program
|
|
|
20
21
|
.version(getCliVersion())
|
|
21
22
|
.argument("[question...]", "question to send to ChatGPT Pro")
|
|
22
23
|
.option("--dry-run", "prepare the session and context bundle without opening the browser")
|
|
24
|
+
.addOption(new Option("--login [session-id]", "open a private remote sign-in link for headless Chrome").conflicts([
|
|
25
|
+
"dryRun",
|
|
26
|
+
"files",
|
|
27
|
+
"promptFile",
|
|
28
|
+
"answers",
|
|
29
|
+
"artifacts",
|
|
30
|
+
"github",
|
|
31
|
+
"responseZip",
|
|
32
|
+
"resume",
|
|
33
|
+
"status",
|
|
34
|
+
"harvest",
|
|
35
|
+
"copy",
|
|
36
|
+
"temporary",
|
|
37
|
+
]))
|
|
23
38
|
.option("--files <pattern>", "include files or globs in the context bundle", collectFiles, [])
|
|
24
39
|
.option("--prompt-file <path>", "read the question from a UTF-8 file; use - for stdin")
|
|
25
40
|
.option("--artifacts", "ask Pro for ask-pro-response.zip plus markdown fallback")
|
|
@@ -58,6 +73,21 @@ program
|
|
|
58
73
|
await program.parseAsync(process.argv);
|
|
59
74
|
async function runAskPro(question, options) {
|
|
60
75
|
const cwd = resolveProjectCwd(options);
|
|
76
|
+
if (options.login) {
|
|
77
|
+
if (question.trim())
|
|
78
|
+
throw new Error("Use --login without a question.");
|
|
79
|
+
const profile = typeof options.login === "string"
|
|
80
|
+
? await resolveAskProLoginProfile(cwd, options.login)
|
|
81
|
+
: undefined;
|
|
82
|
+
await runRemoteLogin((url) => writeToon("ask_pro", {
|
|
83
|
+
state: "needs_auth",
|
|
84
|
+
action: "open_login_url",
|
|
85
|
+
login_url: url,
|
|
86
|
+
expires_minutes: 15,
|
|
87
|
+
}), profile);
|
|
88
|
+
writeToon("ask_pro", { state: "login_closed", action: "confirm_composer_then_resume" });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
61
91
|
if (options.answers && question.trim())
|
|
62
92
|
throw new Error("Use --answers with --resume, without a question argument.");
|
|
63
93
|
await pruneExpiredAskProSessions({ cwd });
|
|
@@ -347,6 +377,11 @@ async function printAuthInstructions(sessionId, options, cwd, error) {
|
|
|
347
377
|
...fallbackPreflight,
|
|
348
378
|
...browserPreflight,
|
|
349
379
|
action: "human_login_then_resume",
|
|
380
|
+
...(process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
|
|
381
|
+
? {
|
|
382
|
+
login: `${buildLauncherCommand()} --cwd ${quoteCommandArg(cwd)} --login ${quoteCommandArg(sessionId)}`,
|
|
383
|
+
}
|
|
384
|
+
: {}),
|
|
350
385
|
resume: resumeCommand,
|
|
351
386
|
});
|
|
352
387
|
}
|
|
@@ -717,6 +717,10 @@ async function writeTerminalBrowserMetadata(cwd, sessionId, status, reason) {
|
|
|
717
717
|
},
|
|
718
718
|
});
|
|
719
719
|
}
|
|
720
|
+
export async function resolveAskProLoginProfile(cwd, sessionId) {
|
|
721
|
+
const paths = getAskProSessionPaths(cwd, sessionId);
|
|
722
|
+
return resolveResumeBrowserProfile(await readBrowserMetadata(paths.browser));
|
|
723
|
+
}
|
|
720
724
|
async function resolveResumeBrowserProfile(metadata) {
|
|
721
725
|
const agentProfile = resolveStoredAgentProfile(metadata.agentId);
|
|
722
726
|
const profileDir = metadata.profileDir;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The browser relays human input only. Never persist input or frames.
|
|
2
|
+
// Keep the short-lived access key in the URL fragment so reload preserves access.
|
|
3
|
+
export const loginPage = `<!doctype html>
|
|
4
|
+
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
5
|
+
<title>ask-pro sign in</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root{font:16px/1.5 system-ui;color:#182421;background:#f5f7f6}body{margin:0}main{max-width:1280px;margin:auto;padding:20px}header{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:16px}h1{font-size:22px;margin:0 auto 0 0}button,input,select{font:inherit;padding:10px;border:1px solid #62716b;border-radius:6px;background:white;color:inherit}button{cursor:pointer}button:hover{background:#e0ebe5}:focus-visible{outline:3px solid #197451;outline-offset:2px}#screen{display:block;width:100%;background:white;touch-action:manipulation}form{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}input{flex:1;min-width:150px}#status{min-height:24px;margin:8px 0;color:#344b41}button:disabled{opacity:.6;cursor:wait}
|
|
8
|
+
</style><main><header><h1>Sign in to ChatGPT</h1><select id="tabs" aria-label="Browser tab"></select><button id="refresh">Refresh tabs</button><button id="done">Done</button></header>
|
|
9
|
+
<p id="status" role="status">Connecting…</p><img id="screen" tabindex="0" alt="Remote browser. Click a field, then type, or use the text box below.">
|
|
10
|
+
<form><input id="text" type="password" autocomplete="off" aria-label="Text for selected browser field" placeholder="Type into selected field"><button>Send text</button><button type="button" id="enter">Enter</button><button type="button" id="tab">Tab</button><button type="button" id="backspace">Backspace</button><button type="button" id="up">Scroll up</button><button type="button" id="down">Scroll down</button></form></main>
|
|
11
|
+
<script>
|
|
12
|
+
const token=location.hash.slice(1);
|
|
13
|
+
const screen=document.querySelector('#screen'),tabs=document.querySelector('#tabs'),status=document.querySelector('#status'),text=document.querySelector('#text');
|
|
14
|
+
let stopped=false,frameUrl,inputQueue=Promise.resolve();
|
|
15
|
+
async function api(route,body){const r=await fetch(route,{method:body===undefined?'GET':'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});if(!r.ok)throw Error(r.status===401?'Link expired. Start ask-pro --login again.':'Connection failed. Retry or refresh tabs.');return r;}
|
|
16
|
+
function report(e){status.textContent=e.message;}
|
|
17
|
+
function input(body){const target=tabs.value;const request=inputQueue.then(()=>api('/input',{target,...body}));inputQueue=request.catch(report);return request;}
|
|
18
|
+
async function refresh(){try{const list=await(await api('/tabs')).json();const previous=tabs.value;tabs.replaceChildren(...list.map(t=>new Option(t.name,t.id)));if(list.some(t=>t.id===previous))tabs.value=previous;status.textContent='Click the browser to sign in. Select Done when the ChatGPT composer is visible.';}catch(e){report(e);}}
|
|
19
|
+
async function frame(){if(stopped)return;try{if(tabs.value){const r=await api('/frame?target='+encodeURIComponent(tabs.value));const url=URL.createObjectURL(await r.blob());screen.src=url;if(frameUrl)URL.revokeObjectURL(frameUrl);frameUrl=url;}}catch(e){report(e);}setTimeout(frame,350);}
|
|
20
|
+
screen.onclick=e=>{const r=screen.getBoundingClientRect();input({kind:'click',x:(e.clientX-r.left)*screen.naturalWidth/r.width,y:(e.clientY-r.top)*screen.naturalHeight/r.height});screen.focus();};
|
|
21
|
+
screen.onkeydown=e=>{if(e.ctrlKey||e.metaKey||e.altKey)return;if(e.key.length===1){e.preventDefault();input({kind:'text',text:e.key});}else if(['Enter','Tab','Backspace','Delete','ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Escape','Home','End'].includes(e.key)){e.preventDefault();input({kind:'key',key:e.key});}};
|
|
22
|
+
screen.onwheel=e=>{e.preventDefault();const r=screen.getBoundingClientRect();input({kind:'scroll',x:(e.clientX-r.left)*screen.naturalWidth/r.width,y:(e.clientY-r.top)*screen.naturalHeight/r.height,deltaY:e.deltaY});};
|
|
23
|
+
document.querySelector('form').onsubmit=async e=>{e.preventDefault();const value=text.value;try{await input({kind:'text',text:value});if(text.value===value)text.value='';}catch(e){report(e);}};
|
|
24
|
+
for(const key of ['Enter','Tab','Backspace'])document.querySelector('#'+key.toLowerCase()).onclick=()=>input({kind:'key',key});
|
|
25
|
+
document.querySelector('#up').onclick=()=>input({kind:'scroll',x:100,y:100,deltaY:-400});
|
|
26
|
+
document.querySelector('#down').onclick=()=>input({kind:'scroll',x:100,y:100,deltaY:400});
|
|
27
|
+
document.querySelector('#refresh').onclick=refresh;
|
|
28
|
+
document.querySelector('#done').onclick=async()=>{try{await api('/done',{});stopped=true;screen.removeAttribute('src');text.value='';status.textContent='Sign-in window closed. You can return to your agent.';}catch(e){report(e);}};
|
|
29
|
+
refresh().then(frame);
|
|
30
|
+
</script></html>`;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import CDP from "chrome-remote-interface";
|
|
5
|
+
import { resolveBrowserConfig } from "../browser/config.js";
|
|
6
|
+
import { prepareAskProBrowserProfile } from "../browser/profileMigration.js";
|
|
7
|
+
import { resolveAskProAgentId } from "../browser/profilePaths.js";
|
|
8
|
+
import { acquireProfileRunLock, createManagedChromeRunLease, releaseManagedChromeRunLeaseAndCountPeers, writeChromePid, writeDevToolsActivePort, } from "../browser/profileState.js";
|
|
9
|
+
import { launchChrome, maybeReuseRunningChrome, releaseChromeProcessHandle, } from "../browser/chromeLifecycle.js";
|
|
10
|
+
import { loginPage } from "./loginPage.js";
|
|
11
|
+
const keys = new Set([
|
|
12
|
+
"Enter",
|
|
13
|
+
"Tab",
|
|
14
|
+
"Backspace",
|
|
15
|
+
"Delete",
|
|
16
|
+
"ArrowLeft",
|
|
17
|
+
"ArrowRight",
|
|
18
|
+
"ArrowUp",
|
|
19
|
+
"ArrowDown",
|
|
20
|
+
"Escape",
|
|
21
|
+
"Home",
|
|
22
|
+
"End",
|
|
23
|
+
]);
|
|
24
|
+
/** Narrow human-operated relay: no cookies, DOM, evaluation, or arbitrary CDP endpoint. */
|
|
25
|
+
export async function startLoginServer(options) {
|
|
26
|
+
const token = randomBytes(32).toString("hex");
|
|
27
|
+
let client;
|
|
28
|
+
let targetId;
|
|
29
|
+
let queue = Promise.resolve();
|
|
30
|
+
let finish;
|
|
31
|
+
const done = new Promise((resolve) => {
|
|
32
|
+
finish = resolve;
|
|
33
|
+
});
|
|
34
|
+
const targets = async () => (await CDP.List({ port: options.chromePort })).filter((t) => t.type === "page" && /^(https?:|about:blank)/.test(t.url));
|
|
35
|
+
async function connect(id) {
|
|
36
|
+
if (!(await targets()).some((t) => t.id === id))
|
|
37
|
+
throw Error("Unavailable tab");
|
|
38
|
+
if (targetId !== id || !client) {
|
|
39
|
+
await client?.close().catch(() => undefined);
|
|
40
|
+
client = await CDP({ port: options.chromePort, target: id });
|
|
41
|
+
targetId = id;
|
|
42
|
+
client.on("disconnect", () => {
|
|
43
|
+
if (targetId === id) {
|
|
44
|
+
client = undefined;
|
|
45
|
+
targetId = undefined;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return client;
|
|
50
|
+
}
|
|
51
|
+
const server = http.createServer((req, res) => {
|
|
52
|
+
res.setHeader("Cache-Control", "no-store");
|
|
53
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
54
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
55
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
56
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src blob:; connect-src 'self'; frame-ancestors 'none'; form-action 'none'");
|
|
57
|
+
if (req.method === "GET" && req.url === "/") {
|
|
58
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
59
|
+
res.end(loginPage);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (req.headers.authorization !== "Bearer " + token) {
|
|
63
|
+
res.writeHead(401).end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Reject cross-origin browser requests even when a caller has the link.
|
|
67
|
+
if (req.headers.origin && req.headers.origin !== "http://" + req.headers.host) {
|
|
68
|
+
res.writeHead(403).end();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const route = new URL(req.url ?? "/", "http://localhost");
|
|
72
|
+
const run = async () => {
|
|
73
|
+
if (req.method === "GET" && route.pathname === "/tabs") {
|
|
74
|
+
res.setHeader("Content-Type", "application/json");
|
|
75
|
+
res.end(JSON.stringify((await targets()).map((t) => ({
|
|
76
|
+
id: t.id,
|
|
77
|
+
name: t.url === "about:blank" ? "Blank tab" : new URL(t.url).hostname,
|
|
78
|
+
}))));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (req.method === "GET" && route.pathname === "/frame") {
|
|
82
|
+
const c = await connect(route.searchParams.get("target") ?? "");
|
|
83
|
+
// Reload cancels the image request. Release its pending CDP capture so
|
|
84
|
+
// it cannot hold the shared request queue and freeze the new viewer.
|
|
85
|
+
const cancel = () => {
|
|
86
|
+
if (!res.writableEnded) {
|
|
87
|
+
if (client === c) {
|
|
88
|
+
client = undefined;
|
|
89
|
+
targetId = undefined;
|
|
90
|
+
}
|
|
91
|
+
void c.close().catch(() => undefined);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
if (res.destroyed) {
|
|
95
|
+
cancel();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
res.once("close", cancel);
|
|
99
|
+
let frame;
|
|
100
|
+
try {
|
|
101
|
+
frame = await c.Page.captureScreenshot({ format: "jpeg", quality: 70 });
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
res.removeListener("close", cancel);
|
|
105
|
+
}
|
|
106
|
+
res.setHeader("Content-Type", "image/jpeg");
|
|
107
|
+
res.end(Buffer.from(frame.data, "base64"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (req.method !== "POST" || !["/input", "/done"].includes(route.pathname)) {
|
|
111
|
+
res.writeHead(404).end();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let raw = "";
|
|
115
|
+
for await (const chunk of req) {
|
|
116
|
+
raw += chunk;
|
|
117
|
+
if (Buffer.byteLength(raw) > 16384) {
|
|
118
|
+
res.writeHead(413).end();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (route.pathname === "/done") {
|
|
123
|
+
res.end();
|
|
124
|
+
setImmediate(() => {
|
|
125
|
+
void close();
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const body = JSON.parse(raw);
|
|
130
|
+
const c = await connect(typeof body.target === "string" ? body.target : "");
|
|
131
|
+
await c.Page.bringToFront();
|
|
132
|
+
const coordinate = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
133
|
+
if (body.kind === "text" && typeof body.text === "string")
|
|
134
|
+
await c.Input.insertText({ text: body.text });
|
|
135
|
+
else if (body.kind === "key" && typeof body.key === "string" && keys.has(body.key)) {
|
|
136
|
+
const key = body.key;
|
|
137
|
+
const code = {
|
|
138
|
+
Enter: 13,
|
|
139
|
+
Tab: 9,
|
|
140
|
+
Backspace: 8,
|
|
141
|
+
Delete: 46,
|
|
142
|
+
Escape: 27,
|
|
143
|
+
ArrowLeft: 37,
|
|
144
|
+
ArrowUp: 38,
|
|
145
|
+
ArrowRight: 39,
|
|
146
|
+
ArrowDown: 40,
|
|
147
|
+
Home: 36,
|
|
148
|
+
End: 35,
|
|
149
|
+
}[key];
|
|
150
|
+
await c.Input.dispatchKeyEvent({
|
|
151
|
+
type: "keyDown",
|
|
152
|
+
key,
|
|
153
|
+
code: key,
|
|
154
|
+
windowsVirtualKeyCode: code,
|
|
155
|
+
...(key === "Enter" ? { text: "\r" } : {}),
|
|
156
|
+
});
|
|
157
|
+
await c.Input.dispatchKeyEvent({
|
|
158
|
+
type: "keyUp",
|
|
159
|
+
key,
|
|
160
|
+
code: key,
|
|
161
|
+
windowsVirtualKeyCode: code,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else if ((body.kind === "click" || body.kind === "scroll") &&
|
|
165
|
+
coordinate(body.x) &&
|
|
166
|
+
coordinate(body.y)) {
|
|
167
|
+
const x = body.x, y = body.y;
|
|
168
|
+
if (body.kind === "click") {
|
|
169
|
+
await c.Input.dispatchMouseEvent({
|
|
170
|
+
type: "mousePressed",
|
|
171
|
+
x,
|
|
172
|
+
y,
|
|
173
|
+
button: "left",
|
|
174
|
+
clickCount: 1,
|
|
175
|
+
});
|
|
176
|
+
await c.Input.dispatchMouseEvent({
|
|
177
|
+
type: "mouseReleased",
|
|
178
|
+
x,
|
|
179
|
+
y,
|
|
180
|
+
button: "left",
|
|
181
|
+
clickCount: 1,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
else if (typeof body.deltaY === "number" && Number.isFinite(body.deltaY))
|
|
185
|
+
await c.Input.dispatchMouseEvent({
|
|
186
|
+
type: "mouseWheel",
|
|
187
|
+
x,
|
|
188
|
+
y,
|
|
189
|
+
deltaX: 0,
|
|
190
|
+
deltaY: body.deltaY,
|
|
191
|
+
});
|
|
192
|
+
else {
|
|
193
|
+
res.writeHead(400).end();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
res.writeHead(400).end();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
res.end();
|
|
202
|
+
};
|
|
203
|
+
queue = queue.then(run).catch(() => {
|
|
204
|
+
if (!res.headersSent)
|
|
205
|
+
res.writeHead(400);
|
|
206
|
+
res.end();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
let closed = false;
|
|
210
|
+
async function close() {
|
|
211
|
+
if (closed)
|
|
212
|
+
return;
|
|
213
|
+
closed = true;
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
server.close();
|
|
216
|
+
server.closeAllConnections();
|
|
217
|
+
await client?.close().catch(() => undefined);
|
|
218
|
+
finish();
|
|
219
|
+
}
|
|
220
|
+
await new Promise((resolve, reject) => {
|
|
221
|
+
server.once("error", reject);
|
|
222
|
+
server.listen(options.port ?? 0, options.host, resolve);
|
|
223
|
+
});
|
|
224
|
+
const timer = setTimeout(() => {
|
|
225
|
+
void close();
|
|
226
|
+
}, options.ttlMs ?? 15 * 60_000);
|
|
227
|
+
const address = server.address();
|
|
228
|
+
const hostname = options.host === "0.0.0.0" ? os.hostname() : options.host;
|
|
229
|
+
return { url: "http://" + hostname + ":" + address.port + "/#" + token, done, close };
|
|
230
|
+
}
|
|
231
|
+
export async function runRemoteLogin(onReady, savedProfile) {
|
|
232
|
+
const host = process.env.ASK_PRO_LOGIN_HOST?.trim();
|
|
233
|
+
if (!host)
|
|
234
|
+
throw Error("Set ASK_PRO_LOGIN_HOST to this machine’s private network IP (for example its Tailscale IP), then run --login.");
|
|
235
|
+
const profile = savedProfile ?? (await prepareAskProBrowserProfile(resolveAskProAgentId()));
|
|
236
|
+
const lock = await acquireProfileRunLock(profile, { timeoutMs: 1000 });
|
|
237
|
+
if (!lock)
|
|
238
|
+
throw Error("Browser busy. Wait for ask-pro runs to finish before --login.");
|
|
239
|
+
const lease = await createManagedChromeRunLease(profile);
|
|
240
|
+
let relay;
|
|
241
|
+
try {
|
|
242
|
+
// Count live automation leases while holding the launch lock; login never races a query.
|
|
243
|
+
const peers = await releaseManagedChromeRunLeaseAndCountPeers(profile, lease);
|
|
244
|
+
if (peers !== 0)
|
|
245
|
+
throw Error("Browser busy. Wait for ask-pro runs to finish before --login.");
|
|
246
|
+
const chrome = (await maybeReuseRunningChrome(profile, () => { })) ??
|
|
247
|
+
(await launchChrome(resolveBrowserConfig({ manualLogin: true }), profile, () => { }, {
|
|
248
|
+
ignoreLockId: lock.lockId,
|
|
249
|
+
}));
|
|
250
|
+
releaseChromeProcessHandle(chrome);
|
|
251
|
+
if (chrome.pid)
|
|
252
|
+
await writeChromePid(profile, chrome.pid);
|
|
253
|
+
await writeDevToolsActivePort(profile, chrome.port);
|
|
254
|
+
const pages = await CDP.List({ port: chrome.port });
|
|
255
|
+
if (!pages.some((t) => t.type === "page" && /^https:\/\/(chatgpt\.com|auth\.openai\.com)(\/|$)/.test(t.url))) {
|
|
256
|
+
await CDP.New({ port: chrome.port, url: "https://chatgpt.com/" });
|
|
257
|
+
}
|
|
258
|
+
relay = await startLoginServer({ chromePort: chrome.port, host });
|
|
259
|
+
const stop = () => {
|
|
260
|
+
void relay?.close();
|
|
261
|
+
};
|
|
262
|
+
process.once("SIGINT", stop);
|
|
263
|
+
process.once("SIGTERM", stop);
|
|
264
|
+
try {
|
|
265
|
+
onReady(relay.url);
|
|
266
|
+
await relay.done;
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
process.removeListener("SIGINT", stop);
|
|
270
|
+
process.removeListener("SIGTERM", stop);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
await relay?.close();
|
|
275
|
+
await releaseManagedChromeRunLeaseAndCountPeers(profile, lease);
|
|
276
|
+
await lock.release();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -12,6 +12,8 @@ import { clearChromeLaunchStarting, clearIdleBrowserProfileCaches, cleanupStaleP
|
|
|
12
12
|
import { delay } from "./utils.js";
|
|
13
13
|
import { formatElapsed } from "./format.js";
|
|
14
14
|
import { withTimeout } from "./reattachHelpers.js";
|
|
15
|
+
import { isHeadlessLinux } from "./config.js";
|
|
16
|
+
import { spawnChromeWithVirtualDisplay } from "./linuxChrome.js";
|
|
15
17
|
const execFileAsync = promisify(execFile);
|
|
16
18
|
const CPU_THROTTLING_OVERRIDE_FLAGS = new Set([
|
|
17
19
|
"--disable-backgrounding-occluded-windows",
|
|
@@ -747,7 +749,9 @@ export function spawnWindowsProcessOutsideJob(command, args, options) {
|
|
|
747
749
|
}
|
|
748
750
|
const spawnManagedChrome = ((command, args, options) => process.platform === "win32"
|
|
749
751
|
? spawnWindowsProcessOutsideJob(command, args, options)
|
|
750
|
-
:
|
|
752
|
+
: isHeadlessLinux() && !args.some((arg) => arg.startsWith("--headless"))
|
|
753
|
+
? spawnChromeWithVirtualDisplay(command, args, options)
|
|
754
|
+
: spawn(command, args, options));
|
|
751
755
|
async function launchManagedChrome({ chromeFlags, chromePath, userDataDir, host, requestedPort, }) {
|
|
752
756
|
const launcher = new Launcher({
|
|
753
757
|
chromePath: chromePath ?? undefined,
|
|
@@ -2,6 +2,9 @@ import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./con
|
|
|
2
2
|
import { normalizeBrowserModelStrategy } from "./modelStrategy.js";
|
|
3
3
|
import { defaultAskProBrowserProfileDir } from "./profilePaths.js";
|
|
4
4
|
import { normalizeChatgptUrl } from "./utils.js";
|
|
5
|
+
export function isHeadlessLinux(platform = process.platform, env = process.env) {
|
|
6
|
+
return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
7
|
+
}
|
|
5
8
|
export const DEFAULT_BROWSER_CONFIG = {
|
|
6
9
|
chromeProfile: null,
|
|
7
10
|
chromePath: null,
|
|
@@ -76,14 +79,14 @@ export function resolveBrowserConfig(config) {
|
|
|
76
79
|
cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,
|
|
77
80
|
inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
|
|
78
81
|
inlineCookiesSource: config?.inlineCookiesSource ?? DEFAULT_BROWSER_CONFIG.inlineCookiesSource,
|
|
79
|
-
headless: config?.headless ??
|
|
82
|
+
headless: config?.headless ?? false,
|
|
80
83
|
keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
|
|
81
84
|
hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
|
|
82
85
|
startMinimized: config?.startMinimized ?? DEFAULT_BROWSER_CONFIG.startMinimized,
|
|
83
86
|
desiredModel,
|
|
84
87
|
modelStrategy,
|
|
85
88
|
chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
|
|
86
|
-
chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
|
|
89
|
+
chromePath: config?.chromePath ?? process.env.CHROME_PATH ?? DEFAULT_BROWSER_CONFIG.chromePath,
|
|
87
90
|
chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
|
|
88
91
|
attachRunning: config?.attachRunning ?? DEFAULT_BROWSER_CONFIG.attachRunning,
|
|
89
92
|
browserTabRef: config?.browserTabRef ?? DEFAULT_BROWSER_CONFIG.browserTabRef,
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
import CDP from "chrome-remote-interface";
|
|
6
|
-
import { DEFAULT_BROWSER_CONFIG, resolveBrowserConfig } from "./config.js";
|
|
6
|
+
import { DEFAULT_BROWSER_CONFIG, resolveBrowserConfig, isHeadlessLinux } from "./config.js";
|
|
7
7
|
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeChromeGracefully, listRemoteChromeTargets, maybeReuseRunningChrome, releaseChromeProcessHandle, restoreChromeWindowByPid, shouldLaunchChromeMinimized, } from "./chromeLifecycle.js";
|
|
8
8
|
import { syncCookies } from "./cookies.js";
|
|
9
9
|
import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
|
|
@@ -1346,7 +1346,7 @@ export async function runBrowserMode(options) {
|
|
|
1346
1346
|
? "ChatGPT login required; leaving browser open so you can sign in."
|
|
1347
1347
|
: "Cloudflare challenge detected; leaving browser open so you can complete the check.");
|
|
1348
1348
|
logger(`Reuse this browser profile with: ${reuseProfileHint}`);
|
|
1349
|
-
if (isLoginRequired && manualLogin) {
|
|
1349
|
+
if (isLoginRequired && manualLogin && !isHeadlessLinux()) {
|
|
1350
1350
|
const recoveryRuntime = client?.Runtime;
|
|
1351
1351
|
if (!recoveryRuntime) {
|
|
1352
1352
|
throw normalizedError;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
// A separate supervisor outlives the controller when a browser is kept for resume.
|
|
8
|
+
// Its PID represents the managed browser lifetime, including its private display.
|
|
9
|
+
export function spawnChromeWithVirtualDisplay(command, args, options) {
|
|
10
|
+
const check = spawnSync("Xvfb", ["-help"], { stdio: "ignore" });
|
|
11
|
+
if (check.error)
|
|
12
|
+
throw Error("Xvfb is required on Linux without a display. Run: sudo apt install xvfb");
|
|
13
|
+
return spawn(process.execPath, [fileURLToPath(import.meta.url), "--supervise", command, ...args], options);
|
|
14
|
+
}
|
|
15
|
+
async function supervise() {
|
|
16
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "ask-pro-display-"));
|
|
17
|
+
const authority = path.join(directory, "Xauthority");
|
|
18
|
+
const cookie = randomBytes(16);
|
|
19
|
+
const writeAuthority = async (number) => {
|
|
20
|
+
const fields = [
|
|
21
|
+
Buffer.alloc(0),
|
|
22
|
+
Buffer.from(number),
|
|
23
|
+
Buffer.from("MIT-MAGIC-COOKIE-1"),
|
|
24
|
+
cookie,
|
|
25
|
+
];
|
|
26
|
+
const records = fields.map((field) => {
|
|
27
|
+
const length = Buffer.alloc(2);
|
|
28
|
+
length.writeUInt16BE(field.length);
|
|
29
|
+
return Buffer.concat([length, field]);
|
|
30
|
+
});
|
|
31
|
+
await writeFile(authority, Buffer.concat([Buffer.from([255, 255]), ...records]), {
|
|
32
|
+
mode: 0o600,
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
await writeAuthority("");
|
|
36
|
+
const display = spawn("Xvfb", ["-displayfd", "3", "-screen", "0", "1280x800x24", "-nolisten", "tcp", "-auth", authority], {
|
|
37
|
+
stdio: ["ignore", "ignore", "inherit", "pipe"],
|
|
38
|
+
});
|
|
39
|
+
let chrome;
|
|
40
|
+
const stop = () => {
|
|
41
|
+
if (chrome)
|
|
42
|
+
chrome.kill("SIGTERM");
|
|
43
|
+
else
|
|
44
|
+
display.kill();
|
|
45
|
+
};
|
|
46
|
+
process.on("SIGTERM", stop);
|
|
47
|
+
process.on("SIGINT", stop);
|
|
48
|
+
let timer;
|
|
49
|
+
try {
|
|
50
|
+
const number = await new Promise((resolve, reject) => {
|
|
51
|
+
timer = setTimeout(() => reject(Error("Xvfb did not start within 10 seconds")), 10_000);
|
|
52
|
+
let output = "";
|
|
53
|
+
display.once("error", reject);
|
|
54
|
+
display.once("exit", () => reject(Error("Xvfb exited before Chrome started")));
|
|
55
|
+
display.stdio[3].on("data", (chunk) => {
|
|
56
|
+
output += String(chunk);
|
|
57
|
+
if (/^\d+\n$/.test(output))
|
|
58
|
+
resolve(output.trim());
|
|
59
|
+
});
|
|
60
|
+
}).finally(() => clearTimeout(timer));
|
|
61
|
+
await writeAuthority(number);
|
|
62
|
+
chrome = spawn(process.argv[3], process.argv.slice(4), {
|
|
63
|
+
env: { ...process.env, DISPLAY: `:${number}`, XAUTHORITY: authority },
|
|
64
|
+
stdio: "inherit",
|
|
65
|
+
});
|
|
66
|
+
display.once("exit", () => chrome?.kill("SIGTERM"));
|
|
67
|
+
process.exitCode = await new Promise((resolve, reject) => {
|
|
68
|
+
chrome.once("error", reject);
|
|
69
|
+
chrome.once("exit", (code) => resolve(code ?? 1));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
display.kill();
|
|
74
|
+
await rm(directory, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (process.argv[2] === "--supervise") {
|
|
78
|
+
await supervise().catch((error) => {
|
|
79
|
+
console.error(error.message);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
});
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ask-pro",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.93",
|
|
4
4
|
"description": "Browser-backed ChatGPT Pro escalation for hard engineering questions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"dist/src/browser/attachRunning.js",
|
|
30
30
|
"dist/src/browser/chatgptModelCatalog.js",
|
|
31
31
|
"dist/src/browser/chromeLifecycle.js",
|
|
32
|
+
"dist/src/browser/linuxChrome.js",
|
|
32
33
|
"dist/src/browser/config.js",
|
|
33
34
|
"dist/src/browser/constants.js",
|
|
34
35
|
"dist/src/browser/cookies.js",
|