fix: batch of 5 issue fixes (#1753 #2087 #2091 #2095 #2108) (#2125)

* fix(plugin): pass --ignore-scripts to plugin npm install (#1753)

Plugin repos are cloned from untrusted third-party Git URLs. Without
--ignore-scripts, `npm install` runs preinstall/install/postinstall
lifecycle scripts (of the plugin and every transitive dep) at install
time with the user's privileges. Adapter plugins don't need lifecycle
scripts — adapter code is loaded later by the discovery path — so deny
that execution vector unconditionally. Adds a test asserting the flag.

* fix(chatgpt): verify whoami via /api/auth/session, not legacy cookie (#2087)

verifyChatgptIdentity hard-gated on the legacy
`__Secure-next-auth.session-token` cookie before probing
/api/auth/session, so logged-in users on cookie-less sessions got a
false AUTH_REQUIRED. The session endpoint (200 + user.id) is
authoritative; drop the cookie precondition from verify. The login
`poll` keeps its cheap non-navigating cookie gate so verify (which
navigates) doesn't run every ~2s and yank the user off the OAuth form.
Also prefix-match the session cookie so the quickCheck/status/refresh
fast paths stop false-negativing on NextAuth chunked (.0/.1) cookies.

* fix(instagram): collect explore_grid media across nested layouts (#2091)

Instagram stopped populating the flat layout_content.medias[] path;
media now nest across mixed layout shapes (one_by_two_item.clips.items[]
.media, fill_items[].media, ...), so explore returned []. Recursively
walk each sectional item collecting every distinct node.media, dedupe by
pk/id/code (skipping descent into a collected media so carousel children
aren't counted as separate posts), and fall back to play_count for
clips/reels engagement.

* fix(extension): upload files via file-chooser interception (#2108)

DOM.setFileInputFiles with a nodeId/backendNodeId is rejected "-32000 Not
allowed" when the debugger is attached via chrome.debugger (crbug
928255), breaking file upload on every site. Switch setFileInputFiles to
the file-chooser interception flow: enable Page.setInterceptFileChooser-
Dialog, programmatically open the chooser, and use the backendNodeId from
the intercepted Page.fileChooserOpened event (which Chrome accepts). The
event listener is registered before the click and settles on any matching
event so a malformed one rejects fast. Includes the rebuilt bundle.

* fix(chatgpt): use page.sleep in the poll loops #2099 missed (#2095)

#2099 converted the main streaming loops to page.sleep but did not touch
image.js, deep-research-result.js, or the image-poll re-navigation waits
in utils.js. Those still called page.wait(n>=1), which injects a whole-
subtree+attributes MutationObserver DOM-stability wait rather than a
sleep — during ChatGPT streaming the observer never goes quiet and pegs
the renderer. Convert the remaining poll-loop sleeps to page.sleep;
one-shot post-navigation settles are left as-is.
This commit is contained in:
jakevin
2026-07-13 02:36:43 +08:00
committed by GitHub
parent c1ad69676f
commit 654019eeba
10 changed files with 174 additions and 48 deletions
+13 -4
View File
@@ -3,13 +3,18 @@ import { registerSiteAuthCommands } from '../_shared/site-auth.js';
async function hasChatgptSessionCookie(page) {
const cookies = await page.getCookies({ url: 'https://chatgpt.com' });
return cookies.some(c => c.name === '__Secure-next-auth.session-token' && c.value);
// Prefix match: NextAuth chunks large session tokens into
// `__Secure-next-auth.session-token.0`, `.1`, … so an exact-name check
// false-negatives on chunked sessions (the `auth status`/`refresh`/login
// fast paths that consume this). See issue #2087.
return cookies.some(c => c.name.startsWith('__Secure-next-auth.session-token') && c.value);
}
async function verifyChatgptIdentity(page) {
if (!await hasChatgptSessionCookie(page)) {
throw new AuthRequiredError('chatgpt.com', 'ChatGPT __Secure-next-auth.session-token cookie missing');
}
// The `/api/auth/session` probe below is authoritative — do NOT pre-gate on the
// legacy `__Secure-next-auth.session-token` cookie. Current ChatGPT web
// sessions authenticate without that cookie, so gating on it produced false
// AUTH_REQUIRED for logged-in users. See issue #2087.
await page.goto('https://chatgpt.com/');
await page.wait(2);
const result = await page.evaluate(`(async () => {
@@ -43,6 +48,10 @@ registerSiteAuthCommands({
columns: ['user_id', 'name'],
quickCheck: hasChatgptSessionCookie,
verify: verifyChatgptIdentity,
// Poll keeps the cheap, non-navigating cookie gate: during `login` the browser
// sits on the OAuth page, and verify (which navigates to chatgpt.com) must not
// run every ~2s or it would yank the user off the login form. #2087 is about
// `whoami` (the verify path above); login-completion detection is unchanged.
poll: async (page) => {
if (!await hasChatgptSessionCookie(page)) {
throw new AuthRequiredError('chatgpt.com', 'Waiting for ChatGPT session cookie');
+4
View File
@@ -30,6 +30,7 @@ function createProjectUploadPageMock() {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
sleep: vi.fn().mockResolvedValue(undefined),
setFileInput: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn((script) => {
const s = String(script);
@@ -155,6 +156,7 @@ describe('chatgpt browser command registration', () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
sleep: vi.fn().mockResolvedValue(undefined),
startNetworkCapture: vi.fn().mockResolvedValue(true),
readNetworkCapture: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
@@ -227,6 +229,7 @@ describe('chatgpt browser command registration', () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
sleep: vi.fn().mockResolvedValue(undefined),
startNetworkCapture: vi.fn().mockResolvedValue(true),
readNetworkCapture: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
@@ -313,6 +316,7 @@ describe('chatgpt browser command registration', () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
sleep: vi.fn().mockResolvedValue(undefined),
startNetworkCapture: vi.fn().mockResolvedValue(true),
readNetworkCapture: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
+1 -1
View File
@@ -78,7 +78,7 @@ export const deepResearchResultCommand = cli({
}
await page.startNetworkCapture?.('/backend-api/conversation/').catch(() => false);
await page.goto(targetUrl, { waitUntil: 'none' });
await page.wait(3);
await page.sleep(3);
await ensureChatGPTLogin(page, 'ChatGPT deep-research-result requires a logged-in ChatGPT session.');
const result = shouldWait
+1 -1
View File
@@ -128,7 +128,7 @@ export const imageCommand = cli({
for (let ci = 0; ci < 10; ci++) {
const url = await currentChatGPTLink(page);
if (url.includes('/c/')) { convUrl = url; break; }
await page.wait(2);
await page.sleep(2);
}
if (!convUrl) {
convUrl = await currentChatGPTLink(page);
+2 -2
View File
@@ -2654,7 +2654,7 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
currentUrl = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) {
await page.goto(convUrl);
await page.wait(3);
await page.sleep(3);
}
}
@@ -2665,7 +2665,7 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
const onConversation = !currentUrl || isSameChatGPTConversation(currentUrl, convUrl);
if (onConversation) {
await page.goto(convUrl);
await page.wait(3);
await page.sleep(3);
}
}
+37 -12
View File
@@ -22,19 +22,44 @@ cli({
);
if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');
const data = await res.json();
const posts = [];
for (const sec of (data?.sectional_items || [])) {
for (const m of (sec?.layout_content?.medias || [])) {
const media = m?.media;
if (media) posts.push({
user: media.user?.username || '',
caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),
likes: media.like_count ?? 0,
comments: media.comment_count ?? 0,
type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel',
});
// Instagram no longer populates the flat layout_content.medias[] path. Media
// objects are now nested across mixed layout shapes (one_by_two_item.clips.
// items[].media, fill_items[].media, etc.), so recursively walk each sectional
// item collecting every distinct node.media and dedupe by pk/id/code. See #2091.
const seen = new Set();
const medias = [];
const collect = (node, depth) => {
if (!node || typeof node !== 'object' || depth > 8) return;
if (Array.isArray(node)) {
for (const item of node) collect(item, depth + 1);
return;
}
}
const media = node.media;
if (media && typeof media === 'object' && !Array.isArray(media)) {
const key = media.pk ?? media.id ?? media.code;
if (key != null && !seen.has(key)) {
seen.add(key);
medias.push(media);
}
}
for (const [k, value] of Object.entries(node)) {
// Don't descend into a collected media object — a carousel's child items
// carry their own .media and would otherwise be counted as separate posts.
if (k === 'media') continue;
if (value && typeof value === 'object') collect(value, depth + 1);
}
};
for (const sec of (data?.sectional_items || [])) collect(sec, 0);
const posts = medias.map((media) => ({
user: media.user?.username || '',
caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),
// Clips/reels report engagement via play_count rather than like_count.
likes: media.like_count ?? media.play_count ?? 0,
comments: media.comment_count ?? 0,
type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel',
}));
return posts.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p }));
})()
` },
+39 -9
View File
@@ -186,19 +186,49 @@ async function screenshot(tabId, options = {}) {
async function setFileInputFiles(tabId, files, selector) {
await ensureAttached(tabId);
await sendDebuggerCommand({ tabId }, "DOM.enable");
const doc = await sendDebuggerCommand({ tabId }, "DOM.getDocument");
await sendDebuggerCommand({ tabId }, "Page.enable");
const query = selector || 'input[type="file"]';
const result = await sendDebuggerCommand({ tabId }, "DOM.querySelector", {
nodeId: doc.root.nodeId,
selector: query
const found = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
expression: `!!document.querySelector(${JSON.stringify(query)})`,
returnByValue: true
});
if (!result.nodeId) {
if (!found.result?.value) {
throw new Error(`No element found matching selector: ${query}`);
}
await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", {
files,
nodeId: result.nodeId
});
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: true });
try {
const backendNodeId = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("Page.fileChooserOpened not received within 5s — the input may not have opened a file chooser"));
}, 5e3);
const listener = (source, method, params) => {
if (source.tabId !== tabId || method !== "Page.fileChooserOpened") return;
cleanup();
const backend = params?.backendNodeId;
if (typeof backend === "number") resolve(backend);
else reject(new Error("Page.fileChooserOpened carried no backendNodeId"));
};
const cleanup = () => {
clearTimeout(timer);
chrome.debugger.onEvent.removeListener(listener);
};
chrome.debugger.onEvent.addListener(listener);
void sendDebuggerCommand({ tabId }, "Runtime.evaluate", {
expression: `document.querySelector(${JSON.stringify(query)}).click()`
}).catch((err) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
});
});
await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", {
files,
backendNodeId
});
} finally {
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {
});
}
}
function matchesDownloadPattern(item, pattern) {
if (!pattern) return true;
+52 -18
View File
@@ -348,30 +348,64 @@ export async function setFileInputFiles(
): Promise<void> {
await ensureAttached(tabId);
// Enable DOM domain (required for DOM.querySelector and DOM.setFileInputFiles)
// Enable DOM + Page domains. Page is needed for file-chooser interception.
await sendDebuggerCommand({ tabId }, 'DOM.enable');
await sendDebuggerCommand({ tabId }, 'Page.enable');
// Get the document root
const doc = await sendDebuggerCommand({ tabId }, 'DOM.getDocument') as {
root: { nodeId: number };
};
// Find the file input element
// Find the file input element (used to trigger the chooser).
const query = selector || 'input[type="file"]';
const result = await sendDebuggerCommand({ tabId }, 'DOM.querySelector', {
nodeId: doc.root.nodeId,
selector: query,
}) as { nodeId: number };
if (!result.nodeId) {
const found = await sendDebuggerCommand({ tabId }, 'Runtime.evaluate', {
expression: `!!document.querySelector(${JSON.stringify(query)})`,
returnByValue: true,
}) as { result?: { value?: boolean } };
if (!found.result?.value) {
throw new Error(`No element found matching selector: ${query}`);
}
// Set files directly via CDP — Chrome reads from local filesystem
await sendDebuggerCommand({ tabId }, 'DOM.setFileInputFiles', {
files,
nodeId: result.nodeId,
});
// Chrome rejects DOM.setFileInputFiles with a plain nodeId/backendNodeId when
// the debugger is attached via chrome.debugger (crbug 928255, "-32000 Not
// allowed"). The only accepted path is file-chooser interception: enable it,
// programmatically open the chooser, and use the backendNodeId that the
// intercepted Page.fileChooserOpened event hands back. See issue #2108.
await sendDebuggerCommand({ tabId }, 'Page.setInterceptFileChooserDialog', { enabled: true });
try {
const backendNodeId = await new Promise<number>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error('Page.fileChooserOpened not received within 5s — the input may not have opened a file chooser'));
}, 5000);
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
if (source.tabId !== tabId || method !== 'Page.fileChooserOpened') return;
// This is our chooser event — settle now either way, so a malformed
// event rejects immediately instead of hanging until the 5s timeout.
cleanup();
const backend = (params as { backendNodeId?: number })?.backendNodeId;
if (typeof backend === 'number') resolve(backend);
else reject(new Error('Page.fileChooserOpened carried no backendNodeId'));
};
const cleanup = () => {
clearTimeout(timer);
chrome.debugger.onEvent.removeListener(listener);
};
chrome.debugger.onEvent.addListener(listener);
// Open the chooser programmatically — interception suppresses the native
// dialog and fires Page.fileChooserOpened instead. Works for hidden inputs.
void sendDebuggerCommand({ tabId }, 'Runtime.evaluate', {
expression: `document.querySelector(${JSON.stringify(query)}).click()`,
}).catch((err) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
});
});
// backendNodeId from the intercepted chooser IS accepted by Chrome.
await sendDebuggerCommand({ tabId }, 'DOM.setFileInputFiles', {
files,
backendNodeId,
});
} finally {
await sendDebuggerCommand({ tabId }, 'Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {});
}
}
function matchesDownloadPattern(item: chrome.downloads.DownloadItem, pattern: string): boolean {
+17
View File
@@ -613,6 +613,23 @@ describe('installDependencies', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('runs npm install with --ignore-scripts so untrusted lifecycle scripts cannot execute (#1753)', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-plugin-scripts-'));
const pluginDir = path.join(tmpDir, 'plugin-c');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'package.json'), JSON.stringify({ name: 'plugin-c' }));
_installDependencies(pluginDir);
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
const [bin, args] = mockExecFileSync.mock.calls[0];
expect(bin).toBe('npm');
expect(args).toContain('install');
expect(args).toContain('--ignore-scripts');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});
describe('postInstallMonorepoLifecycle', () => {
+8 -1
View File
@@ -564,7 +564,14 @@ function installDependencies(dir: string): void {
if (!fs.existsSync(pkgJsonPath)) return;
try {
execFileSync('npm', ['install', '--omit=dev'], {
// --ignore-scripts is a security boundary, not an optimization: the plugin
// repo was just cloned from an untrusted third-party Git URL, and without
// this flag npm would execute preinstall/install/postinstall lifecycle
// scripts declared by the plugin (and every transitive dep) with the user's
// privileges at install time. Adapter plugins don't need lifecycle scripts
// to work — the adapter code is loaded later by the discovery path — so we
// deny that extra execution vector unconditionally. See issue #1753.
execFileSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
cwd: dir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],