ask-pro 0.2.78 → 0.2.87
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 +9 -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 +29 -0
- package/dist/src/ask-pro/remoteLogin.js +254 -0
- package/dist/src/browser/chromeLifecycle.js +4 -2
- package/dist/src/browser/config.js +5 -2
- package/dist/src/browser/index.js +1 -1
- package/dist/src/browser/profileState.js +23 -0
- package/dist/src/browser/reattach.js +3 -1
- package/package.json +1 -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,15 @@ 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 headless Linux host, set `CHROME_PATH` if Chrome is not discovered, and
|
|
15
|
+
`ASK_PRO_LOGIN_HOST` to the host's private Tailscale IP. Before the first query,
|
|
16
|
+
run `ask-pro --login` and give the human its `login_url`. Keep that command alive
|
|
17
|
+
while they sign in from their desktop or phone. The link expires after 15 minutes
|
|
18
|
+
or Done. Never drive the login page or capture its frames during real sign-in.
|
|
19
|
+
After the human confirms the ChatGPT composer is visible, resume the saved
|
|
20
|
+
session or start the requested query. A later `needs_auth` result includes a
|
|
21
|
+
`login` command for the same profile. See [Linux setup](docs/linux.md).
|
|
22
|
+
|
|
14
23
|
This skill folder contains ask-pro itself; nothing else needs installing. In
|
|
15
24
|
the commands below, `ask-pro` stands for:
|
|
16
25
|
|
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,29 @@
|
|
|
1
|
+
// The browser relays human input only. Never persist input, frames, or the access key.
|
|
2
|
+
export const loginPage = `<!doctype html>
|
|
3
|
+
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
+
<title>ask-pro sign in</title>
|
|
5
|
+
<style>
|
|
6
|
+
: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}
|
|
7
|
+
</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>
|
|
8
|
+
<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.">
|
|
9
|
+
<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>
|
|
10
|
+
<script>
|
|
11
|
+
const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);
|
|
12
|
+
const screen=document.querySelector('#screen'),tabs=document.querySelector('#tabs'),status=document.querySelector('#status'),text=document.querySelector('#text');
|
|
13
|
+
let stopped=false,frameUrl,inputQueue=Promise.resolve();
|
|
14
|
+
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;}
|
|
15
|
+
function report(e){status.textContent=e.message;}
|
|
16
|
+
function input(body){const target=tabs.value;const request=inputQueue.then(()=>api('/input',{target,...body}));inputQueue=request.catch(report);return request;}
|
|
17
|
+
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);}}
|
|
18
|
+
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);}
|
|
19
|
+
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();};
|
|
20
|
+
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});}};
|
|
21
|
+
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});};
|
|
22
|
+
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);}};
|
|
23
|
+
for(const key of ['Enter','Tab','Backspace'])document.querySelector('#'+key.toLowerCase()).onclick=()=>input({kind:'key',key});
|
|
24
|
+
document.querySelector('#up').onclick=()=>input({kind:'scroll',x:100,y:100,deltaY:-400});
|
|
25
|
+
document.querySelector('#down').onclick=()=>input({kind:'scroll',x:100,y:100,deltaY:400});
|
|
26
|
+
document.querySelector('#refresh').onclick=refresh;
|
|
27
|
+
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);}};
|
|
28
|
+
refresh().then(frame);
|
|
29
|
+
</script></html>`;
|
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
const frame = await c.Page.captureScreenshot({ format: "jpeg", quality: 70 });
|
|
84
|
+
res.setHeader("Content-Type", "image/jpeg");
|
|
85
|
+
res.end(Buffer.from(frame.data, "base64"));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (req.method !== "POST" || !["/input", "/done"].includes(route.pathname)) {
|
|
89
|
+
res.writeHead(404).end();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let raw = "";
|
|
93
|
+
for await (const chunk of req) {
|
|
94
|
+
raw += chunk;
|
|
95
|
+
if (Buffer.byteLength(raw) > 16384) {
|
|
96
|
+
res.writeHead(413).end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (route.pathname === "/done") {
|
|
101
|
+
res.end();
|
|
102
|
+
setImmediate(() => {
|
|
103
|
+
void close();
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const body = JSON.parse(raw);
|
|
108
|
+
const c = await connect(typeof body.target === "string" ? body.target : "");
|
|
109
|
+
await c.Page.bringToFront();
|
|
110
|
+
const coordinate = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
111
|
+
if (body.kind === "text" && typeof body.text === "string")
|
|
112
|
+
await c.Input.insertText({ text: body.text });
|
|
113
|
+
else if (body.kind === "key" && typeof body.key === "string" && keys.has(body.key)) {
|
|
114
|
+
const key = body.key;
|
|
115
|
+
const code = {
|
|
116
|
+
Enter: 13,
|
|
117
|
+
Tab: 9,
|
|
118
|
+
Backspace: 8,
|
|
119
|
+
Delete: 46,
|
|
120
|
+
Escape: 27,
|
|
121
|
+
ArrowLeft: 37,
|
|
122
|
+
ArrowUp: 38,
|
|
123
|
+
ArrowRight: 39,
|
|
124
|
+
ArrowDown: 40,
|
|
125
|
+
Home: 36,
|
|
126
|
+
End: 35,
|
|
127
|
+
}[key];
|
|
128
|
+
await c.Input.dispatchKeyEvent({
|
|
129
|
+
type: "keyDown",
|
|
130
|
+
key,
|
|
131
|
+
code: key,
|
|
132
|
+
windowsVirtualKeyCode: code,
|
|
133
|
+
...(key === "Enter" ? { text: "\r" } : {}),
|
|
134
|
+
});
|
|
135
|
+
await c.Input.dispatchKeyEvent({
|
|
136
|
+
type: "keyUp",
|
|
137
|
+
key,
|
|
138
|
+
code: key,
|
|
139
|
+
windowsVirtualKeyCode: code,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else if ((body.kind === "click" || body.kind === "scroll") &&
|
|
143
|
+
coordinate(body.x) &&
|
|
144
|
+
coordinate(body.y)) {
|
|
145
|
+
const x = body.x, y = body.y;
|
|
146
|
+
if (body.kind === "click") {
|
|
147
|
+
await c.Input.dispatchMouseEvent({
|
|
148
|
+
type: "mousePressed",
|
|
149
|
+
x,
|
|
150
|
+
y,
|
|
151
|
+
button: "left",
|
|
152
|
+
clickCount: 1,
|
|
153
|
+
});
|
|
154
|
+
await c.Input.dispatchMouseEvent({
|
|
155
|
+
type: "mouseReleased",
|
|
156
|
+
x,
|
|
157
|
+
y,
|
|
158
|
+
button: "left",
|
|
159
|
+
clickCount: 1,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else if (typeof body.deltaY === "number" && Number.isFinite(body.deltaY))
|
|
163
|
+
await c.Input.dispatchMouseEvent({
|
|
164
|
+
type: "mouseWheel",
|
|
165
|
+
x,
|
|
166
|
+
y,
|
|
167
|
+
deltaX: 0,
|
|
168
|
+
deltaY: body.deltaY,
|
|
169
|
+
});
|
|
170
|
+
else {
|
|
171
|
+
res.writeHead(400).end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
res.writeHead(400).end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
res.end();
|
|
180
|
+
};
|
|
181
|
+
queue = queue.then(run).catch(() => {
|
|
182
|
+
if (!res.headersSent)
|
|
183
|
+
res.writeHead(400);
|
|
184
|
+
res.end();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
let closed = false;
|
|
188
|
+
async function close() {
|
|
189
|
+
if (closed)
|
|
190
|
+
return;
|
|
191
|
+
closed = true;
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
server.close();
|
|
194
|
+
server.closeAllConnections();
|
|
195
|
+
await client?.close().catch(() => undefined);
|
|
196
|
+
finish();
|
|
197
|
+
}
|
|
198
|
+
await new Promise((resolve, reject) => {
|
|
199
|
+
server.once("error", reject);
|
|
200
|
+
server.listen(options.port ?? 0, options.host, resolve);
|
|
201
|
+
});
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
void close();
|
|
204
|
+
}, options.ttlMs ?? 15 * 60_000);
|
|
205
|
+
const address = server.address();
|
|
206
|
+
const hostname = options.host === "0.0.0.0" ? os.hostname() : options.host;
|
|
207
|
+
return { url: "http://" + hostname + ":" + address.port + "/#" + token, done, close };
|
|
208
|
+
}
|
|
209
|
+
export async function runRemoteLogin(onReady, savedProfile) {
|
|
210
|
+
const host = process.env.ASK_PRO_LOGIN_HOST?.trim();
|
|
211
|
+
if (!host)
|
|
212
|
+
throw Error("Set ASK_PRO_LOGIN_HOST to this machine’s private network IP (for example its Tailscale IP), then run --login.");
|
|
213
|
+
const profile = savedProfile ?? (await prepareAskProBrowserProfile(resolveAskProAgentId()));
|
|
214
|
+
const lock = await acquireProfileRunLock(profile, { timeoutMs: 1000 });
|
|
215
|
+
if (!lock)
|
|
216
|
+
throw Error("Browser busy. Wait for ask-pro runs to finish before --login.");
|
|
217
|
+
const lease = await createManagedChromeRunLease(profile);
|
|
218
|
+
let relay;
|
|
219
|
+
try {
|
|
220
|
+
// Count live automation leases while holding the launch lock; login never races a query.
|
|
221
|
+
const peers = await releaseManagedChromeRunLeaseAndCountPeers(profile, lease);
|
|
222
|
+
if (peers !== 0)
|
|
223
|
+
throw Error("Browser busy. Wait for ask-pro runs to finish before --login.");
|
|
224
|
+
const chrome = (await maybeReuseRunningChrome(profile, () => { })) ??
|
|
225
|
+
(await launchChrome(resolveBrowserConfig({ manualLogin: true, headless: true }), profile, () => { }, { ignoreLockId: lock.lockId }));
|
|
226
|
+
releaseChromeProcessHandle(chrome);
|
|
227
|
+
if (chrome.pid)
|
|
228
|
+
await writeChromePid(profile, chrome.pid);
|
|
229
|
+
await writeDevToolsActivePort(profile, chrome.port);
|
|
230
|
+
const pages = await CDP.List({ port: chrome.port });
|
|
231
|
+
if (!pages.some((t) => t.type === "page" && /^https:\/\/(chatgpt\.com|auth\.openai\.com)(\/|$)/.test(t.url))) {
|
|
232
|
+
await CDP.New({ port: chrome.port, url: "https://chatgpt.com/" });
|
|
233
|
+
}
|
|
234
|
+
relay = await startLoginServer({ chromePort: chrome.port, host });
|
|
235
|
+
const stop = () => {
|
|
236
|
+
void relay?.close();
|
|
237
|
+
};
|
|
238
|
+
process.once("SIGINT", stop);
|
|
239
|
+
process.once("SIGTERM", stop);
|
|
240
|
+
try {
|
|
241
|
+
onReady(relay.url);
|
|
242
|
+
await relay.done;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
process.removeListener("SIGINT", stop);
|
|
246
|
+
process.removeListener("SIGTERM", stop);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await relay?.close();
|
|
251
|
+
await releaseManagedChromeRunLeaseAndCountPeers(profile, lease);
|
|
252
|
+
await lock.release();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import CDP from "chrome-remote-interface";
|
|
10
10
|
import { Launcher } from "chrome-launcher";
|
|
11
|
-
import { clearChromeLaunchStarting, cleanupStaleProfileState, hasChromeLaunchStarting, markChromeLaunchStarting, readChromePid, readDevToolsPort, verifyDevToolsReachable, } from "./profileState.js";
|
|
11
|
+
import { clearChromeLaunchStarting, clearIdleBrowserProfileCaches, cleanupStaleProfileState, hasChromeLaunchStarting, markChromeLaunchStarting, readChromePid, readDevToolsPort, verifyDevToolsReachable, } from "./profileState.js";
|
|
12
12
|
import { delay } from "./utils.js";
|
|
13
13
|
import { formatElapsed } from "./format.js";
|
|
14
14
|
import { withTimeout } from "./reattachHelpers.js";
|
|
@@ -20,13 +20,14 @@ const CPU_THROTTLING_OVERRIDE_FLAGS = new Set([
|
|
|
20
20
|
"--disable-ipc-flooding-protection",
|
|
21
21
|
]);
|
|
22
22
|
const CHROME_STARTUP_HANDOFF_MS = 26_000;
|
|
23
|
-
export async function launchChrome(config, userDataDir, logger) {
|
|
23
|
+
export async function launchChrome(config, userDataDir, logger, options = {}) {
|
|
24
24
|
const connectHost = resolveRemoteDebugHost();
|
|
25
25
|
const debugBindAddress = connectHost && connectHost !== "127.0.0.1" ? "0.0.0.0" : connectHost;
|
|
26
26
|
const debugPort = config.debugPort ?? parseDebugPortEnv();
|
|
27
27
|
const chromeFlags = buildChromeLaunchFlags(buildChromeFlags(config.headless ?? false, debugBindAddress, config.acceptLanguage, {
|
|
28
28
|
startMinimized: shouldLaunchChromeMinimized(config),
|
|
29
29
|
}));
|
|
30
|
+
await clearIdleBrowserProfileCaches(userDataDir, options);
|
|
30
31
|
await markChromeLaunchStarting(userDataDir);
|
|
31
32
|
try {
|
|
32
33
|
const launcher = await launchManagedChrome({
|
|
@@ -581,6 +582,7 @@ export function buildChromeFlags(headless, debugBindAddress, acceptLanguage = "e
|
|
|
581
582
|
const primaryLanguage = acceptLanguage.split(",", 1)[0]?.trim() || "en-US";
|
|
582
583
|
const flags = [
|
|
583
584
|
"--disable-background-networking",
|
|
585
|
+
"--disk-cache-size=16777216",
|
|
584
586
|
"--disable-breakpad",
|
|
585
587
|
"--disable-client-side-phishing-detection",
|
|
586
588
|
"--disable-default-apps",
|
|
@@ -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 ?? isHeadlessLinux(),
|
|
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,
|
|
@@ -394,7 +394,7 @@ export async function runBrowserMode(options) {
|
|
|
394
394
|
(await launchChrome({
|
|
395
395
|
...config,
|
|
396
396
|
remoteChrome: config.remoteChrome,
|
|
397
|
-
}, userDataDir, logger));
|
|
397
|
+
}, userDataDir, logger, { ignoreLockId: profileLock?.lockId }));
|
|
398
398
|
// Persist profile state so future manual-login runs can reuse this Chrome.
|
|
399
399
|
if (manualLogin && chrome.port) {
|
|
400
400
|
await writeDevToolsActivePort(userDataDir, chrome.port);
|
|
@@ -182,6 +182,16 @@ export async function isBrowserProfileInUse(userDataDir, options = {}) {
|
|
|
182
182
|
const lock = parseProfileRunLock(raw);
|
|
183
183
|
if (lock && lock.lockId !== options.ignoreLockId && isProcessAlive(lock.pid))
|
|
184
184
|
return true;
|
|
185
|
+
try {
|
|
186
|
+
const leases = await readdir(path.join(userDataDir, ASK_PRO_RUN_LEASE_DIRNAME));
|
|
187
|
+
if (leases.some((name) => name.endsWith(".lease") && isProcessAlive(Number(name.match(/^(\d+)-/)?.[1])))) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error.code !== "ENOENT")
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
185
195
|
const pid = await readChromePid(userDataDir);
|
|
186
196
|
const port = await readDevToolsPort(userDataDir);
|
|
187
197
|
const [owner, devTools] = await Promise.all([
|
|
@@ -192,6 +202,19 @@ export async function isBrowserProfileInUse(userDataDir, options = {}) {
|
|
|
192
202
|
return true;
|
|
193
203
|
return owner === null;
|
|
194
204
|
}
|
|
205
|
+
// Call while holding the profile's launch lock; only disposable caches are removed.
|
|
206
|
+
export async function clearIdleBrowserProfileCaches(userDataDir, options = {}) {
|
|
207
|
+
try {
|
|
208
|
+
if (await isBrowserProfileInUse(userDataDir, options))
|
|
209
|
+
return;
|
|
210
|
+
for (const name of ["Cache", "Code Cache"]) {
|
|
211
|
+
await rm(path.join(userDataDir, "Default", name), { recursive: true, force: true }).catch(() => undefined);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Cache maintenance must never prevent a browser run.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
195
218
|
export async function acquireProfileRunLock(userDataDir, options) {
|
|
196
219
|
const timeoutMs = options.timeoutMs;
|
|
197
220
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
@@ -355,7 +355,9 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
|
|
|
355
355
|
if (!reusedChrome) {
|
|
356
356
|
await seedChromeProfileLanguage(userDataDir, resolved.acceptLanguage, logger);
|
|
357
357
|
}
|
|
358
|
-
chrome =
|
|
358
|
+
chrome =
|
|
359
|
+
reusedChrome ??
|
|
360
|
+
(await launchChrome(resolved, userDataDir, logger, { ignoreLockId: launchLock?.lockId }));
|
|
359
361
|
if (manualLogin) {
|
|
360
362
|
reattachRunLease = {
|
|
361
363
|
userDataDir,
|