ask-pro 0.1.0
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/.codex-plugin/plugin.json +30 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/assets/ask-pro_logo.png +0 -0
- package/dist/bin/ask-pro-cli.js +507 -0
- package/dist/scripts/run-cli.js +27 -0
- package/dist/src/ask-pro/atomicWrite.js +26 -0
- package/dist/src/ask-pro/browserRunner.js +796 -0
- package/dist/src/ask-pro/responseZip.js +349 -0
- package/dist/src/ask-pro/session.js +662 -0
- package/dist/src/ask-pro/sessionControllerLease.js +64 -0
- package/dist/src/ask-pro/toon.js +26 -0
- package/dist/src/ask-pro/zip.js +85 -0
- package/dist/src/browser/actions/assistantResponse.js +1245 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +140 -0
- package/dist/src/browser/actions/attachments.js +1720 -0
- package/dist/src/browser/actions/composerSendReadiness.js +369 -0
- package/dist/src/browser/actions/domEvents.js +31 -0
- package/dist/src/browser/actions/inputGuard.js +52 -0
- package/dist/src/browser/actions/modelPickerDom.js +68 -0
- package/dist/src/browser/actions/modelSelection.js +576 -0
- package/dist/src/browser/actions/navigation.js +510 -0
- package/dist/src/browser/actions/promptComposer.js +824 -0
- package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
- package/dist/src/browser/actions/thinkingStatus.js +408 -0
- package/dist/src/browser/actions/thinkingTime.js +635 -0
- package/dist/src/browser/actions/windowState.js +47 -0
- package/dist/src/browser/attachRunning.js +31 -0
- package/dist/src/browser/chatgptModelCatalog.js +321 -0
- package/dist/src/browser/chromeLifecycle.js +807 -0
- package/dist/src/browser/config.js +110 -0
- package/dist/src/browser/constants.js +85 -0
- package/dist/src/browser/cookies.js +191 -0
- package/dist/src/browser/detect.js +337 -0
- package/dist/src/browser/domDebug.js +72 -0
- package/dist/src/browser/errors.js +20 -0
- package/dist/src/browser/format.js +16 -0
- package/dist/src/browser/index.js +2631 -0
- package/dist/src/browser/language.js +97 -0
- package/dist/src/browser/liveTabs.js +434 -0
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/profilePaths.js +282 -0
- package/dist/src/browser/profileState.js +413 -0
- package/dist/src/browser/providerDomFlow.js +17 -0
- package/dist/src/browser/providers/chatgptDomProvider.js +50 -0
- package/dist/src/browser/reattach.js +534 -0
- package/dist/src/browser/reattachHelpers.js +387 -0
- package/dist/src/browser/utils.js +122 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/version.js +39 -0
- package/package.json +114 -0
- package/scripts/refresh-local-plugin.mjs +179 -0
- package/scripts/refresh-local-plugin.ps1 +93 -0
- package/skills/ask-pro/SKILL.md +181 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { acquireProfileRunLock, isBrowserProfileInUse, isProcessAlive, releaseProfileRunLock, } from "./profileState.js";
|
|
6
|
+
const RESOLVED_AGENT_ID_PATTERN = /^[a-z0-9._-]+-[a-f0-9]{10}$/;
|
|
7
|
+
const MIGRATION_MARKER = ".ask-pro-profile-migration";
|
|
8
|
+
export function defaultAskProBrowserProfileDir() {
|
|
9
|
+
return askProBrowserProfileDirForAgentId(null);
|
|
10
|
+
}
|
|
11
|
+
export function askProBrowserProfileDirForAgentId(agentId, env = process.env) {
|
|
12
|
+
return profileDirUnder(askProStateDir(env), agentId);
|
|
13
|
+
}
|
|
14
|
+
export function legacyAskProBrowserProfileDirForAgentId(agentId, homeDir = os.homedir()) {
|
|
15
|
+
return profileDirUnder(path.join(homeDir, ".agents", "skills", "ask-pro"), agentId);
|
|
16
|
+
}
|
|
17
|
+
export async function ensureAskProBrowserProfileDir(agentId, options = {}) {
|
|
18
|
+
const target = askProBrowserProfileDirForAgentId(agentId, options.env);
|
|
19
|
+
const legacy = legacyAskProBrowserProfileDirForAgentId(agentId, options.homeDir);
|
|
20
|
+
const [targetExists, legacyExists] = await Promise.all([exists(target), exists(legacy)]);
|
|
21
|
+
if (targetExists && legacyExists) {
|
|
22
|
+
const recordedIdentity = await retainedLegacyIdentity(target);
|
|
23
|
+
if (recordedIdentity) {
|
|
24
|
+
const currentIdentity = await legacyProfileIdentity(legacy);
|
|
25
|
+
if (currentIdentity && sameLegacyIdentity(currentIdentity, recordedIdentity)) {
|
|
26
|
+
if (await waitForConcurrentMigration(target, legacy))
|
|
27
|
+
return target;
|
|
28
|
+
if (await isBrowserProfileInUse(legacy)) {
|
|
29
|
+
throw new Error("Legacy ask-pro browser profile is in use; retry after its browser run exits.");
|
|
30
|
+
}
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`Both current and legacy ask-pro browser profiles exist; refusing to merge ${legacy} into ${target}.`);
|
|
34
|
+
}
|
|
35
|
+
if (await waitForConcurrentMigration(target, legacy))
|
|
36
|
+
return target;
|
|
37
|
+
if ((await exists(target)) && !(await exists(legacy)))
|
|
38
|
+
return target;
|
|
39
|
+
throw new Error(`Both current and legacy ask-pro browser profiles exist; refusing to merge ${legacy} into ${target}.`);
|
|
40
|
+
}
|
|
41
|
+
if (targetExists || !legacyExists)
|
|
42
|
+
return target;
|
|
43
|
+
if (await isBrowserProfileInUse(legacy)) {
|
|
44
|
+
throw new Error("Legacy ask-pro browser profile is in use; retry after its browser run exits.");
|
|
45
|
+
}
|
|
46
|
+
let migrationLock;
|
|
47
|
+
try {
|
|
48
|
+
migrationLock = await acquireProfileRunLock(legacy, {
|
|
49
|
+
timeoutMs: 300_000,
|
|
50
|
+
pollMs: 100,
|
|
51
|
+
requireExistingProfile: true,
|
|
52
|
+
staleLockMode: "fail",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if ((await exists(target)) && !(await exists(legacy)))
|
|
57
|
+
return target;
|
|
58
|
+
throw new Error("Legacy ask-pro browser profile could not be claimed; retry after other browser runs exit.", { cause: error });
|
|
59
|
+
}
|
|
60
|
+
if (!migrationLock)
|
|
61
|
+
throw new Error("Could not claim the legacy ask-pro browser profile.");
|
|
62
|
+
let releasePath = migrationLock.path;
|
|
63
|
+
try {
|
|
64
|
+
if (await matchesRetainedLegacy(target, legacy))
|
|
65
|
+
return target;
|
|
66
|
+
if (await isBrowserProfileInUse(legacy, { ignoreLockId: migrationLock.lockId })) {
|
|
67
|
+
throw new Error("Legacy ask-pro browser profile is in use; retry after its browser run exits.");
|
|
68
|
+
}
|
|
69
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
70
|
+
let identityRequired = false;
|
|
71
|
+
try {
|
|
72
|
+
await fs.rename(legacy, target);
|
|
73
|
+
releasePath = path.join(target, path.basename(migrationLock.path));
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const code = error.code;
|
|
78
|
+
if (code !== "EXDEV" && !isSharingDenied(error)) {
|
|
79
|
+
if ((await exists(target)) && !(await exists(legacy)))
|
|
80
|
+
return target;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
identityRequired = code !== "EXDEV";
|
|
84
|
+
}
|
|
85
|
+
const legacyIdentity = await legacyProfileIdentity(legacy);
|
|
86
|
+
if (identityRequired && !legacyIdentity) {
|
|
87
|
+
throw new Error("Legacy ask-pro browser profile identity is unavailable; refusing to copy.");
|
|
88
|
+
}
|
|
89
|
+
const staging = `${target}.migrating-${process.pid}-${randomUUID()}`;
|
|
90
|
+
try {
|
|
91
|
+
await fs.cp(legacy, staging, { recursive: true, errorOnExist: true });
|
|
92
|
+
await fs.writeFile(path.join(staging, MIGRATION_MARKER), JSON.stringify({
|
|
93
|
+
pid: process.pid,
|
|
94
|
+
createdAt: Date.now(),
|
|
95
|
+
migrationLockId: migrationLock.lockId,
|
|
96
|
+
...(legacyIdentity ? { legacyIdentity } : {}),
|
|
97
|
+
}));
|
|
98
|
+
await fs.rename(staging, target);
|
|
99
|
+
releasePath = path.join(target, path.basename(migrationLock.path));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
103
|
+
if ((await exists(target)) && !(await exists(legacy)))
|
|
104
|
+
return target;
|
|
105
|
+
if (await matchesRetainedLegacy(target, legacy))
|
|
106
|
+
return target;
|
|
107
|
+
if (await waitForConcurrentMigration(target, legacy))
|
|
108
|
+
return target;
|
|
109
|
+
if ((await exists(target)) && !(await exists(legacy)))
|
|
110
|
+
return target;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await fs.rm(legacy, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
await releaseProfileRunLock(migrationLock.path, migrationLock.lockId);
|
|
118
|
+
if (isSharingDenied(error) && legacyIdentity)
|
|
119
|
+
return target;
|
|
120
|
+
await fs.rm(path.join(target, MIGRATION_MARKER), { force: true });
|
|
121
|
+
if (isSharingDenied(error)) {
|
|
122
|
+
throw new Error("Legacy ask-pro browser profile could not be removed and has no reliable identity; refusing to retain it.", { cause: error });
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
await fs.rm(path.join(target, MIGRATION_MARKER), { force: true });
|
|
127
|
+
return target;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await releaseProfileRunLock(releasePath, migrationLock.lockId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function askProAgentIdForLegacyBrowserProfileDir(profileDir) {
|
|
134
|
+
return agentIdForProfileDir(profileDir, path.join(os.homedir(), ".agents", "skills", "ask-pro"));
|
|
135
|
+
}
|
|
136
|
+
export function isLegacyAskProManagedBrowserProfileDir(profileDir) {
|
|
137
|
+
const resolved = normalizeProfilePath(profileDir);
|
|
138
|
+
if (resolved === normalizeProfilePath(legacyAskProBrowserProfileDirForAgentId(null)))
|
|
139
|
+
return true;
|
|
140
|
+
return askProAgentIdForLegacyBrowserProfileDir(profileDir) !== null;
|
|
141
|
+
}
|
|
142
|
+
function profileDirUnder(root, agentId) {
|
|
143
|
+
if (!agentId)
|
|
144
|
+
return path.join(root, "browser-profile");
|
|
145
|
+
if (!RESOLVED_AGENT_ID_PATTERN.test(agentId)) {
|
|
146
|
+
throw new Error("Stored ask-pro agent id is invalid.");
|
|
147
|
+
}
|
|
148
|
+
return path.join(root, "agents", agentId, "browser-profile");
|
|
149
|
+
}
|
|
150
|
+
export function isAskProManagedBrowserProfileDir(profileDir) {
|
|
151
|
+
const resolved = normalizeProfilePath(profileDir);
|
|
152
|
+
const defaultProfile = normalizeProfilePath(askProBrowserProfileDirForAgentId(null));
|
|
153
|
+
if (resolved === defaultProfile)
|
|
154
|
+
return true;
|
|
155
|
+
return askProAgentIdForManagedBrowserProfileDir(profileDir) !== null;
|
|
156
|
+
}
|
|
157
|
+
export function isAskProStatePath(profileDir) {
|
|
158
|
+
const resolved = normalizeProfilePath(profileDir);
|
|
159
|
+
const stateRoot = normalizeProfilePath(askProStateDir());
|
|
160
|
+
const relative = path.relative(stateRoot, resolved);
|
|
161
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
162
|
+
}
|
|
163
|
+
export function askProAgentIdForManagedBrowserProfileDir(profileDir) {
|
|
164
|
+
return agentIdForProfileDir(profileDir, askProStateDir());
|
|
165
|
+
}
|
|
166
|
+
function agentIdForProfileDir(profileDir, stateRoot) {
|
|
167
|
+
const resolved = normalizeProfilePath(profileDir);
|
|
168
|
+
const agentsRoot = normalizeProfilePath(path.join(stateRoot, "agents"));
|
|
169
|
+
const relative = path.relative(agentsRoot, resolved);
|
|
170
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
171
|
+
return null;
|
|
172
|
+
const parts = relative.split(path.sep);
|
|
173
|
+
if (parts.length !== 2 || parts[1] !== "browser-profile")
|
|
174
|
+
return null;
|
|
175
|
+
const agentId = parts[0];
|
|
176
|
+
return RESOLVED_AGENT_ID_PATTERN.test(agentId) ? agentId : null;
|
|
177
|
+
}
|
|
178
|
+
function askProStateDir(env = process.env) {
|
|
179
|
+
const codexHome = env.CODEX_HOME?.trim();
|
|
180
|
+
return path.join(codexHome ? path.resolve(codexHome) : path.join(os.homedir(), ".codex"), "state", "ask-pro");
|
|
181
|
+
}
|
|
182
|
+
async function exists(filePath) {
|
|
183
|
+
return fs
|
|
184
|
+
.stat(filePath)
|
|
185
|
+
.then(() => true)
|
|
186
|
+
.catch((error) => {
|
|
187
|
+
if (error.code === "ENOENT")
|
|
188
|
+
return false;
|
|
189
|
+
throw error;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async function legacyProfileIdentity(profileDir) {
|
|
193
|
+
const info = await fs.stat(profileDir, { bigint: true }).catch(() => null);
|
|
194
|
+
if (!info?.isDirectory() || info.dev <= 0n || info.ino <= 0n || info.birthtimeNs <= 0n) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
dev: info.dev.toString(),
|
|
199
|
+
ino: info.ino.toString(),
|
|
200
|
+
birthtimeNs: info.birthtimeNs.toString(),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async function retainedLegacyIdentity(target) {
|
|
204
|
+
const recorded = await fs
|
|
205
|
+
.readFile(path.join(target, MIGRATION_MARKER), "utf8")
|
|
206
|
+
.then((raw) => JSON.parse(raw).legacyIdentity)
|
|
207
|
+
.catch(() => null);
|
|
208
|
+
if (!recorded ||
|
|
209
|
+
![recorded.dev, recorded.ino, recorded.birthtimeNs].every((value) => /^[1-9]\d*$/.test(value))) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return recorded;
|
|
213
|
+
}
|
|
214
|
+
async function matchesRetainedLegacy(target, legacy) {
|
|
215
|
+
const [recorded, current] = await Promise.all([
|
|
216
|
+
retainedLegacyIdentity(target),
|
|
217
|
+
legacyProfileIdentity(legacy),
|
|
218
|
+
]);
|
|
219
|
+
return recorded !== null && current !== null && sameLegacyIdentity(recorded, current);
|
|
220
|
+
}
|
|
221
|
+
function sameLegacyIdentity(left, right) {
|
|
222
|
+
return left.dev === right.dev && left.ino === right.ino && left.birthtimeNs === right.birthtimeNs;
|
|
223
|
+
}
|
|
224
|
+
function isSharingDenied(error) {
|
|
225
|
+
return ["EACCES", "EBUSY", "EPERM"].includes(error.code ?? "");
|
|
226
|
+
}
|
|
227
|
+
async function waitForConcurrentMigration(target, legacy) {
|
|
228
|
+
const marker = path.join(target, MIGRATION_MARKER);
|
|
229
|
+
const owner = await fs
|
|
230
|
+
.readFile(marker, "utf8")
|
|
231
|
+
.then((raw) => JSON.parse(raw))
|
|
232
|
+
.catch(() => null);
|
|
233
|
+
if (!owner?.pid ||
|
|
234
|
+
!Number.isFinite(owner.pid) ||
|
|
235
|
+
!owner.createdAt ||
|
|
236
|
+
!Number.isFinite(owner.createdAt) ||
|
|
237
|
+
!owner.migrationLockId) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
while (isProcessAlive(owner.pid) &&
|
|
241
|
+
Date.now() - owner.createdAt < 300_000 &&
|
|
242
|
+
(await legacyHasMigrationLock(legacy, owner.migrationLockId))) {
|
|
243
|
+
if (!(await exists(legacy))) {
|
|
244
|
+
await fs.rm(marker, { force: true }).catch(() => undefined);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
248
|
+
}
|
|
249
|
+
if (await exists(legacy))
|
|
250
|
+
return false;
|
|
251
|
+
await fs.rm(marker, { force: true }).catch(() => undefined);
|
|
252
|
+
return exists(target);
|
|
253
|
+
}
|
|
254
|
+
async function legacyHasMigrationLock(legacy, lockId) {
|
|
255
|
+
const activeLockId = await fs
|
|
256
|
+
.readFile(path.join(legacy, "ask-pro-automation.lock"), "utf8")
|
|
257
|
+
.then((raw) => JSON.parse(raw).lockId)
|
|
258
|
+
.catch(() => null);
|
|
259
|
+
return activeLockId === lockId;
|
|
260
|
+
}
|
|
261
|
+
export function resolveAskProAgentId(env = process.env) {
|
|
262
|
+
const value = env.ASK_PRO_AGENT_ID;
|
|
263
|
+
if (value === undefined)
|
|
264
|
+
return null;
|
|
265
|
+
const raw = value.trim();
|
|
266
|
+
if (raw !== value) {
|
|
267
|
+
throw new Error("ASK_PRO_AGENT_ID must not start or end with whitespace.");
|
|
268
|
+
}
|
|
269
|
+
if (!raw) {
|
|
270
|
+
throw new Error("ASK_PRO_AGENT_ID must not be empty.");
|
|
271
|
+
}
|
|
272
|
+
if (!/^[a-z0-9._-]+$/.test(raw)) {
|
|
273
|
+
throw new Error("ASK_PRO_AGENT_ID must use only lowercase letters, numbers, '.', '_', or '-'.");
|
|
274
|
+
}
|
|
275
|
+
const hash = createHash("sha256").update(raw).digest("hex").slice(0, 10);
|
|
276
|
+
const prefix = raw.slice(0, 53).replace(/^[-_.]+|[-_.]+$/g, "") || "agent";
|
|
277
|
+
return `${prefix}-${hash}`;
|
|
278
|
+
}
|
|
279
|
+
function normalizeProfilePath(profileDir) {
|
|
280
|
+
const resolved = path.resolve(profileDir);
|
|
281
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
282
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { delay } from "./utils.js";
|
|
7
|
+
const DEVTOOLS_ACTIVE_PORT_FILENAME = "DevToolsActivePort";
|
|
8
|
+
const DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS = [
|
|
9
|
+
DEVTOOLS_ACTIVE_PORT_FILENAME,
|
|
10
|
+
path.join("Default", DEVTOOLS_ACTIVE_PORT_FILENAME),
|
|
11
|
+
];
|
|
12
|
+
const CHROME_PID_FILENAME = "chrome.pid";
|
|
13
|
+
const CHROME_STARTING_FILENAME = "chrome-starting";
|
|
14
|
+
const ASK_PRO_PROFILE_LOCK_FILENAME = "ask-pro-automation.lock";
|
|
15
|
+
const ASK_PRO_RUN_LEASE_DIRNAME = "ask-pro-browser-runs";
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
export function getDevToolsActivePortPaths(userDataDir) {
|
|
18
|
+
return DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS.map((relative) => path.join(userDataDir, relative));
|
|
19
|
+
}
|
|
20
|
+
export async function readDevToolsPort(userDataDir) {
|
|
21
|
+
for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(candidate, "utf8");
|
|
24
|
+
const firstLine = raw.split(/\r?\n/u)[0]?.trim();
|
|
25
|
+
const port = Number.parseInt(firstLine ?? "", 10);
|
|
26
|
+
if (Number.isFinite(port)) {
|
|
27
|
+
return port;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// ignore missing/unreadable candidates
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
export async function writeDevToolsActivePort(userDataDir, port) {
|
|
37
|
+
const contents = `${port}\n/devtools/browser`;
|
|
38
|
+
for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
|
|
39
|
+
try {
|
|
40
|
+
await mkdir(path.dirname(candidate), { recursive: true });
|
|
41
|
+
await writeFile(candidate, contents, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// best effort
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function readChromePid(userDataDir) {
|
|
49
|
+
const pidPath = path.join(userDataDir, CHROME_PID_FILENAME);
|
|
50
|
+
try {
|
|
51
|
+
const raw = (await readFile(pidPath, "utf8")).trim();
|
|
52
|
+
const pid = Number.parseInt(raw, 10);
|
|
53
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return pid;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function writeChromePid(userDataDir, pid) {
|
|
63
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
64
|
+
return;
|
|
65
|
+
const pidPath = path.join(userDataDir, CHROME_PID_FILENAME);
|
|
66
|
+
try {
|
|
67
|
+
await mkdir(path.dirname(pidPath), { recursive: true });
|
|
68
|
+
await writeFile(pidPath, `${Math.trunc(pid)}\n`, "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// best effort
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function markChromeLaunchStarting(userDataDir) {
|
|
75
|
+
const markerPath = path.join(userDataDir, CHROME_STARTING_FILENAME);
|
|
76
|
+
await mkdir(path.dirname(markerPath), { recursive: true });
|
|
77
|
+
await writeFile(markerPath, "", "utf8");
|
|
78
|
+
}
|
|
79
|
+
export async function hasChromeLaunchStarting(userDataDir) {
|
|
80
|
+
try {
|
|
81
|
+
await stat(path.join(userDataDir, CHROME_STARTING_FILENAME));
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function clearChromeLaunchStarting(userDataDir) {
|
|
89
|
+
await rm(path.join(userDataDir, CHROME_STARTING_FILENAME), { force: true }).catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
export function isProcessAlive(pid) {
|
|
92
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
93
|
+
return false;
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// EPERM means "exists but no permission"; treat as alive.
|
|
100
|
+
if (error &&
|
|
101
|
+
typeof error === "object" &&
|
|
102
|
+
"code" in error &&
|
|
103
|
+
error.code === "EPERM") {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function createManagedChromeRunLease(userDataDir) {
|
|
110
|
+
const leaseId = randomUUID();
|
|
111
|
+
const leaseDir = path.join(userDataDir, ASK_PRO_RUN_LEASE_DIRNAME);
|
|
112
|
+
const leasePath = path.join(leaseDir, `${process.pid}-${leaseId}.lease`);
|
|
113
|
+
await mkdir(leaseDir, { recursive: true });
|
|
114
|
+
await writeFile(leasePath, "", { encoding: "utf8", flag: "wx" });
|
|
115
|
+
return { path: leasePath };
|
|
116
|
+
}
|
|
117
|
+
export async function releaseManagedChromeRunLeaseAndCountPeers(userDataDir, lease, logger) {
|
|
118
|
+
try {
|
|
119
|
+
await rm(lease.path, { force: true });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
logger?.(`Failed to release ask-pro browser run lease: ${String(error)}`);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const leaseDir = path.join(userDataDir, ASK_PRO_RUN_LEASE_DIRNAME);
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = await readdir(leaseDir, { withFileTypes: true });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error.code === "ENOENT")
|
|
132
|
+
return 0;
|
|
133
|
+
logger?.(`Failed to inspect ask-pro browser run leases: ${String(error)}`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
let peers = 0;
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (!entry.isFile() || !entry.name.endsWith(".lease"))
|
|
139
|
+
continue;
|
|
140
|
+
const leasePath = path.join(leaseDir, entry.name);
|
|
141
|
+
const pid = Number(entry.name.match(/^(\d+)-/)?.[1]);
|
|
142
|
+
if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) {
|
|
143
|
+
try {
|
|
144
|
+
await rm(leasePath, { force: true });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
logger?.(`Failed to remove stale ask-pro browser run lease: ${String(error)}`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
peers += 1;
|
|
153
|
+
}
|
|
154
|
+
return peers;
|
|
155
|
+
}
|
|
156
|
+
function parseProfileRunLock(payload) {
|
|
157
|
+
if (!payload)
|
|
158
|
+
return null;
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(payload);
|
|
161
|
+
if (!Number.isFinite(parsed.pid) || parsed.pid <= 0)
|
|
162
|
+
return null;
|
|
163
|
+
if (!parsed.lockId || typeof parsed.lockId !== "string")
|
|
164
|
+
return null;
|
|
165
|
+
return parsed;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export async function isBrowserProfileInUse(userDataDir, options = {}) {
|
|
172
|
+
const lockPath = path.join(userDataDir, ASK_PRO_PROFILE_LOCK_FILENAME);
|
|
173
|
+
let lockPayload;
|
|
174
|
+
try {
|
|
175
|
+
lockPayload = await readFile(lockPath, "utf8");
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== "ENOENT")
|
|
179
|
+
return true;
|
|
180
|
+
lockPayload = null;
|
|
181
|
+
}
|
|
182
|
+
let lock = parseProfileRunLock(lockPayload);
|
|
183
|
+
if (lockPayload !== null && !lock) {
|
|
184
|
+
await delay(200);
|
|
185
|
+
try {
|
|
186
|
+
lockPayload = await readFile(lockPath, "utf8");
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error.code !== "ENOENT")
|
|
190
|
+
return true;
|
|
191
|
+
lockPayload = null;
|
|
192
|
+
}
|
|
193
|
+
lock = parseProfileRunLock(lockPayload);
|
|
194
|
+
}
|
|
195
|
+
if (lock && lock.lockId !== options.ignoreLockId && isProcessAlive(lock.pid))
|
|
196
|
+
return true;
|
|
197
|
+
const pid = await readChromePid(userDataDir);
|
|
198
|
+
const port = await readDevToolsPort(userDataDir);
|
|
199
|
+
const [owner, devTools] = await Promise.all([
|
|
200
|
+
isChromeUsingUserDataDir(userDataDir),
|
|
201
|
+
port ? verifyDevToolsReachable({ port, attempts: 1 }) : null,
|
|
202
|
+
]);
|
|
203
|
+
if (owner === true || (pid && isProcessAlive(pid) && devTools?.ok))
|
|
204
|
+
return true;
|
|
205
|
+
return owner === null;
|
|
206
|
+
}
|
|
207
|
+
export async function acquireProfileRunLock(userDataDir, options) {
|
|
208
|
+
const timeoutMs = options.timeoutMs;
|
|
209
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const pollMs = typeof options.pollMs === "number" && Number.isFinite(options.pollMs) && options.pollMs > 0
|
|
213
|
+
? options.pollMs
|
|
214
|
+
: 1000;
|
|
215
|
+
const lockPath = path.join(userDataDir, ASK_PRO_PROFILE_LOCK_FILENAME);
|
|
216
|
+
const lockId = randomUUID();
|
|
217
|
+
const startedAt = Date.now();
|
|
218
|
+
let warned = false;
|
|
219
|
+
for (;;) {
|
|
220
|
+
try {
|
|
221
|
+
const payload = {
|
|
222
|
+
pid: process.pid,
|
|
223
|
+
lockId,
|
|
224
|
+
createdAt: new Date().toISOString(),
|
|
225
|
+
sessionId: options.sessionId,
|
|
226
|
+
};
|
|
227
|
+
if (options.requireExistingProfile) {
|
|
228
|
+
await stat(userDataDir);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
232
|
+
}
|
|
233
|
+
await writeFile(lockPath, JSON.stringify(payload), { encoding: "utf8", flag: "wx" });
|
|
234
|
+
options.logger?.(`Acquired ask-pro profile lock at ${lockPath}`);
|
|
235
|
+
return {
|
|
236
|
+
path: lockPath,
|
|
237
|
+
lockId,
|
|
238
|
+
release: async () => releaseProfileRunLock(lockPath, lockId, options.logger),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
const code = error.code;
|
|
243
|
+
if (code !== "EEXIST") {
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
let existing = parseProfileRunLock(await readFile(lockPath, "utf8").catch(() => null));
|
|
247
|
+
if (!existing) {
|
|
248
|
+
// Likely partial write / corruption; re-read once, then delete (user preference: delete unreadable lockfiles).
|
|
249
|
+
await delay(200);
|
|
250
|
+
existing = parseProfileRunLock(await readFile(lockPath, "utf8").catch(() => null));
|
|
251
|
+
if (!existing) {
|
|
252
|
+
if (options.staleLockMode === "fail") {
|
|
253
|
+
throw new Error("ask-pro profile lock is unreadable; refusing stale lock removal.");
|
|
254
|
+
}
|
|
255
|
+
options.logger?.("ask-pro profile lock unreadable; deleting lockfile.");
|
|
256
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!existing || !isProcessAlive(existing.pid)) {
|
|
261
|
+
if (options.staleLockMode === "fail") {
|
|
262
|
+
throw new Error("ask-pro profile lock owner is not alive; refusing stale lock removal.");
|
|
263
|
+
}
|
|
264
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!warned) {
|
|
268
|
+
const waited = Math.round(timeoutMs / 1000);
|
|
269
|
+
options.logger?.(`ask-pro profile lock held by pid ${existing.pid}; waiting up to ${waited}s.`);
|
|
270
|
+
warned = true;
|
|
271
|
+
}
|
|
272
|
+
const elapsed = Date.now() - startedAt;
|
|
273
|
+
if (elapsed >= timeoutMs) {
|
|
274
|
+
throw new Error(`ask-pro profile lock still held by pid ${existing.pid} after ${Math.round(elapsed / 1000)}s`);
|
|
275
|
+
}
|
|
276
|
+
await delay(Math.min(pollMs, timeoutMs - elapsed));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export async function releaseProfileRunLock(lockPath, lockId, logger) {
|
|
281
|
+
try {
|
|
282
|
+
const existing = parseProfileRunLock(await readFile(lockPath, "utf8").catch(() => null));
|
|
283
|
+
if (!existing || existing.lockId !== lockId) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await rm(lockPath, { force: true });
|
|
287
|
+
logger?.(`Released ask-pro profile lock ${lockPath}`);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// best effort
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
export async function verifyDevToolsReachable({ port, host = "127.0.0.1", attempts = 3, timeoutMs = 3000, }) {
|
|
294
|
+
const versionUrl = `http://${host}:${port}/json/version`;
|
|
295
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
296
|
+
try {
|
|
297
|
+
const controller = new AbortController();
|
|
298
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
299
|
+
const response = await fetch(versionUrl, { signal: controller.signal });
|
|
300
|
+
clearTimeout(timeout);
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
throw new Error(`HTTP ${response.status}`);
|
|
303
|
+
}
|
|
304
|
+
return { ok: true };
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (attempt < attempts - 1) {
|
|
308
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
312
|
+
return { ok: false, error: message };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { ok: false, error: "unreachable" };
|
|
316
|
+
}
|
|
317
|
+
export async function shouldCleanupManualLoginProfileState(userDataDir, logger, options = {}) {
|
|
318
|
+
if (!options.connectionClosedUnexpectedly) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
const port = await readDevToolsPort(userDataDir);
|
|
322
|
+
if (!port) {
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
const probe = await (options.probe ?? verifyDevToolsReachable)({ port, host: options.host });
|
|
326
|
+
if (probe.ok) {
|
|
327
|
+
logger?.(`DevTools port ${port} still reachable; preserving manual-login profile state`);
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
logger?.(`DevTools port ${port} unreachable (${probe.error}); clearing stale profile state`);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
export async function cleanupStaleProfileState(userDataDir, logger, options = {}) {
|
|
334
|
+
for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
|
|
335
|
+
try {
|
|
336
|
+
await rm(candidate, { force: true });
|
|
337
|
+
logger?.(`Removed stale DevToolsActivePort: ${candidate}`);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// ignore cleanup errors
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const lockRemovalMode = options.lockRemovalMode ?? "never";
|
|
344
|
+
if (lockRemovalMode === "never") {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const pid = await readChromePid(userDataDir);
|
|
348
|
+
if (!pid) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (isProcessAlive(pid)) {
|
|
352
|
+
logger?.(`Chrome pid ${pid} still alive; skipping profile lock cleanup`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Extra safety: if Chrome is running with this profile (but with a different PID, e.g. user relaunched
|
|
356
|
+
// without remote debugging), never delete lock files.
|
|
357
|
+
if ((await isChromeUsingUserDataDir(userDataDir)) === true) {
|
|
358
|
+
logger?.("Detected running Chrome using this profile; skipping profile lock cleanup");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const lockFiles = [
|
|
362
|
+
path.join(userDataDir, "lockfile"),
|
|
363
|
+
path.join(userDataDir, "SingletonLock"),
|
|
364
|
+
path.join(userDataDir, "SingletonSocket"),
|
|
365
|
+
path.join(userDataDir, "SingletonCookie"),
|
|
366
|
+
];
|
|
367
|
+
for (const lock of lockFiles) {
|
|
368
|
+
await rm(lock, { force: true }).catch(() => undefined);
|
|
369
|
+
}
|
|
370
|
+
logger?.("Cleaned up stale Chrome profile locks");
|
|
371
|
+
}
|
|
372
|
+
async function isChromeUsingUserDataDir(userDataDir) {
|
|
373
|
+
if (process.platform === "win32") {
|
|
374
|
+
try {
|
|
375
|
+
const { stdout } = await execFileAsync("powershell.exe", [
|
|
376
|
+
"-NoProfile",
|
|
377
|
+
"-NonInteractive",
|
|
378
|
+
"-Command",
|
|
379
|
+
"$ErrorActionPreference = 'Stop'; $match = Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(chrome|chromium|msedge)\\.exe$' -and $_.CommandLine -and $_.CommandLine.Contains($env:ASK_PRO_PROFILE_SCAN_PATH) -and $_.CommandLine.Contains('--user-data-dir') } | Select-Object -First 1; if ($match) { 'match' } else { 'scan-ok' }",
|
|
380
|
+
], {
|
|
381
|
+
env: { ...process.env, ASK_PRO_PROFILE_SCAN_PATH: userDataDir },
|
|
382
|
+
maxBuffer: 1024 * 1024,
|
|
383
|
+
windowsHide: true,
|
|
384
|
+
});
|
|
385
|
+
const result = String(stdout).trim();
|
|
386
|
+
return result === "match" ? true : result === "scan-ok" ? false : null;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const { stdout } = await execFileAsync("ps", ["-ax", "-o", "command="], {
|
|
394
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
395
|
+
});
|
|
396
|
+
const lines = String(stdout ?? "").split("\n");
|
|
397
|
+
const needle = userDataDir;
|
|
398
|
+
for (const line of lines) {
|
|
399
|
+
if (!line)
|
|
400
|
+
continue;
|
|
401
|
+
const lower = line.toLowerCase();
|
|
402
|
+
if (!lower.includes("chrome") && !lower.includes("chromium"))
|
|
403
|
+
continue;
|
|
404
|
+
if (line.includes(needle) && lower.includes("user-data-dir")) {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|