codex-chatgpt-control 0.1.0-alpha.1
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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/contracts/v1/fixtures/backend-capabilities.json +54 -0
- package/contracts/v1/fixtures/backend-error-event-missing-stream-input.json +10 -0
- package/contracts/v1/fixtures/backend-error-missing-run-input.json +10 -0
- package/contracts/v1/fixtures/backend-runner-plan-request.json +12 -0
- package/contracts/v1/fixtures/backend-version.json +10 -0
- package/contracts/v1/fixtures/command-descriptors.json +803 -0
- package/contracts/v1/fixtures/describe-runner-run.json +31 -0
- package/contracts/v1/fixtures/doctor-bridge-upload.json +34 -0
- package/contracts/v1/fixtures/help-root.json +6 -0
- package/contracts/v1/fixtures/named-plan-two-turn.json +39 -0
- package/contracts/v1/fixtures/output-json-parse-success.json +115 -0
- package/contracts/v1/fixtures/primitive-bootstrap-blocker.json +43 -0
- package/contracts/v1/fixtures/report-redaction-default.json +16 -0
- package/contracts/v1/fixtures/reports-create-redacted.json +16 -0
- package/contracts/v1/fixtures/reports-summarize-redacted.json +26 -0
- package/contracts/v1/fixtures/responses-basic-success.json +27 -0
- package/contracts/v1/fixtures/responses-hidden-instructions-unsupported.json +22 -0
- package/contracts/v1/fixtures/responses-unknown-field-unsupported.json +22 -0
- package/contracts/v1/fixtures/responses-unsupported-previous-response-id.json +22 -0
- package/contracts/v1/fixtures/responses-unsupported-temperature.json +22 -0
- package/contracts/v1/fixtures/run-basic-success.json +36 -0
- package/contracts/v1/fixtures/run-browser-bridge-blocker.json +180 -0
- package/contracts/v1/fixtures/run-file-attach-success.json +63 -0
- package/contracts/v1/fixtures/run-report-redacted.json +52 -0
- package/contracts/v1/fixtures/run-selector-drift-blocker.json +60 -0
- package/contracts/v1/fixtures/run-timeout-partial.json +81 -0
- package/contracts/v1/fixtures/run-two-turn-success.json +83 -0
- package/contracts/v1/fixtures/run-upload-permission-blocker.json +56 -0
- package/contracts/v1/fixtures/runner-budget-blocker.json +106 -0
- package/contracts/v1/fixtures/runner-full-agent-config.json +50 -0
- package/contracts/v1/fixtures/runner-input-items-and-files-plan.json +53 -0
- package/contracts/v1/fixtures/runner-metadata-only-plan.json +31 -0
- package/contracts/v1/fixtures/runner-visible-prefix-plan.json +28 -0
- package/contracts/v1/fixtures/runner-visible-setup-plan.json +37 -0
- package/contracts/v1/fixtures/stream-basic.ndjson +3 -0
- package/contracts/v1/fixtures/stream-blocked.ndjson +2 -0
- package/contracts/v1/fixtures/stream-submitted-completed.ndjson +3 -0
- package/contracts/v1/fixtures/workflow-ask-success.json +67 -0
- package/contracts/v1/manifest.json +210 -0
- package/contracts/v1/parity-suite.json +635 -0
- package/contracts/v1/schemas/agent.schema.json +52 -0
- package/contracts/v1/schemas/backend-event.schema.json +74 -0
- package/contracts/v1/schemas/backend-request.schema.json +59 -0
- package/contracts/v1/schemas/backend-response.schema.json +42 -0
- package/contracts/v1/schemas/capabilities.schema.json +33 -0
- package/contracts/v1/schemas/command-descriptor.schema.json +31 -0
- package/contracts/v1/schemas/command-result.schema.json +99 -0
- package/contracts/v1/schemas/manifest.schema.json +69 -0
- package/contracts/v1/schemas/response.schema.json +51 -0
- package/contracts/v1/schemas/run-result.schema.json +138 -0
- package/contracts/v1/schemas/sequence-plan.schema.json +44 -0
- package/contracts/v1/schemas/stream-event.schema.json +25 -0
- package/dist/codex-chatgpt-control-backend.mjs +5284 -0
- package/dist/codex-chatgpt-control.bundle.mjs +6013 -0
- package/dist/src/backend/client.d.ts +129 -0
- package/dist/src/backend/client.js +415 -0
- package/dist/src/backend/protocol.d.ts +74 -0
- package/dist/src/backend/protocol.js +128 -0
- package/dist/src/backend/session.d.ts +11 -0
- package/dist/src/backend/session.js +223 -0
- package/dist/src/backend/stdio-server.d.ts +9 -0
- package/dist/src/backend/stdio-server.js +88 -0
- package/dist/src/browser/attach.d.ts +8 -0
- package/dist/src/browser/attach.js +456 -0
- package/dist/src/browser/clipboard.d.ts +2 -0
- package/dist/src/browser/clipboard.js +26 -0
- package/dist/src/browser/downloads.d.ts +7 -0
- package/dist/src/browser/downloads.js +29 -0
- package/dist/src/browser/page-state.d.ts +17 -0
- package/dist/src/browser/page-state.js +66 -0
- package/dist/src/client.d.ts +167 -0
- package/dist/src/client.js +666 -0
- package/dist/src/commands/confirmations.d.ts +9 -0
- package/dist/src/commands/confirmations.js +33 -0
- package/dist/src/commands/context.d.ts +2 -0
- package/dist/src/commands/context.js +32 -0
- package/dist/src/commands/doctor.d.ts +16 -0
- package/dist/src/commands/doctor.js +120 -0
- package/dist/src/commands/files.d.ts +4 -0
- package/dist/src/commands/files.js +314 -0
- package/dist/src/commands/helpers.d.ts +16 -0
- package/dist/src/commands/helpers.js +128 -0
- package/dist/src/commands/messages.d.ts +16 -0
- package/dist/src/commands/messages.js +538 -0
- package/dist/src/commands/modes.d.ts +9 -0
- package/dist/src/commands/modes.js +323 -0
- package/dist/src/commands/output.d.ts +5 -0
- package/dist/src/commands/output.js +31 -0
- package/dist/src/commands/registry.d.ts +17 -0
- package/dist/src/commands/registry.js +275 -0
- package/dist/src/commands/reports.d.ts +14 -0
- package/dist/src/commands/reports.js +47 -0
- package/dist/src/commands/response-actions.d.ts +2 -0
- package/dist/src/commands/response-actions.js +141 -0
- package/dist/src/commands/sequence.d.ts +9 -0
- package/dist/src/commands/sequence.js +228 -0
- package/dist/src/commands/session.d.ts +2 -0
- package/dist/src/commands/session.js +25 -0
- package/dist/src/commands/threads.d.ts +6 -0
- package/dist/src/commands/threads.js +300 -0
- package/dist/src/dom/fixtures.d.ts +1 -0
- package/dist/src/dom/fixtures.js +8 -0
- package/dist/src/dom/menus.d.ts +8 -0
- package/dist/src/dom/menus.js +40 -0
- package/dist/src/dom/message-format.d.ts +30 -0
- package/dist/src/dom/message-format.js +593 -0
- package/dist/src/dom/messages.d.ts +40 -0
- package/dist/src/dom/messages.js +127 -0
- package/dist/src/dom/selectors.d.ts +20 -0
- package/dist/src/dom/selectors.js +72 -0
- package/dist/src/dom/visible-text.d.ts +5 -0
- package/dist/src/dom/visible-text.js +27 -0
- package/dist/src/errors.d.ts +32 -0
- package/dist/src/errors.js +127 -0
- package/dist/src/index.d.ts +34 -0
- package/dist/src/index.js +34 -0
- package/dist/src/logger.d.ts +15 -0
- package/dist/src/logger.js +25 -0
- package/dist/src/runner/agent.d.ts +2 -0
- package/dist/src/runner/agent.js +17 -0
- package/dist/src/runner/index.d.ts +7 -0
- package/dist/src/runner/index.js +7 -0
- package/dist/src/runner/interruptions.d.ts +3 -0
- package/dist/src/runner/interruptions.js +65 -0
- package/dist/src/runner/responses.d.ts +30 -0
- package/dist/src/runner/responses.js +171 -0
- package/dist/src/runner/result.d.ts +3 -0
- package/dist/src/runner/result.js +128 -0
- package/dist/src/runner/resume.d.ts +11 -0
- package/dist/src/runner/resume.js +26 -0
- package/dist/src/runner/stream.d.ts +13 -0
- package/dist/src/runner/stream.js +66 -0
- package/dist/src/runner/types.d.ts +264 -0
- package/dist/src/runner/types.js +1 -0
- package/dist/src/safety/blockers.d.ts +7 -0
- package/dist/src/safety/blockers.js +60 -0
- package/dist/src/safety/redaction.d.ts +2 -0
- package/dist/src/safety/redaction.js +18 -0
- package/dist/src/safety/report-redaction.d.ts +8 -0
- package/dist/src/safety/report-redaction.js +74 -0
- package/dist/src/safety/risk.d.ts +26 -0
- package/dist/src/safety/risk.js +28 -0
- package/dist/src/scripts/backend-server.d.ts +2 -0
- package/dist/src/scripts/backend-server.js +7 -0
- package/dist/src/scripts/check-parity-suite.d.ts +1 -0
- package/dist/src/scripts/check-parity-suite.js +10 -0
- package/dist/src/scripts/continue-thread.d.ts +27 -0
- package/dist/src/scripts/continue-thread.js +388 -0
- package/dist/src/scripts/live-smoke/harness.d.ts +11 -0
- package/dist/src/scripts/live-smoke/harness.js +151 -0
- package/dist/src/scripts/live-smoke/scenarios.d.ts +3 -0
- package/dist/src/scripts/live-smoke/scenarios.js +648 -0
- package/dist/src/scripts/live-smoke/types.d.ts +53 -0
- package/dist/src/scripts/live-smoke/types.js +1 -0
- package/dist/src/scripts/live-smoke-module.d.ts +3 -0
- package/dist/src/scripts/live-smoke-module.js +2 -0
- package/dist/src/scripts/live-smoke.d.ts +1 -0
- package/dist/src/scripts/live-smoke.js +54 -0
- package/dist/src/scripts/smoke-attach-file.d.ts +1 -0
- package/dist/src/scripts/smoke-attach-file.js +39 -0
- package/dist/src/scripts/smoke-hi.d.ts +1 -0
- package/dist/src/scripts/smoke-hi.js +25 -0
- package/dist/src/scripts/smoke-search-open-read.d.ts +1 -0
- package/dist/src/scripts/smoke-search-open-read.js +41 -0
- package/dist/src/testing/parity-suite.d.ts +12 -0
- package/dist/src/testing/parity-suite.js +213 -0
- package/dist/src/types.d.ts +495 -0
- package/dist/src/types.js +1 -0
- package/package.json +81 -0
- package/references/agents-runner.md +32 -0
- package/references/backend-protocol.md +238 -0
- package/references/python-parity.md +168 -0
- package/references/responses-adapter.md +28 -0
- package/references/safety.md +14 -0
- package/references/streaming.md +15 -0
- package/references/troubleshooting.md +91 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { BrowserBridgeUnavailableError, ChatGPTControlError, LoginRequiredError } from "../errors.js";
|
|
2
|
+
import { parseConversationId, readPageState } from "./page-state.js";
|
|
3
|
+
const CHATGPT_HOME = "https://chatgpt.com/";
|
|
4
|
+
const CHATGPT_HOSTS = new Set(["chatgpt.com", "www.chatgpt.com", "chat.openai.com"]);
|
|
5
|
+
export async function attachChatGPTBrowser(env, args = {}) {
|
|
6
|
+
const browser = await getBrowser(env);
|
|
7
|
+
const page = await getOrCreateChatGPTPage(browser, env, args);
|
|
8
|
+
const state = await readPageState(page);
|
|
9
|
+
if (state.blocker?.kind === "login_required") {
|
|
10
|
+
throw new LoginRequiredError(state.blocker.visibleText);
|
|
11
|
+
}
|
|
12
|
+
const attached = {
|
|
13
|
+
browser,
|
|
14
|
+
page,
|
|
15
|
+
browserName: browser.name ?? "chrome"
|
|
16
|
+
};
|
|
17
|
+
const tabId = getTabId(page);
|
|
18
|
+
if (tabId !== undefined) {
|
|
19
|
+
attached.tabId = tabId;
|
|
20
|
+
}
|
|
21
|
+
return attached;
|
|
22
|
+
}
|
|
23
|
+
async function getBrowser(env) {
|
|
24
|
+
if (env.browser !== undefined) {
|
|
25
|
+
return env.browser;
|
|
26
|
+
}
|
|
27
|
+
const anyEnv = env;
|
|
28
|
+
const agent = env.agent ?? anyEnv.agent ?? globalThis.agent;
|
|
29
|
+
const browsers = agent?.browsers;
|
|
30
|
+
if (browsers !== undefined && typeof browsers === "object") {
|
|
31
|
+
const maybeBrowser = await tryBrowserGetPreferredListed(browsers)
|
|
32
|
+
?? await tryBrowserGet(browsers, "extension")
|
|
33
|
+
?? await tryBrowserGet(browsers, "chrome");
|
|
34
|
+
if (maybeBrowser !== undefined) {
|
|
35
|
+
return maybeBrowser;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new BrowserBridgeUnavailableError();
|
|
39
|
+
}
|
|
40
|
+
async function tryBrowserGet(browsers, name) {
|
|
41
|
+
const get = browsers.get;
|
|
42
|
+
if (typeof get !== "function") {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const browser = await get.call(browsers, name);
|
|
47
|
+
return normalizeBrowser(browser);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function tryBrowserGetFirst(browsers) {
|
|
54
|
+
const list = browsers.list;
|
|
55
|
+
const get = browsers.get;
|
|
56
|
+
if (typeof list !== "function" || typeof get !== "function") {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const names = await list.call(browsers);
|
|
61
|
+
const first = names.find(name => typeof name === "string");
|
|
62
|
+
if (first === undefined) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const browser = await get.call(browsers, first);
|
|
66
|
+
return normalizeBrowser(browser);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function tryBrowserGetPreferredListed(browsers) {
|
|
73
|
+
const list = browsers.list;
|
|
74
|
+
const get = browsers.get;
|
|
75
|
+
if (typeof list !== "function" || typeof get !== "function") {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const available = await list.call(browsers);
|
|
80
|
+
const preferred = available.find(browser => browser.type === "extension")
|
|
81
|
+
?? available.find(browser => typeof browser.name === "string" && /chrome/i.test(browser.name))
|
|
82
|
+
?? available[0];
|
|
83
|
+
const id = preferred?.id;
|
|
84
|
+
if (typeof id !== "string") {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const browser = await get.call(browsers, id);
|
|
88
|
+
return normalizeBrowser(browser);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function getOrCreateChatGPTPage(browser, env, args) {
|
|
95
|
+
const targetUrl = args.url ?? CHATGPT_HOME;
|
|
96
|
+
const explicitExistingPolicy = normalizeExplicitExistingTabPolicy(args);
|
|
97
|
+
if (env.page !== undefined) {
|
|
98
|
+
const cached = normalizePage(env.page);
|
|
99
|
+
if (await cachedPageMatchesBootstrapArgs(cached, args, explicitExistingPolicy)) {
|
|
100
|
+
return cached;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (explicitExistingPolicy !== undefined) {
|
|
104
|
+
const existing = await selectExistingTab(browser, explicitExistingPolicy);
|
|
105
|
+
if (existing !== undefined) {
|
|
106
|
+
return existing;
|
|
107
|
+
}
|
|
108
|
+
const ifMissing = explicitExistingPolicy.ifMissing ?? "block";
|
|
109
|
+
if (ifMissing === "block") {
|
|
110
|
+
throw new ExistingTabSelectionError("No already-open ChatGPT tab matched the requested existing-tab target.", "existing_tab_not_found");
|
|
111
|
+
}
|
|
112
|
+
const missingUrl = ifMissing === "open"
|
|
113
|
+
? urlFromExistingTarget(explicitExistingPolicy.target) ?? targetUrl
|
|
114
|
+
: targetUrl;
|
|
115
|
+
const created = await createTab(browser, missingUrl);
|
|
116
|
+
if (created !== undefined) {
|
|
117
|
+
return created;
|
|
118
|
+
}
|
|
119
|
+
throw new BrowserBridgeUnavailableError("Codex can access a browser object, but no tab creation API was found.");
|
|
120
|
+
}
|
|
121
|
+
if (args.preferExistingTab !== false) {
|
|
122
|
+
const existing = await findExistingChatGPTTab(browser);
|
|
123
|
+
if (existing !== undefined) {
|
|
124
|
+
return existing;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const created = await createTab(browser, targetUrl);
|
|
128
|
+
if (created !== undefined) {
|
|
129
|
+
return created;
|
|
130
|
+
}
|
|
131
|
+
throw new BrowserBridgeUnavailableError("Codex can access a browser object, but no tab creation API was found.");
|
|
132
|
+
}
|
|
133
|
+
async function cachedPageMatchesBootstrapArgs(page, args, explicitExistingPolicy) {
|
|
134
|
+
if (explicitExistingPolicy !== undefined) {
|
|
135
|
+
return pageMatchesExistingTarget(page, explicitExistingPolicy);
|
|
136
|
+
}
|
|
137
|
+
if (args.url !== undefined) {
|
|
138
|
+
const currentUrl = await Promise.resolve(page.url?.()).catch(() => undefined);
|
|
139
|
+
return urlMatches(currentUrl, args.url);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function normalizeExplicitExistingTabPolicy(args) {
|
|
144
|
+
if (args.existingTab === undefined) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
if (args.existingTab === true) {
|
|
148
|
+
return {
|
|
149
|
+
target: { type: "selected", host: "chatgpt" },
|
|
150
|
+
ifMissing: "create",
|
|
151
|
+
ifMultiple: "first",
|
|
152
|
+
requireChatGPT: true
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (args.existingTab === false) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
requireChatGPT: true,
|
|
160
|
+
ifMissing: "block",
|
|
161
|
+
ifMultiple: args.existingTab.target?.type === "selected" ? "first" : "block",
|
|
162
|
+
...args.existingTab
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async function selectExistingTab(browser, policy) {
|
|
166
|
+
const userMatch = await selectExistingUserTab(browser, policy);
|
|
167
|
+
if (userMatch !== undefined) {
|
|
168
|
+
return userMatch;
|
|
169
|
+
}
|
|
170
|
+
if (policy.target?.type === "selected" && typeof browser.tabs?.selected === "function") {
|
|
171
|
+
const selected = await Promise.resolve(browser.tabs.selected.call(browser.tabs)).catch(() => undefined);
|
|
172
|
+
if (selected !== undefined) {
|
|
173
|
+
const normalized = normalizePage(selected);
|
|
174
|
+
if (await pageMatchesExistingTarget(normalized, policy)) {
|
|
175
|
+
return normalized;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (policy.target?.type === "tabId" && typeof browser.tabs?.get === "function") {
|
|
180
|
+
const tab = await Promise.resolve(browser.tabs.get.call(browser.tabs, policy.target.tabId)).catch(() => undefined);
|
|
181
|
+
if (tab !== undefined) {
|
|
182
|
+
const normalized = normalizePage(tab);
|
|
183
|
+
if (await pageMatchesExistingTarget(normalized, policy)) {
|
|
184
|
+
return normalized;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
async function selectExistingUserTab(browser, policy) {
|
|
191
|
+
const openTabs = browser.user?.openTabs;
|
|
192
|
+
const claimTab = browser.user?.claimTab;
|
|
193
|
+
if (typeof openTabs !== "function" || typeof claimTab !== "function") {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
const tabs = await Promise.resolve(openTabs.call(browser.user)).catch(() => []);
|
|
197
|
+
const matches = tabs.filter(tab => userTabMatchesTarget(tab, policy));
|
|
198
|
+
if (matches.length === 0) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
if (matches.length > 1 && (policy.ifMultiple ?? "block") !== "first") {
|
|
202
|
+
throw new ExistingTabSelectionError("Multiple already-open ChatGPT tabs matched the requested existing-tab target.", "existing_tab_ambiguous", matches);
|
|
203
|
+
}
|
|
204
|
+
const selected = matches[0];
|
|
205
|
+
return normalizePage(await claimTab.call(browser.user, selected));
|
|
206
|
+
}
|
|
207
|
+
function userTabMatchesTarget(tab, policy) {
|
|
208
|
+
const target = policy.target ?? { type: "selected", host: "chatgpt" };
|
|
209
|
+
const requireChatGPT = policy.requireChatGPT ?? targetRequiresChatGPT(target);
|
|
210
|
+
if (requireChatGPT && !isChatGPTUrl(tab.url)) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
switch (target.type) {
|
|
214
|
+
case "selected":
|
|
215
|
+
return target.host === undefined || target.host === "chatgpt" ? isChatGPTUrl(tab.url) : true;
|
|
216
|
+
case "tabId":
|
|
217
|
+
return tab.id === target.tabId;
|
|
218
|
+
case "conversationId":
|
|
219
|
+
case "conversation_id":
|
|
220
|
+
return parseConversationId(tab.url ?? "") === target.conversationId;
|
|
221
|
+
case "url":
|
|
222
|
+
return urlMatches(tab.url, target.url);
|
|
223
|
+
case "title":
|
|
224
|
+
return titleMatches(tab.title, target.title, target.exact ?? true);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function pageMatchesExistingTarget(page, policy) {
|
|
228
|
+
const url = await Promise.resolve(page.url?.()).catch(() => undefined);
|
|
229
|
+
const title = await Promise.resolve(page.title?.()).catch(() => undefined);
|
|
230
|
+
const tab = { id: getTabId(page) ?? "" };
|
|
231
|
+
if (url !== undefined)
|
|
232
|
+
tab.url = url;
|
|
233
|
+
if (title !== undefined)
|
|
234
|
+
tab.title = title;
|
|
235
|
+
return userTabMatchesTarget(tab, policy);
|
|
236
|
+
}
|
|
237
|
+
async function findExistingChatGPTTab(browser) {
|
|
238
|
+
const userTab = await selectExistingUserTab(browser, {
|
|
239
|
+
target: { type: "selected", host: "chatgpt" },
|
|
240
|
+
ifMultiple: "first",
|
|
241
|
+
requireChatGPT: true
|
|
242
|
+
});
|
|
243
|
+
if (userTab !== undefined) {
|
|
244
|
+
return userTab;
|
|
245
|
+
}
|
|
246
|
+
const selected = browser.tabs?.selected;
|
|
247
|
+
if (typeof selected === "function") {
|
|
248
|
+
try {
|
|
249
|
+
const current = await selected.call(browser.tabs);
|
|
250
|
+
if (current !== undefined) {
|
|
251
|
+
const normalized = normalizePage(current);
|
|
252
|
+
try {
|
|
253
|
+
if ((await normalized.url?.())?.includes("chatgpt.com") === true) {
|
|
254
|
+
return normalized;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Continue to full tab list.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// No selected tab is a normal fresh-browser state.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const list = browser.tabs?.list;
|
|
267
|
+
if (typeof list !== "function") {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
const tabs = await list.call(browser.tabs);
|
|
271
|
+
const normalized = await Promise.all(tabs.map(tab => hydrateTab(browser, tab)));
|
|
272
|
+
for (const tab of normalized) {
|
|
273
|
+
try {
|
|
274
|
+
if ((await tab.url?.())?.includes("chatgpt.com") === true) {
|
|
275
|
+
return tab;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Keep looking.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
class ExistingTabSelectionError extends ChatGPTControlError {
|
|
285
|
+
constructor(message, code, candidates = []) {
|
|
286
|
+
super(message, "not_found", true, undefined, {
|
|
287
|
+
code,
|
|
288
|
+
candidates: candidates.map(tab => ({ label: userTabCandidateLabel(tab) })),
|
|
289
|
+
remediation: [
|
|
290
|
+
{
|
|
291
|
+
label: "Choose an exact tab",
|
|
292
|
+
instruction: "Use the selected tab, a ChatGPT conversation URL, conversation ID, or a tab id returned by openTabs().",
|
|
293
|
+
userActionRequired: false
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
label: "Allow opening",
|
|
297
|
+
instruction: "Rerun with open-if-missing only if it is acceptable to open or create a ChatGPT tab instead of reusing an already-open one.",
|
|
298
|
+
userActionRequired: false
|
|
299
|
+
}
|
|
300
|
+
]
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function targetRequiresChatGPT(target) {
|
|
305
|
+
switch (target.type) {
|
|
306
|
+
case "selected":
|
|
307
|
+
return target.host === "chatgpt";
|
|
308
|
+
case "tabId":
|
|
309
|
+
case "title":
|
|
310
|
+
return true;
|
|
311
|
+
case "conversationId":
|
|
312
|
+
case "conversation_id":
|
|
313
|
+
case "url":
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function isChatGPTUrl(url) {
|
|
318
|
+
if (url === undefined) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
return CHATGPT_HOSTS.has(new URL(url).hostname);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function urlMatches(actual, expected) {
|
|
329
|
+
if (actual === undefined) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
const actualConversationId = parseConversationId(actual);
|
|
333
|
+
const expectedConversationId = parseConversationId(expected);
|
|
334
|
+
if (actualConversationId !== undefined || expectedConversationId !== undefined) {
|
|
335
|
+
return actualConversationId !== undefined && actualConversationId === expectedConversationId;
|
|
336
|
+
}
|
|
337
|
+
return normalizeUrl(actual) === normalizeUrl(expected);
|
|
338
|
+
}
|
|
339
|
+
function normalizeUrl(value) {
|
|
340
|
+
try {
|
|
341
|
+
const url = new URL(value);
|
|
342
|
+
url.hash = "";
|
|
343
|
+
return url.toString().replace(/\/$/, "");
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return value.trim().replace(/\/$/, "");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function titleMatches(actual, expected, exact) {
|
|
350
|
+
if (actual === undefined) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
const normalizedActual = normalizeText(actual);
|
|
354
|
+
const normalizedExpected = normalizeText(expected);
|
|
355
|
+
return exact ? normalizedActual === normalizedExpected : normalizedActual.includes(normalizedExpected);
|
|
356
|
+
}
|
|
357
|
+
function normalizeText(value) {
|
|
358
|
+
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
|
359
|
+
}
|
|
360
|
+
function urlFromExistingTarget(target) {
|
|
361
|
+
if (target === undefined) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
switch (target.type) {
|
|
365
|
+
case "url":
|
|
366
|
+
return target.url;
|
|
367
|
+
case "conversationId":
|
|
368
|
+
case "conversation_id":
|
|
369
|
+
return new URL(`/c/${target.conversationId}`, CHATGPT_HOME).toString();
|
|
370
|
+
case "selected":
|
|
371
|
+
case "tabId":
|
|
372
|
+
case "title":
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function userTabCandidateLabel(tab) {
|
|
377
|
+
return `tab ${tab.id} - ${tab.title ?? "Untitled"} - ${tab.url ?? "unknown URL"}`;
|
|
378
|
+
}
|
|
379
|
+
async function createTab(browser, url) {
|
|
380
|
+
if (typeof browser.tabs?.create === "function") {
|
|
381
|
+
const tab = await browser.tabs.create(url);
|
|
382
|
+
const page = await hydrateTab(browser, tab);
|
|
383
|
+
await ensurePageAt(page, url);
|
|
384
|
+
return page;
|
|
385
|
+
}
|
|
386
|
+
if (typeof browser.tabs?.new === "function") {
|
|
387
|
+
const tab = await browser.tabs.new(url);
|
|
388
|
+
const page = await hydrateTab(browser, tab);
|
|
389
|
+
await ensurePageAt(page, url);
|
|
390
|
+
return page;
|
|
391
|
+
}
|
|
392
|
+
if (typeof browser.newPage === "function") {
|
|
393
|
+
const page = normalizePage(await browser.newPage());
|
|
394
|
+
if (typeof page.goto === "function") {
|
|
395
|
+
await page.goto(url);
|
|
396
|
+
}
|
|
397
|
+
return page;
|
|
398
|
+
}
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
async function ensurePageAt(page, url) {
|
|
402
|
+
const currentUrl = await Promise.resolve(page.url?.()).catch(() => "");
|
|
403
|
+
if (currentUrl?.includes("chatgpt.com") === true) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (typeof page.goto === "function") {
|
|
407
|
+
await page.goto(url);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function normalizeBrowser(browser) {
|
|
411
|
+
if (browser === undefined || browser === null || typeof browser !== "object") {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
return browser;
|
|
415
|
+
}
|
|
416
|
+
async function hydrateTab(browser, pageOrTab) {
|
|
417
|
+
const maybe = pageOrTab;
|
|
418
|
+
if (maybe.playwright === undefined && typeof maybe.id === "string" && typeof browser.tabs?.get === "function") {
|
|
419
|
+
try {
|
|
420
|
+
return normalizePage(await browser.tabs.get(maybe.id));
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return normalizePage(pageOrTab);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return normalizePage(pageOrTab);
|
|
427
|
+
}
|
|
428
|
+
function normalizePage(pageOrTab) {
|
|
429
|
+
const maybe = pageOrTab;
|
|
430
|
+
const playwright = maybe.playwright ?? maybe.page;
|
|
431
|
+
if (playwright !== undefined && typeof playwright === "object") {
|
|
432
|
+
return new Proxy(playwright, {
|
|
433
|
+
get(target, prop) {
|
|
434
|
+
if (prop in target) {
|
|
435
|
+
const value = target[prop];
|
|
436
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
437
|
+
}
|
|
438
|
+
const value = maybe[prop];
|
|
439
|
+
return typeof value === "function" ? value.bind(maybe) : value;
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
if (typeof maybe.url === "string") {
|
|
444
|
+
return {
|
|
445
|
+
...maybe,
|
|
446
|
+
url: () => maybe.url,
|
|
447
|
+
title: async () => typeof maybe.title === "string" ? maybe.title : ""
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
return pageOrTab;
|
|
451
|
+
}
|
|
452
|
+
function getTabId(page) {
|
|
453
|
+
const maybe = page;
|
|
454
|
+
const id = maybe.id ?? maybe.tabId;
|
|
455
|
+
return typeof id === "string" ? id : undefined;
|
|
456
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export async function readSystemClipboard() {
|
|
5
|
+
if (typeof process === "undefined" || process.platform !== "darwin") {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execFileAsync("pbpaste", [], { timeout: 2000, maxBuffer: 10 * 1024 * 1024 });
|
|
10
|
+
return stdout;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function waitForClipboardChange(before, timeoutMs, pollMs = 150) {
|
|
17
|
+
const started = Date.now();
|
|
18
|
+
while (Date.now() - started < timeoutMs) {
|
|
19
|
+
const current = await readSystemClipboard();
|
|
20
|
+
if (current !== undefined && current.length > 0 && current !== before) {
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DownloadedFile, PageLike } from "../types.js";
|
|
2
|
+
export type DownloadLike = {
|
|
3
|
+
suggestedFilename?: () => string;
|
|
4
|
+
saveAs?: (path: string) => Promise<void>;
|
|
5
|
+
path?: () => Promise<string | null>;
|
|
6
|
+
};
|
|
7
|
+
export declare function waitForDownloadFromClick(page: PageLike, click: () => Promise<void>, destDir: string, timeoutMs: number): Promise<DownloadedFile>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
export async function waitForDownloadFromClick(page, click, destDir, timeoutMs) {
|
|
4
|
+
const absoluteDest = resolve(destDir);
|
|
5
|
+
await mkdir(absoluteDest, { recursive: true });
|
|
6
|
+
const downloadPromise = page.waitForEvent?.("download", { timeout: timeoutMs, timeoutMs });
|
|
7
|
+
if (downloadPromise === undefined) {
|
|
8
|
+
throw new Error("The active browser page does not expose download events.");
|
|
9
|
+
}
|
|
10
|
+
await click();
|
|
11
|
+
const download = await downloadPromise;
|
|
12
|
+
const suggestedFilename = download.suggestedFilename?.() ?? `chatgpt-download-${Date.now()}`;
|
|
13
|
+
const targetPath = join(absoluteDest, basename(suggestedFilename));
|
|
14
|
+
if (typeof download.saveAs === "function") {
|
|
15
|
+
await download.saveAs(targetPath);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
throw new Error("The browser download object does not expose saveAs().");
|
|
19
|
+
}
|
|
20
|
+
const saved = await stat(targetPath);
|
|
21
|
+
if (saved.size <= 0) {
|
|
22
|
+
throw new Error(`Downloaded file is empty: ${targetPath}`);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
path: targetPath,
|
|
26
|
+
suggestedFilename,
|
|
27
|
+
bytes: saved.size
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BlockerKind, PageLike } from "../types.js";
|
|
2
|
+
export type PageState = {
|
|
3
|
+
url: string;
|
|
4
|
+
conversationId?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
visibleText: string;
|
|
7
|
+
signedIn: boolean;
|
|
8
|
+
blocker?: {
|
|
9
|
+
kind: BlockerKind;
|
|
10
|
+
message: string;
|
|
11
|
+
visibleText?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export declare function parseConversationId(url: string): string | undefined;
|
|
15
|
+
export declare function readPageState(page: PageLike): Promise<PageState>;
|
|
16
|
+
export declare function readVisibleText(page: PageLike): Promise<string>;
|
|
17
|
+
export declare function htmlToText(html: string): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { classifyVisibleText } from "../safety/blockers.js";
|
|
2
|
+
import { compactVisibleText } from "../safety/redaction.js";
|
|
3
|
+
export function parseConversationId(url) {
|
|
4
|
+
const match = /\/c\/([A-Za-z0-9-]+)/.exec(url);
|
|
5
|
+
return match?.[1];
|
|
6
|
+
}
|
|
7
|
+
export async function readPageState(page) {
|
|
8
|
+
const rawUrl = typeof page.url === "function" ? await Promise.resolve(page.url()).catch(() => "") : "";
|
|
9
|
+
const url = typeof rawUrl === "string" ? rawUrl : "";
|
|
10
|
+
const rawTitle = typeof page.title === "function" ? await page.title().catch(() => undefined) : undefined;
|
|
11
|
+
const title = typeof rawTitle === "string" ? rawTitle : undefined;
|
|
12
|
+
const visibleText = await readVisibleText(page);
|
|
13
|
+
const blocker = classifyVisibleText(visibleText);
|
|
14
|
+
const signedIn = isLikelySignedIn(visibleText) && blocker?.kind !== "login_required";
|
|
15
|
+
const conversationId = parseConversationId(url);
|
|
16
|
+
const state = {
|
|
17
|
+
url,
|
|
18
|
+
visibleText: compactVisibleText(visibleText),
|
|
19
|
+
signedIn
|
|
20
|
+
};
|
|
21
|
+
if (conversationId !== undefined) {
|
|
22
|
+
state.conversationId = conversationId;
|
|
23
|
+
}
|
|
24
|
+
if (title !== undefined) {
|
|
25
|
+
state.title = title;
|
|
26
|
+
}
|
|
27
|
+
if (blocker !== undefined) {
|
|
28
|
+
state.blocker = blocker;
|
|
29
|
+
}
|
|
30
|
+
return state;
|
|
31
|
+
}
|
|
32
|
+
export async function readVisibleText(page) {
|
|
33
|
+
if (typeof page.evaluate === "function") {
|
|
34
|
+
try {
|
|
35
|
+
return await page.evaluate(() => document.body?.innerText ?? "");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Fall back to content parsing below.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (typeof page.content === "function") {
|
|
42
|
+
try {
|
|
43
|
+
const html = await page.content();
|
|
44
|
+
return htmlToText(html);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
export function htmlToText(html) {
|
|
53
|
+
return html
|
|
54
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
55
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
56
|
+
.replace(/<[^>]+>/g, " ")
|
|
57
|
+
.replace(/ /g, " ")
|
|
58
|
+
.replace(/&/g, "&")
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/\s+/g, " ")
|
|
62
|
+
.trim();
|
|
63
|
+
}
|
|
64
|
+
function isLikelySignedIn(visibleText) {
|
|
65
|
+
return /\b(New chat|Search chats|Chat with ChatGPT|Recents|Projects)\b/i.test(visibleText);
|
|
66
|
+
}
|