diff --git a/README.md b/README.md index 7921952..726e178 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,44 @@ needs [libwebp](https://developers.google.com/speed/webp/download) (`img2webp`). Full options: `image-use --help`. → **[Generate images](https://drawstyle.leeguoo.com/en/docs/generate)** · **[Styles](https://drawstyle.leeguoo.com/en/docs/styles)** +To organize web runs, `--project` accepts an exact **name** (found or created; +default `imagegen`) or an existing **Project landing URL** such as +`https://chatgpt.com/g/g-p-<32-hex-id>/project`. URL targets open directly by ID, +without listing or creating projects; localized display slugs, query strings, +and fragments are accepted and discarded when opening the canonical URL. +Malformed URL targets are rejected. `--project ""` uses a plain chat. + +Project routing is best-effort by default: an unavailable project warns and +continues in a plain chat. Add `--require-project` (or `IMAGE_USE_REQUIRE_PROJECT=1`) +when that fallback would be unwanted. It requires a non-empty target and +`--backend web` or `auto`, verifies Project identity after opening and again in +a capture guard on the native Send click, and disables Codex fallback even when +the browser is unavailable. `IMAGE_USE_PROJECT` can supply either a name or URL. +Use `--no-require-project` to override the environment default for one run, +restoring best-effort routing and ordinary backend/fallback rules without +changing the exported variable. + +Required-mode failures before Send retain the current draft for inspection. +Inspect it in ChatGPT and manually clear the composer before the next run; +ChatGPT can restore unsent drafts in new chats. + +```bash +image-use "a watercolor cat" --project "Art" --require-project --keep-conversation +# To target an existing project exactly, set PROJECT_URL to its browser URL: +image-use "a watercolor cat" --project "$PROJECT_URL" --require-project --keep-conversation +``` + +Routing and retention are independent: **the conversation is deleted by default**, +including with `--require-project`. Use `--keep-conversation` (or +`IMAGE_USE_KEEP_CONVERSATION=1`) to retain it; `--keep-tab` also retains it. + The ChatGPT browser backend pastes multiline prompts, checks the exact editor text, and waits for every reference upload to finish before clicking Send once. Incomplete uploads or altered text stop the run. If a send cannot be confirmed, it reports the uncertainty without sending again. Start with an empty composer; an existing draft is preserved. A run that stops before sending clears the text -it pasted; if a run still reports a non-empty composer, clear the box at -chatgpt.com — ChatGPT restores unsent drafts in new chats. +it pasted, except in required Project mode: it preserves the current draft for +inspection because the route or draft ownership may have changed. After sending, the web backend waits up to `--timeout` for a fresh image confirmed in two consecutive page reads. Assistant text or a missing Stop control does not diff --git a/README.zh-CN.md b/README.zh-CN.md index 8dd7538..d245071 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -99,11 +99,38 @@ image-use "产品主图" --backend codex --image-model gpt-image-2.5-sunburst -- 完整参数:`image-use --help`。→ **[生成图片](https://drawstyle.leeguoo.com/zh/docs/generate)** · **[风格系统](https://drawstyle.leeguoo.com/zh/docs/styles)** +web 后端的 `--project` 可以传精确**名称**(找到就复用,没有就创建;默认 `imagegen`), +也可以传已有 **Project 首页链接**,形如 +`https://chatgpt.com/g/g-p-<32-hex-id>/project`。链接按完整 ID 直接打开,不依赖侧栏列表, +也不创建 Project;支持中文等显示名 slug、query 和 fragment,打开时去掉这些装饰。 +格式错误的链接会直接报错。`--project ""` 则使用普通 chat。 + +默认仍是 best-effort:Project 不可用时会警告并继续使用普通 chat。如果必须在目标 +Project 中提交,加 `--require-project`(或 `IMAGE_USE_REQUIRE_PROJECT=1`)。 +它要求目标非空,backend 为 `web` 或 `auto`,进入后核对 Project 身份,并在原生 +发送点击事件的捕获阶段再次校验身份和输入状态;浏览器不可用时也禁止回退 Codex。 +`IMAGE_USE_PROJECT` 同样可以设置名称或链接。 +用 `--no-require-project` 可以只在本次运行覆盖环境变量的默认值,恢复 best-effort +路由和普通 backend/fallback 规则,不需要修改已 export 的变量。 + +required 模式在发送前失败时会保留当前草稿供检查。请先在 ChatGPT 中检查草稿, +再手动清空输入框后运行下一次;ChatGPT 可能会在新对话里恢复未发送的草稿。 + +```bash +image-use "一只水彩猫" --project "Art" --require-project --keep-conversation +# 精确指定已有 Project 时,先把浏览器里的首页链接赋给 PROJECT_URL: +image-use "一只水彩猫" --project "$PROJECT_URL" --require-project --keep-conversation +``` + +路由与保留对话是独立选项:**默认仍会删除对话**,即使加了 `--require-project`。 +要让对话留在 Project 中,加 `--keep-conversation`(或 `IMAGE_USE_KEEP_CONVERSATION=1`); +`--keep-tab` 也会保留对话。 + ChatGPT 浏览器后端会粘贴多行提示词,核对编辑器中的完整文本,等待所有参考图上传 完成后只点击一次发送。上传不完整或文本发生变化时会停止;无法确认发送结果时会 报告问题,不会重复发送。运行前请确保输入框为空,已有草稿会被保留。未发送就中止的运行 -会清掉自己粘贴的文本;如果仍提示输入框非空,请到 chatgpt.com 手动清空——ChatGPT 会在 -新对话里恢复未发送的草稿。 +会清掉自己粘贴的文本;required Project 模式会保留当前草稿供检查,避免在路由或草稿 +归属变化后误清其他内容。 发送后,web 后端会在 `--timeout` 内等待新图片,要求相邻两次页面读取确认到同一张图。 assistant 正文出现或 Stop 控件缺失,都不能证明图片任务已经结束。已检测到的限流 diff --git a/SKILL.md b/SKILL.md index fd99161..f74e734 100644 --- a/SKILL.md +++ b/SKILL.md @@ -31,10 +31,10 @@ A standalone Python CLI that produces images via the user's existing subscriptio **Default is `auto`** (`--backend auto`, or `IMAGE_USE_BACKEND`): it tries **web first** because that spares the Codex-usage limit, and falls back to **codex only when web is unavailable** — i.e. `chrome-use` isn't installed, the browser isn't reachable, or chatgpt.com isn't logged in. The two not-set-up cases are handled explicitly: -- **Browser not logged in / chrome-use missing** → auto silently falls back to codex (a one-line notice prints to stderr). If codex is *also* not set up, it exits naming both fixes. +- **Browser not logged in / chrome-use missing** → auto falls back to codex (a one-line notice prints to stderr), unless `--require-project` is set. Required mode stops without reading Codex credentials. If codex is *also* not set up in ordinary auto mode, it exits naming both fixes. - **codex not logged in** (`~/.codex/auth.json` absent) → auto still uses web; codex is only the fallback. -Auto does **not** fall back to codex if web was reachable but the generation itself failed after submitting — that would spend the very bucket auto-mode protects. In that case it errors and tells you to rerun with `--backend codex` if you want the Codex-usage path. Force a single backend with `--backend web` or `--backend codex`. +Auto does **not** fall back to codex if web was reachable but the generation itself failed after submitting — that would spend the very bucket auto-mode protects. In ordinary auto mode it errors and tells you to rerun with `--backend codex` if you want the Codex-usage path; required Project mode stops without that suggestion. Force a single backend with `--backend web` or `--backend codex`. ### Gemini backends (opt-in — `auto` never picks them) @@ -141,13 +141,15 @@ Useful flags: | Flag | When to use | | --- | --- | -| `--backend auto` \| `web` \| `codex` \| `gemini` \| `agy` | `auto` (default) prefers web and falls back to codex only when the browser is unavailable/not-logged-in; `web` forces the logged-in-browser path (spares Codex-usage); `codex` forces the headless path (bills Codex-usage); `gemini` and `agy` use a Google account instead and are never picked by `auto` (see [Gemini backends](#gemini-backends-opt-in--auto-never-picks-them)). Also settable via `IMAGE_USE_BACKEND`. | +| `--backend auto` \| `web` \| `codex` \| `gemini` \| `agy` | `auto` (default) prefers web and falls back to codex only when the browser is unavailable/not-logged-in, unless `--require-project` disables that fallback; `web` forces the logged-in-browser path (spares Codex-usage); `codex` forces the headless path (bills Codex-usage); `gemini` and `agy` use a Google account instead and are never picked by `auto` (see [Gemini backends](#gemini-backends-opt-in--auto-never-picks-them)). Also settable via `IMAGE_USE_BACKEND`. | | `--gemini-profile NAME` | (`gemini` backend) Chrome profile to drive, overriding `--profile`. Worth setting — auto-detection cannot tell which Google account holds the subscription. Also `IMAGE_USE_GEMINI_PROFILE`. | | `--no-gemini-image-tool` | (`gemini` backend) skip switching the composer to the dedicated image model (Nano Banana 2). Rarely wanted — the switch is already best-effort. | | `--no-agy-yolo` | (`agy` backend) don't pass `--dangerously-skip-permissions`. Only use it if the user has their own `permissions.allow` rules — otherwise every headless run fails. | | `--profile auto` \| `relay` \| `NAME` | (web) Which Chrome profile to drive. `auto` (default): use the open Chrome if it's logged in, else auto-switch to a profile that is (detected offline from the cookie DB, read-only). `relay`: only the open Chrome. `"Profile 3"`: that profile. Note: *logged in* ≠ *able to generate* — a free-tier account can still hit its daily image cap. | | `--session NAME` | (web) Drive a named Chrome tab group instead of the shared `chatgpt-web` session. Rarely wanted: the default is shared ON PURPOSE so the whole machine keeps ONE chatgpt.com tab. | -| `--project NAME` | (web) ChatGPT Project to file the run's conversation under — matched by exact name, **created automatically if absent**, reused if present. Default `imagegen` (or `IMAGE_USE_PROJECT`). Pass `--project ""` for a plain top-level chat. If the project step fails, the run warns and continues in a plain chat — it never blocks generation. | +| `--project NAME_OR_URL` | (web) An exact name (**created if absent**, reused if present) or an existing ChatGPT Project landing URL (`https://chatgpt.com/g/g-p-<32-hex-id>/project`, optional localized slug/query/fragment). URLs open directly by ID without listing/creating; malformed URLs fail. Default `imagegen` (or `IMAGE_USE_PROJECT`, also accepts URLs). `--project ""` uses a plain chat. Project failures warn and continue in a plain chat unless required. | +| `--require-project` | Require a non-empty Project target and verify its identity after opening and in a capture guard on the native Send click. Only `web`/`auto`; **disables Codex fallback**, including browser-unavailable failures. Also `IMAGE_USE_REQUIRE_PROJECT=1`. Routing does **not** imply retention: add `--keep-conversation` to retain history. Pre-send failures retain the current draft; inspect it and manually clear the composer before the next run. | +| `--no-require-project` | Override the required-mode environment default for this run without changing the exported variable. Restores best-effort Project routing and ordinary backend/fallback rules; allows `--project ""` or an explicit non-web backend. Retention remains independent. | | `--keep-tab` | (web) Leave the ChatGPT tab open after generating (default closes it). Useful for debugging. Implies `--keep-conversation`. | | `--keep-conversation` | (web) Keep the ChatGPT conversation after generating. **Default deletes it** (`PATCH is_visible:false`) so the run leaves no history — it's filed under the project only transiently. Also `IMAGE_USE_KEEP_CONVERSATION=1`. | | `-o PATH` | Always use when you know where the file should go in the repo. | @@ -309,8 +311,9 @@ Update notices are covered under [Upgrade](#upgrade). | `no image returned. events seen: ...` | Model decided not to call the tool | Rephrase prompt to explicitly say "Use the image_generation tool to render…" | | `HTTP 429` | Subscription rate-limited | Wait a few minutes; do not retry in a loop | | `warning: --format=X but FILE.Y has .Y extension` | `-o` extension disagrees with `--format` | Fix the path or the format flag; the file IS written with the format you specified | -| `warning: project 'X' unavailable (…); using a plain chat` | (web) Project list/create API hiccup, or the project page's composer didn't render | Nothing — the image still generated, just in a top-level chat. If it recurs, check the name or pass `--project ""` | -| `chatgpt.com rate-limited this account ('Too many requests') …` | (web) The page surface temporarily blocked the account for making requests too quickly | Wait a few minutes. If it fired *before* submit, `auto` mode already fell back to codex; if *after* submit, check the conversation later — the image may still appear there. Don't retry in a loop | +| `warning: project 'X' unavailable (…); using a plain chat` | (web, without `--require-project`) Project list/create API hiccup, or the project page's composer didn't render | Nothing — the image still generated, just in a top-level chat. If it recurs, check the target; use `--require-project` to stop instead of degrading | +| `required project unavailable` / `required Project Send stopped` | Project access, route, or composer state could not be verified | Stop; inspect the Project link, permissions, and retained draft. Manually clear any retained composer text before the next run; ChatGPT can restore drafts in new chats. No plain-chat or Codex fallback. An uncertain Send is never repeated; inspect the conversation before retrying. | +| `chatgpt.com rate-limited this account ('Too many requests') …` | (web) The page surface temporarily blocked the account for making requests too quickly | Wait a few minutes. A detected rate limit stops the run without automatic Codex fallback; if the prompt was already submitted, check the conversation later — the image may still appear there. Don't retry in a loop | | `waiting for a free web/codex slot (max N concurrent …)` | More parallel runs than the backend's concurrency cap | Nothing — the run starts when a slot frees up; queue time doesn't eat `--timeout` | ## Upgrade @@ -331,8 +334,8 @@ The notice is one stderr line, checked at most once a day; `IMAGE_USE_NO_UPDATE_ **web backend (`run_web`)** - Shells out to `chrome-use` against a session-named Chrome tab group. - Opens a *regular* `https://chatgpt.com/` chat (Temporary Chat disables the image tool). -- Resolves the target ChatGPT Project from inside the authenticated page (undocumented endpoints, probed live): `GET /backend-api/gizmos/snorlax/sidebar` lists projects (a project is a gizmo with id `g-p-…`); `POST /backend-api/projects {name, instructions}` creates one. It then navigates to `https://chatgpt.com/g//project` and submits from that composer, which files the conversation inside the project. Any failure degrades to a plain chat with a stderr warning. -- Pastes with `fill --stdin`, verifies the editor's exact logical text and every completed reference upload, then clicks Send once. Multiline `keyboard type` can turn newlines into partial submissions. Unconfirmed uploads or changed text stop the run; an uncertain send is observed without replaying it. The composer must be empty before a run so an existing draft is preserved; a run that stops before sending clears its own pasted text, since ChatGPT restores unsent drafts in new chats and one would block every later run. +- Resolves a named ChatGPT Project from inside the authenticated page (undocumented endpoints, probed live): `GET /backend-api/gizmos/snorlax/sidebar` lists projects (a project is a gizmo with id `g-p-…`); `POST /backend-api/projects {name, instructions}` creates one. An existing Project URL bypasses both endpoints and opens `https://chatgpt.com/g//project` directly. Failures warn and degrade to a plain chat by default; `--require-project` makes them fatal and prevents auto-mode Codex fallback. +- Pastes with `fill --stdin`, verifies the editor's exact logical text and every completed reference upload, then clicks Send once. Required mode also checks the Project before reference upload/paste. It marks only the current document's Send button; a capture listener rechecks route, text, uploads, turns, and readiness during its native click. The marker/listener are removed after the attempt, or expire after 30 seconds if the CLI disappears. Multiline `keyboard type` can turn newlines into partial submissions. Unconfirmed uploads or changed text stop the run; an uncertain send is observed without replaying it. Existing drafts are preserved. Pre-send failures clear the pasted text in legacy mode; required mode retains the current draft because route or ownership may have changed. Inspect it and manually clear the composer before retrying. - Polls page state via `eval`: waits until the streaming/stop control is gone AND a brand-new `` (src matching `estuary/content|files/download|oaiusercontent`) is present and stable across two reads. The img scan is scoped to `main img` (the tab's own conversation thread) — ChatGPT pushes an "Image created" toast with a matching thumbnail into any open tab when *another* conversation finishes an image, and a document-wide scan grabs that sibling's image (issue #7). The generated img is NOT inside `[data-message-author-role="assistant"]`, so `
` is the right scope. - Downloads the bytes with an in-page `fetch(src, {credentials:'include'})` → base64, so the browser's own session cookies authorize the signed asset URL. No tokens leave the browser. diff --git a/image-use b/image-use index 76fca1e..f515e86 100755 --- a/image-use +++ b/image-use @@ -1383,14 +1383,19 @@ def _ab(ab: str, *ab_args: str, session: str, timeout: float, profile: str | Non return proc.stdout -def _ab_eval(ab: str, js: str, session: str, timeout: float) -> Any: +def _ab_eval(ab: str, js: str, session: str, timeout: float, + *, stdin: bool = False) -> Any: """Run JS in the page and decode the value it returns. Convention: the JS always `return JSON.stringify(value)`. chrome-use prints that string JSON-encoded, so we decode twice — once to recover the JSON text the page returned, once to parse it into a Python object. + ``stdin`` carries scripts containing long prompt text without argv limits. """ - out = _ab(ab, "eval", js, session=session, timeout=timeout) + if stdin: + out = _ab(ab, "eval", "--stdin", input_text=js, session=session, timeout=timeout) + else: + out = _ab(ab, "eval", js, session=session, timeout=timeout) # Scan from the last non-empty line for the first that decodes to a string. for line in reversed([l for l in out.splitlines() if l.strip()]): try: @@ -1603,31 +1608,94 @@ def _wait_composer(ab: str, session: str, remaining, tries: int = 15) -> bool: return False -def _enter_project(ab: str, session: str, name: str, remaining, emit) -> None: - """Navigate the open session into the named ChatGPT Project, creating the - project on first use — so each run's conversation files under the project - instead of littering the top-level chat history. +def _project_url_id(target: str) -> str | None: + """Return a landing URL's stable Project ID, or None for a display name. - Best-effort: organization is a nicety, the generation is the point. On any - failure this warns and restores a plain new chat, never aborts the run. + URL-like mistakes must not reach the name/create API. Display slugs and + query/fragment decorations are optional; navigation uses only the ID. """ + target = target.strip() + if not re.match(r"(?:[a-z][a-z0-9+.-]*://|https?:|//|(?:www\.)?chatgpt\.com(?:[/?#]|$))", + target, re.I): + return None + try: + url = urllib.parse.urlsplit(target) + path = urllib.parse.unquote(url.path, errors="strict") + match = re.fullmatch(r"/g/(g-p-[0-9a-f]{32})(?:-[^/\\?#]+)?/project/?", path, re.I) + if (url.scheme == "https" and url.netloc.lower() == "chatgpt.com" + and match and not re.search(r"[\x00-\x1f\x7f]", target)): + return match[1].lower() + except (ValueError, UnicodeError): + pass + raise GatewayError("--project needs a name or an HTTPS ChatGPT Project URL " + "(https://chatgpt.com/g/g-p-<32-hex-id>/project)") + + +def _validate_project_args(args) -> None: + """Reject invalid targets and incompatible required routes before backend work.""" + target = (getattr(args, "project", None) or "").strip() + _project_url_id(target) + if getattr(args, "require_project", False): + if not target: + raise GatewayError("--require-project needs a non-empty --project target") + if args.backend not in ("web", "auto"): + raise GatewayError("--require-project only supports --backend web or auto") + + +# Read only the Project identity on either its landing page or a conversation. +# The display slug may change or be localized; a prefix of another ID is never +# a match. This same expression is embedded in the guarded Send operation. +_JS_PROJECT_ID = r"""(() => { + const m = location.pathname.match(/^\/g\/(g-p-[0-9a-f]{32})(?:-[^/\\?#]+)?\/(?:project\/?|c\/[^/]+\/?)$/i); + return JSON.stringify(location.origin === 'https://chatgpt.com' && m + ? m[1].toLowerCase() : null); +})()""" + + +def _assert_web_project(ab, session, project_id, remaining) -> None: + """Require the expected Project route; an unreadable route also stops the run.""" + actual = _ab_eval(ab, _JS_PROJECT_ID, session=session, + timeout=min(15.0, remaining())) + if actual != project_id: + raise GatewayError("required ChatGPT Project could not be verified; " + "prompt not submitted. Check the Project URL and access.") + + +def _enter_project(ab: str, session: str, target: str, remaining, emit, + *, require: bool = False) -> str | None: + """Open a Project URL directly, or find/create an exact display name. + + Legacy best-effort routing warns and restores a plain chat on failure. + Required routing propagates failures and verifies the opened Project ID. + """ + project_id = _project_url_id(target) # invalid URLs never become names navigated = False try: - res = _ab_eval(ab, _JS_ENSURE_PROJECT % json.dumps(name), - session=session, timeout=min(30.0, remaining())) - if not isinstance(res, dict) or not res.get("ok") or not res.get("id"): - detail = res.get("error") if isinstance(res, dict) else res - raise GatewayError(f"could not resolve project: {detail}") - emit(f"using project {name!r}" + (" (created)" if res.get("created") else "")) + created = False + if project_id is None: + res = _ab_eval(ab, _JS_ENSURE_PROJECT % json.dumps(target), + session=session, timeout=min(30.0, remaining())) + if not isinstance(res, dict) or not res.get("ok") or not re.fullmatch( + r"g-p-[0-9a-f]{32}", str(res.get("id") or ""), re.I): + detail = res.get("error") if isinstance(res, dict) else res + raise GatewayError(f"could not resolve project: {detail}") + project_id = res["id"].lower() + created = res.get("created") + emit(f"using project {target!r}" + (" (created)" if created else "")) navigated = True - _ab(ab, "open", WEB_PROJECT_URL.format(gizmo_id=res["id"]), + _ab(ab, "open", WEB_PROJECT_URL.format(gizmo_id=project_id), session=session, timeout=min(30.0, remaining())) if not _wait_composer(ab, session, remaining): raise GatewayError("project page composer never appeared") + if require: + _assert_web_project(ab, session, project_id, remaining) + return project_id except WebRateLimited: raise # account-level block; a plain chat would be just as dead except GatewayError as e: - emit(f"warning: project {name!r} unavailable ({e}); using a plain chat") + if require: + raise GatewayError(f"required project unavailable: {e}") from e + emit(f"warning: project {target!r} unavailable ({e}); using a plain chat") if navigated: # We may have left the plain-chat page; restore it so the normal # submit flow still has a composer to type into. @@ -2050,6 +2118,7 @@ def run_web( Returns (image_bytes, meta). Raises GatewayError / exits on failure. """ + _validate_project_args(args) ab = _find_agent_browser() if ab is None: raise WebUnavailable(f"`chrome-use` is not installed — {_AB_INSTALL_HINT}") @@ -2172,14 +2241,19 @@ def run_web( # exception — instead of leaking a window on every non-happy path. try: project = (args.project or "").strip() + required_project_id = None if project: - _enter_project(ab, session, project, remaining, emit) + project_id = _enter_project(ab, session, project, remaining, emit, + require=getattr(args, "require_project", False)) + if getattr(args, "require_project", False): + required_project_id = project_id _ensure_web_model( ab, session, getattr(args, "web_model", WEB_MODEL_DEFAULT) or WEB_MODEL_DEFAULT, remaining, emit) result = _generate_in_browser( - ab, session, user_text, args, deadline, remaining, emit, refs=refs) + ab, session, user_text, args, deadline, remaining, emit, refs=refs, + required_project_id=required_project_id) # Image is saved by now. Delete the conversation so no history is left — # the default. `--keep-conversation` opts out; `--keep-tab` (debugging) # implies keeping it, since an open tab on a deleted chat is useless. @@ -2375,23 +2449,89 @@ def _canonicalize_composer_text(text: str) -> str: return text.replace("\r\n", "\n").replace("\r", "\n").replace("\u00a0", " ") -def _submit_web_prompt(ab, session, user_text, n_refs, remaining, emit) -> None: +# Arm only this document's Send button, then use chrome-use's normal native +# click. A capture listener rechecks route/state at the event, before the app +# handles it. Navigation replaces the document/marker; SPA route changes are +# checked by the listener. A DOM-only click can leave a local provisional chat +# without a server conversation, so keep the normal trusted browser interaction. +_JS_ARM_PROJECT_SEND = r"""(() => { + const expected = %s; + const token = %s; + const fail = error => JSON.stringify({ok: false, error}); + const normalize = text => (text || '').replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' '); + const check = () => { + if (JSON.parse(%s) !== expected) return 'Project route changed or is unverifiable'; + const state = JSON.parse(%s); + if (!state.exists || normalize(state.text) !== normalize(%s) || + state.attachments !== %s || state.ready !== state.attachments || + state.user_turns !== %s || state.busy || !state.send_ready) + return 'composer text, attachments, or Send state changed'; + if (document.querySelector('[role="dialog"]')) return 'a dialog is open'; + return null; + }; + const error = check(); + if (error) return fail(error); + const composer = document.querySelector(%s); + const send = composer && composer.closest('form').querySelector(%s); + if (!send) return fail('Send unavailable'); + const attr = 'data-image-use-project-send'; + const selector = '[' + attr + '="' + token + '"]'; + const result = {ok: null}; + const handler = event => { + const target = event.target.closest && event.target.closest(selector); + if (!target) return; + const error = target !== send ? 'Send target changed' : check(); + if (error) { + event.preventDefault(); + event.stopImmediatePropagation(); + result.ok = false; + result.error = error; + } else result.ok = true; + send.removeAttribute(attr); + window.removeEventListener('click', handler, true); + }; + const cleanup = () => { + clearTimeout(timer); + if (send.getAttribute(attr) === token) send.removeAttribute(attr); + window.removeEventListener('click', handler, true); + delete window[token]; + }; + // If the CLI disappears, remove our marker/listener without clearing text. + // A delayed native click can no longer find its marked target after expiry. + const timer = setTimeout(cleanup, 30000); + window[token] = {finish: () => { cleanup(); return result; }}; + send.setAttribute(attr, token); + window.addEventListener('click', handler, true); + return JSON.stringify({ok: true}); +})()""" + + +def _finish_project_send_guard(ab, session, token, remaining): + """Read this guard's result and remove only its own listener/marker.""" + key = json.dumps(token) + return _ab_eval(ab, f"JSON.stringify(window[{key}] ? window[{key}].finish() : null)", + session=session, timeout=min(15.0, remaining())) + + +def _submit_web_prompt(ab, session, user_text, n_refs, remaining, emit, + *, required_project_id=None) -> None: """Paste, verify exact editor text and attachments, then click Send once. `keyboard type` dispatches Enter for newlines and can submit several partial messages. `fill --stdin` inserts paragraphs without any submit key events. Never replay an uncertain send: a retry could consume quota twice. """ + if required_project_id: + _assert_web_project(ab, session, required_project_id, remaining) initial = _web_composer_state(ab, session, remaining) if initial.get("text"): raise GatewayError("ChatGPT composer is not empty; prompt not submitted. " + _DRAFT_HINT) emit("pasting prompt") _ab(ab, "fill", WEB_COMPOSER_SEL, "--stdin", input_text=user_text, session=session, timeout=min(60.0, remaining())) - # Everything below until the click is a "nothing was sent" failure. ChatGPT - # keeps composer drafts across chats, so leaving our text in the box would - # make every LATER run stop at the empty-composer check above until the user - # clears it by hand — undo the paste before propagating. + # ChatGPT keeps drafts across chats. Legacy mode undoes the paste on a + # pre-click failure so it does not block later runs; required mode preserves + # the current draft because the route or its ownership may have changed. try: ready_deadline = time.monotonic() + min(30.0, remaining()) for _ in range(30): @@ -2415,18 +2555,54 @@ def _submit_web_prompt(ab, session, user_text, n_refs, remaining, emit) -> None: else: raise GatewayError("ChatGPT Send button did not become ready; prompt not submitted") except GatewayError: - _clear_web_composer(ab, session, remaining) + if not required_project_id: + _clear_web_composer(ab, session, remaining) + # Required mode may have lost its route or draft ownership. Preserve + # the current composer instead of clearing someone else's draft. raise emit(f"verified prompt ({len(user_text)} characters) and {n_refs} reference image(s)") send_error = None + send_selector = WEB_SEND_CLICK_SEL + guard_token = None + if required_project_id: + guard_token = "imageUseProjectSend_" + uuid.uuid4().hex + js = _JS_ARM_PROJECT_SEND % ( + json.dumps(required_project_id), json.dumps(guard_token), _JS_PROJECT_ID, + _JS_WEB_COMPOSER_STATE % _js_composer_selectors(), + json.dumps(user_text), n_refs, initial.get("user_turns", 0), + json.dumps(WEB_COMPOSER_SEL), json.dumps(WEB_SEND_SEL)) + try: + armed = _ab_eval(ab, js, session=session, timeout=min(20.0, remaining()), stdin=True) + if not isinstance(armed, dict) or armed.get("ok") is not True: + detail = armed.get("error") if isinstance(armed, dict) else "guard unavailable" + raise GatewayError(str(detail)) + except GatewayError as error: + try: + _finish_project_send_guard(ab, session, guard_token, remaining) + except GatewayError: + pass + raise GatewayError(f"required Project Send stopped: {error}; prompt not submitted. " + "Draft retained; inspect it before retrying.") from error + send_selector = f'button[data-image-use-project-send="{guard_token}"]' try: - _ab(ab, "click", WEB_SEND_CLICK_SEL, - session=session, timeout=min(20.0, remaining())) + _ab(ab, "click", send_selector, session=session, timeout=min(20.0, remaining())) except GatewayError as error: send_error = error + if guard_token: + try: + guarded = _finish_project_send_guard(ab, session, guard_token, remaining) + except GatewayError as error: + guarded, send_error = None, send_error or error + if isinstance(guarded, dict) and guarded.get("ok") is False: + raise GatewayError(f"required Project Send stopped: {guarded.get('error')}; " + "prompt not submitted. Draft retained; inspect it before retrying.") + if not isinstance(guarded, dict) or guarded.get("ok") is not True: + send_error = send_error or GatewayError("guarded Send outcome unknown") sent_deadline = time.monotonic() + min(30.0, remaining()) for _ in range(30): try: + if required_project_id: + _assert_web_project(ab, session, required_project_id, remaining) state = _web_composer_state(ab, session, remaining) except GatewayError: state = {} @@ -2485,11 +2661,13 @@ def _recover_hint(ab, session) -> str: def _generate_in_browser(ab, session, user_text, args, deadline, remaining, emit, - refs=None): + refs=None, required_project_id=None): """Upload references, submit verified text once, and download the new image.""" # Image-to-image: attach the reference(s) to the composer FIRST, then delete # any temp downloads — the browser has uploaded the bytes to ChatGPT by the # time the thumbnail renders, so the local copy is no longer needed. + if required_project_id: + _assert_web_project(ab, session, required_project_id, remaining) if refs: ref_paths: list[str] = [] cleanups: list[str] = [] @@ -2516,7 +2694,8 @@ def _generate_in_browser(ab, session, user_text, args, deadline, remaining, emit if not isinstance(baseline, list): baseline = [] - _submit_web_prompt(ab, session, user_text, len(refs or []), remaining, emit) + _submit_web_prompt(ab, session, user_text, len(refs or []), remaining, emit, + required_project_id=required_project_id) # Poll until the stream stops AND a brand-new image asset is present and # stable across two reads (guards against grabbing a mid-render partial). @@ -3906,6 +4085,11 @@ def _dispatch( web : force web; any failure is fatal (with guidance). codex : force codex. """ + try: + _validate_project_args(args) + except GatewayError as e: + sys.exit(str(e)) + def note(msg: str) -> None: if progress: print(_fmt_progress(time.monotonic() - start, msg), file=sys.stderr) @@ -3992,6 +4176,9 @@ def _dispatch( with _concurrency_slot("web", web_limit, progress, start): result = run_web(args, progress, start, refs=refs) except WebUnavailable as e: + if getattr(args, "require_project", False): + sys.exit(f"required Project needs the web backend: {e}\n" + "Codex fallback is disabled by --require-project.") if _codex_token_present(): if _find_agent_browser() is None: # chrome-use isn't installed. Keep this to a *gentle* one-line @@ -4015,6 +4202,8 @@ def _dispatch( "or codex login." ) except GatewayError as e: + if getattr(args, "require_project", False): + sys.exit(str(e)) # Web was available but failed mid-generation. Don't silently burn the # Codex bucket; surface it and let the user decide (--backend codex). sys.exit( @@ -6472,10 +6661,20 @@ def main() -> int: parser.add_argument( "--project", default=_env("IMAGE_USE_PROJECT", DEFAULT_PROJECT), + metavar="NAME_OR_URL", help="(web backend) ChatGPT Project to file the conversation under — " - f"found by exact name, created if absent (default: {DEFAULT_PROJECT!r}; " - "also settable via IMAGE_USE_PROJECT). Pass an empty string " - "to use a plain top-level chat instead.", + "an existing Project landing URL, or an exact name (created if absent). " + f"Default: {DEFAULT_PROJECT!r}; " + "also settable via IMAGE_USE_PROJECT. Pass an empty string " + "to use a plain top-level chat instead. Best-effort unless --require-project.", + ) + parser.add_argument( + "--require-project", + action=argparse.BooleanOptionalAction, + default=_env_bool("IMAGE_USE_REQUIRE_PROJECT"), + help="Require a non-empty --project and verified Project routing before Send. " + "Only web/auto; disables Codex fallback. Does not imply --keep-conversation. " + "Also IMAGE_USE_REQUIRE_PROJECT=1; --no-require-project overrides it for one run.", ) parser.add_argument( "--web-model", @@ -6547,6 +6746,10 @@ def main() -> int: "(macOS `open`, Linux `xdg-open`, Windows default). Best-effort.", ) args = parser.parse_args(argv) + try: + _validate_project_args(args) + except GatewayError as e: + parser.error(str(e)) # '' / 'auto' / 'default' mean "backend decides" for the image-tool string # knobs; collapse them to None once so every consumer agrees. for _attr in ("image_model", "quality", "background", "action"): diff --git a/test_image_use.py b/test_image_use.py index 4f6fb23..2e6179f 100644 --- a/test_image_use.py +++ b/test_image_use.py @@ -4409,5 +4409,516 @@ def test_adapter_failure_is_reported_and_the_tab_still_closes(self): self.assertEqual(self.ab_calls, [("close",)]) +class WebProjectRouting(unittest.TestCase): + """Exact URL targets bypass name creation; required routing never degrades.""" + + ID = "g-p-0123456789abcdef0123456789abcdef" + URL = "https://chatgpt.com/g/" + ID + "/project" + + def test_project_url_decorations_resolve_to_one_stable_id(self): + """Display slugs, localization, and share decorations are not identity.""" + for suffix in ("", "-image-art", "-画图", "-%E7%94%BB%E5%9B%BE"): + for tail in ("/project", "/project/", "/project?source=share#top"): + with self.subTest(suffix=suffix, tail=tail): + self.assertEqual(cig._project_url_id( + "https://chatgpt.com/g/" + self.ID + suffix + tail), self.ID) + self.assertIsNone(cig._project_url_id("My art project")) + self.assertIsNone(cig._project_url_id("Art: concepts")) + + def test_url_like_invalid_targets_are_never_names(self): + """Reject malformed, external, GPT, and conversation links before creation.""" + bad = [self.URL.replace("https:", "http:"), + self.URL.replace("chatgpt.com", "example.com"), + self.URL.replace("chatgpt.com", "chatgpt.com.evil.example"), + self.URL.replace("chatgpt.com", "user@chatgpt.com"), + self.URL.replace("chatgpt.com", "chatgpt.com:443"), + self.URL.replace(self.ID, self.ID + "0"), + self.URL.replace(self.ID, "g-p-short"), + self.URL.replace("/project", "/c/01234567-89ab-cdef"), + self.URL.replace("/project", "/project/extra"), + self.URL.replace("https://", "//"), + self.URL.replace("https://", "chatgpt.com/https://"), + "https://chatgpt.com/g/g-example/project"] + for target in bad: + with self.subTest(target=target), \ + unittest.mock.patch.object(cig, "_ab_eval") as page, \ + unittest.mock.patch.object(cig, "_ab") as ab: + with self.assertRaisesRegex(cig.GatewayError, "Project URL"): + cig._enter_project("ab", "s", target, lambda: 90, lambda _: None) + page.assert_not_called() + ab.assert_not_called() + + def test_url_opens_canonical_target_without_sidebar_or_create(self): + """An existing URL opens directly, including projects not in the sidebar.""" + with unittest.mock.patch.object(cig, "_ab_eval") as page, \ + unittest.mock.patch.object(cig, "_ab") as ab, \ + unittest.mock.patch.object(cig, "_wait_composer", return_value=True): + self.assertEqual(cig._enter_project("ab", "s", + self.URL.replace("/project", "-画图/project?share=1"), + lambda: 90, lambda _: None), self.ID) + page.assert_not_called() + self.assertEqual(ab.call_args.args, ("ab", "open", self.URL)) + + def test_name_lookup_and_create_behavior_remains_available(self): + """Names keep the authenticated lookup/create flow and return its ID.""" + for created in (False, True): + messages = [] + with self.subTest(created=created), \ + unittest.mock.patch.object(cig, "_ab_eval", + return_value={"ok": True, "id": self.ID, "created": created}) as page, \ + unittest.mock.patch.object(cig, "_ab"), \ + unittest.mock.patch.object(cig, "_wait_composer", return_value=True): + self.assertEqual(cig._enter_project("ab", "s", "Art", + lambda: 90, messages.append), self.ID) + self.assertIn(json.dumps("Art"), page.call_args.args[1]) + self.assertEqual("(created)" in messages[0], created) + + def test_optional_failure_restores_plain_chat_but_required_failure_stops(self): + """Only legacy best-effort routing warns and opens a plain chat.""" + for required in (False, True): + messages = [] + with self.subTest(required=required), \ + unittest.mock.patch.object(cig, "_ab") as ab, \ + unittest.mock.patch.object(cig, "_wait_composer", return_value=False): + if required: + with self.assertRaisesRegex(cig.GatewayError, "required"): + cig._enter_project("ab", "s", self.URL, + lambda: 90, messages.append, require=True) + self.assertEqual(ab.call_count, 1) + else: + self.assertIsNone(cig._enter_project("ab", "s", self.URL, + lambda: 90, messages.append)) + self.assertEqual(ab.call_args.args, ("ab", "open", cig.WEB_NEW_CHAT_URL)) + self.assertIn("using a plain chat", messages[-1]) + + def test_required_entry_verifies_identity_after_composer_appears(self): + """A visible composer on a redirected page is not a resolved Project.""" + for actual in (None, self.ID[:-1] + "0"): + with self.subTest(actual=actual), \ + unittest.mock.patch.object(cig, "_ab_eval", return_value=actual), \ + unittest.mock.patch.object(cig, "_ab") as ab, \ + unittest.mock.patch.object(cig, "_wait_composer", return_value=True): + with self.assertRaisesRegex(cig.GatewayError, "required"): + cig._enter_project("ab", "s", self.URL, + lambda: 90, lambda _: None, require=True) + self.assertEqual(ab.call_count, 1) + + def test_required_resolution_open_and_composer_errors_never_restore_plain_chat(self): + """Every entry failure propagates before generation or plain-chat recovery.""" + cases = [("Art", {"ok": False, "error": "HTTP 403"}, None, True), + ("Art", {"ok": True, "id": "not-a-project"}, None, True), + (self.URL, self.ID, cig.GatewayError("open failed"), True), + (self.URL, self.ID, None, False), + (self.URL, cig.GatewayError("route unreadable"), None, True)] + for target, page, open_error, composer in cases: + with self.subTest(target=target, page=page, open_error=open_error), \ + unittest.mock.patch.object(cig, "_ab_eval", + **({"side_effect": page} if isinstance(page, Exception) else {"return_value": page})), \ + unittest.mock.patch.object(cig, "_ab", side_effect=open_error) as ab, \ + unittest.mock.patch.object(cig, "_wait_composer", return_value=composer): + with self.assertRaisesRegex(cig.GatewayError, "required"): + cig._enter_project("ab", "s", target, lambda: 90, lambda _: None, require=True) + self.assertNotIn(cig.WEB_NEW_CHAT_URL, [c.args[2] for c in ab.call_args_list]) + + +class RequiredProjectSubmission(unittest.TestCase): + """Preserve drafts and never replay an uncertain guarded native Send.""" + + def _run(self, project=None, arm=None, guarded=None, click_error=False, states=None): + """Simulate only transport boundaries; execute the real submit control flow.""" + self.calls, self.evals = [], [] + project_id = WebProjectRouting.ID + states = states or [_composer_state(), _composer_state("prompt"), + _composer_state(user_turns=1)] + + def page(ab, js, **kw): + """Return route and guard receipts while recording evaluated scripts.""" + self.evals.append(js) + if js == cig._JS_PROJECT_ID: + return project_id if project is None else project + result = guarded if js.startswith("JSON.stringify(window[") else arm + if isinstance(result, Exception): + raise result + return result if result is not None else {"ok": True} + + def command(*args, **kw): + """Record native transport calls and optionally lose the click response.""" + self.calls.append((args, kw)) + if click_error and args[1] == "click": + raise cig.GatewayError("native click outcome unknown") + + state_iter = iter(states) + with unittest.mock.patch.object(cig, "_ab_eval", side_effect=page), \ + unittest.mock.patch.object(cig, "_web_composer_state", + side_effect=lambda *a: next(state_iter, states[-1])), \ + unittest.mock.patch.object(cig, "_ab", side_effect=command), \ + unittest.mock.patch.object(cig.time, "sleep"): + cig._submit_web_prompt("ab", "s", "prompt", 0, lambda: 90, lambda _: None, + required_project_id=project_id) + + def test_wrong_project_stops_before_paste(self): + """A mismatched Project never receives the prompt or a Send click.""" + with self.assertRaisesRegex(cig.GatewayError, "required"): + self._run(project="g-p-ffffffffffffffffffffffffffffffff") + self.assertEqual(self.calls, []) + + def test_guard_refusal_or_unreadable_arm_prevents_native_click_and_preserves_draft(self): + """Failed guard setup retains the pasted draft and never clicks Send.""" + for result in ({"ok": False, "error": "Project route changed"}, + {}, cig.GatewayError("guard setup outcome unknown")): + with self.subTest(result=result), self.assertRaisesRegex(cig.GatewayError, "Draft retained"): + self._run(arm=result) + self.assertEqual([a[1] for a, _ in self.calls], ["fill"]) + self.assertTrue(any(js.startswith("JSON.stringify(window[") for js in self.evals)) + + def test_click_time_guard_refusal_stops_without_draft_cleanup_or_replay(self): + """A blocked native event leaves the current draft and is never retried.""" + with self.assertRaisesRegex(cig.GatewayError, "Draft retained"): + self._run(guarded={"ok": False, "error": "Project route changed"}) + self.assertEqual([a[1] for a, _ in self.calls], ["fill", "click"]) + + def test_readiness_failure_preserves_current_composer(self): + """Changed text can belong to another context; do not erase it.""" + with self.assertRaisesRegex(cig.GatewayError, "text differs"): + self._run(states=[_composer_state(), _composer_state("someone else's draft")]) + self.assertEqual([a[1] for a, _ in self.calls], ["fill"]) + + def test_correct_project_has_one_native_send_to_the_marked_button(self): + """One native click targets only the button carrying this guard token.""" + self._run() + self.assertEqual([a[1] for a, _ in self.calls], ["fill", "click"]) + selector = self.calls[-1][0][2] + self.assertRegex(selector, r'^button\[data-image-use-project-send="imageUseProjectSend_[0-9a-f]{32}"\]$') + arm_js = next(js for js in self.evals if js.startswith(cig._JS_ARM_PROJECT_SEND.split("%s")[0])) + self.assertIn(selector.split('"')[1], arm_js) + + def test_uncertain_native_click_observes_receipt_without_replay(self): + """A lost click response can be confirmed without a second Send.""" + self._run(click_error=True) + self.assertEqual([a[1] for a, _ in self.calls], ["fill", "click"]) + + def test_uncertain_send_without_receipt_stops_without_replay_or_draft_cleanup(self): + """Missing receipts cannot justify resending or clearing the current draft.""" + for outcome in ({}, cig.GatewayError("guard receipt unknown")): + with self.subTest(outcome=outcome), \ + self.assertRaisesRegex(cig.GatewayError, "Send was not repeated"): + self._run(guarded=outcome, states=[_composer_state(), _composer_state("prompt")]) + self.assertEqual([a[1] for a, _ in self.calls], ["fill", "click"]) + + def test_existing_draft_is_untouched_in_required_mode(self): + """An existing draft prevents both prompt paste and Send.""" + with self.assertRaisesRegex(cig.GatewayError, "not empty"): + self._run(states=[_composer_state("existing draft")]) + self.assertEqual(self.calls, []) + + def test_long_prompt_guard_uses_stdin_instead_of_browser_command_arguments(self): + """Embedding exact text in the guard must not reintroduce argv size limits.""" + prompt = "first\n\nUnicode 猫 — $() `text`\n" * 6000 + calls = [] + def ab(*args, **kw): + """Emulate encoded browser responses and record long-script stdin use.""" + calls.append((args, kw)) + if args[1] == "click": + return "" + result = {"ok": True} if (args[2] == "--stdin" or + args[2].startswith("JSON.stringify(window[")) else WebProjectRouting.ID + return json.dumps(json.dumps(result)) + with unittest.mock.patch.object(cig, "_ab", side_effect=ab), \ + unittest.mock.patch.object(cig, "_web_composer_state", side_effect=[ + _composer_state(), _composer_state(prompt), _composer_state(user_turns=1)]): + cig._submit_web_prompt("ab", "s", prompt, 0, lambda: 90, lambda _: None, + required_project_id=WebProjectRouting.ID) + self.assertEqual([a[1] for a, _ in calls], ["eval", "fill", "eval", "click", "eval", "eval"]) + self.assertEqual(calls[2][0], ("ab", "eval", "--stdin")) + self.assertIn(json.dumps(prompt), calls[2][1]["input_text"]) + + +@unittest.skipUnless(cig.shutil.which("node"), "Node is needed only to execute page-JS fixtures") +class RequiredProjectPageJS(unittest.TestCase): + """Execute the real capture guard, including changes between arm and native click.""" + + def test_native_click_is_guarded_at_the_event_and_cleanup_is_bounded(self): + """Wrong routes/state block the app handler; expiry removes only our marker.""" + project_id, token = WebProjectRouting.ID, "imageUseProjectSend_fixture" + script = cig._JS_ARM_PROJECT_SEND % ( + json.dumps(project_id), json.dumps(token), cig._JS_PROJECT_ID, + cig._JS_WEB_COMPOSER_STATE % cig._js_composer_selectors(), + json.dumps("prompt"), 0, 0, + json.dumps(cig.WEB_COMPOSER_SEL), json.dumps(cig.WEB_SEND_SEL)) + path = "/g/" + project_id + "/project" + cases = [ + (path, {}, 1), + ("/g/" + project_id + "-画图/c/12345678-90ab", {}, 1), + ("/g/" + project_id + "-%E7%94%BB%E5%9B%BE/project/", {}, 1), + ("/", {}, 0), ("/c/12345678-90ab", {}, 0), + ("/g/" + project_id + "0/project", {}, 0), + ("/g/g-p-ffffffffffffffffffffffffffffffff/project", {}, 0), + (path, {"origin": "https://example.com"}, 0), + (path, {"text": "changed"}, 0), (path, {"attachments": 1}, 0), + (path, {"turns": 1}, 0), (path, {"disabled": True}, 0), + (path, {"busy": True}, 0), (path, {"dialog": True}, 0), + (path, {"composer": False}, 0), + (path, {"latePath": "/"}, 0), + (path, {"latePath": "/g/g-p-ffffffffffffffffffffffffffffffff/project"}, 0), + (path, {"lateText": "another draft"}, 0), + (path, {"lateAttachments": 1}, 0), (path, {"lateTurns": 1}, 0), + (path, {"lateDialog": True}, 0), (path, {"expired": True}, 0), + (path, {"newDocument": True}, 0), + ] + harness = r""" +const input = JSON.parse(require('fs').readFileSync(0, 'utf8')); +const results = input.cases.map(([pathname, cfg]) => { + let clicks = 0, listener = null, expiry = null; + const attributes = {'data-owner': 'preserve'}; + const window = { + addEventListener: (name, fn) => {listener = fn;}, + removeEventListener: () => {listener = null;}, + }; + const setTimeout = fn => {expiry = fn; return 1;}; + const clearTimeout = () => {expiry = null;}; + const send = { + disabled: !!cfg.disabled, + setAttribute: (key, value) => {attributes[key] = value;}, + removeAttribute: key => {delete attributes[key];}, + getAttribute: key => attributes[key] || null, + closest: () => send, + click: () => { + let blocked = false; + const event = {target: send, preventDefault: () => {blocked = true;}, + stopImmediatePropagation: () => {blocked = true;}}; + if (listener) listener(event); + if (!blocked) clicks++; // stand-in for the app's Send handler + }, + }; + const form = { + querySelector: sel => sel === input.send ? send : (cfg.busy ? {} : null), + querySelectorAll: () => Array.from({length: cfg.attachments || 0}, + () => ({src: 'https://chatgpt.com/backend-api/estuary/content?id=fixture'})), + }; + const composer = {value: cfg.text || 'prompt', childNodes: [], closest: () => form}; + const document = { + querySelector: sel => sel === input.composer ? (cfg.composer === false ? null : composer) + : (cfg.dialog ? {} : null), + querySelectorAll: () => Array.from({length: cfg.turns || 0}, () => ({ + parentElement: {closest: () => null}, closest: () => null, + })), + }; + const location = {pathname, origin: cfg.origin || 'https://chatgpt.com'}; + const armed = JSON.parse(eval(input.script)); + if (armed.ok) { + if (cfg.latePath) location.pathname = cfg.latePath; + if (cfg.lateText) composer.value = cfg.lateText; + if (cfg.lateAttachments) cfg.attachments = cfg.lateAttachments; + if (cfg.lateTurns) cfg.turns = cfg.lateTurns; + if (cfg.lateDialog) cfg.dialog = true; + if (cfg.expired) expiry(); + if (cfg.newDocument) { // a new document cannot carry the armed marker + delete attributes['data-image-use-project-send']; + delete window[input.token]; listener = null; + } + if (attributes['data-image-use-project-send'] === input.token) send.click(); + } + const receipt = window[input.token] ? window[input.token].finish() : null; + return {clicks, marker: attributes['data-image-use-project-send'] || null, + listener: !!listener, owner: attributes['data-owner'], text: composer.value}; +}); +process.stdout.write(JSON.stringify(results)); +""" + proc = subprocess.run([cig.shutil.which("node"), "-e", harness], text=True, + input=json.dumps({"script": script, "cases": cases, "token": token, + "composer": cig.WEB_COMPOSER_SEL, "send": cig.WEB_SEND_SEL}), + capture_output=True, timeout=10) + self.assertEqual(proc.returncode, 0, proc.stderr) + for case, result in zip(cases, json.loads(proc.stdout)): + with self.subTest(case=case): + cfg = case[1] + self.assertEqual(result, {"clicks": case[2], "marker": None, "listener": False, + "owner": "preserve", "text": cfg.get("lateText", cfg.get("text", "prompt"))}) + + +class RequiredProjectIntegration(unittest.TestCase): + """Carry resolved identity through run_web, with retention independently controlled.""" + + def test_run_web_passes_required_identity_and_respects_retention_flags(self): + """Resolved identity reaches generation while retention controls deletion.""" + from contextlib import ExitStack + for required, keep, keep_tab in ((True, False, False), (True, True, False), + (True, False, True), (False, False, False)): + args = argparse.Namespace(backend="web", project=WebProjectRouting.URL, + require_project=required, keep_conversation=keep, keep_tab=keep_tab, + profile="relay", session="fixture", timeout=90, gen_prompt="image", + size="auto", web_model="") + with self.subTest(required=required, keep=keep, keep_tab=keep_tab), ExitStack() as stack: + for name, result in (("_find_agent_browser", "ab"), ("_relay_connected", True), + ("_wait_composer", True), ("_ref_counts", (0, 0, 0))): + stack.enter_context(unittest.mock.patch.object(cig, name, return_value=result)) + for name in ("_warn_if_chrome_use_old", "_ensure_web_model", "_ab"): + stack.enter_context(unittest.mock.patch.object(cig, name)) + entry = stack.enter_context(unittest.mock.patch.object(cig, "_enter_project", + return_value=WebProjectRouting.ID)) + generate = stack.enter_context(unittest.mock.patch.object(cig, "_generate_in_browser", + return_value=(b"image", {}))) + delete = stack.enter_context(unittest.mock.patch.object(cig, "_delete_conversation")) + cig.run_web(args, False, 0) + self.assertEqual(entry.call_args.kwargs["require"], required) + self.assertEqual(generate.call_args.kwargs["required_project_id"], + WebProjectRouting.ID if required else None) + self.assertEqual(delete.call_count, int(not keep and not keep_tab)) + + def test_required_route_failure_stops_before_reference_resolution_or_upload(self): + """Route rejection prevents reference work and prompt submission.""" + with unittest.mock.patch.object(cig, "_ab_eval", return_value=None), \ + unittest.mock.patch.object(cig, "_resolve_ref_path") as resolve, \ + unittest.mock.patch.object(cig, "_upload_references") as upload, \ + unittest.mock.patch.object(cig, "_submit_web_prompt") as submit: + with self.assertRaisesRegex(cig.GatewayError, "required"): + cig._generate_in_browser("ab", "s", "prompt", argparse.Namespace(), + 90, lambda: 90, lambda _: None, refs=["fixture.png"], + required_project_id=WebProjectRouting.ID) + resolve.assert_not_called() + upload.assert_not_called() + submit.assert_not_called() + + def test_cli_env_flag_and_retention_are_independent(self): + """The real parser consumes env/flags before styles or backend work.""" + parse_args = argparse.ArgumentParser.parse_args + for env, flags, expected, keep in ( + ({"IMAGE_USE_REQUIRE_PROJECT": "1"}, [], True, False), + ({"CHATGPT_IMAGEGEN_REQUIRE_PROJECT": "1"}, [], True, False), + ({"IMAGE_USE_REQUIRE_PROJECT": "0", "CHATGPT_IMAGEGEN_REQUIRE_PROJECT": "1"}, [], False, False), + ({"IMAGE_USE_REQUIRE_PROJECT": "1"}, ["--no-require-project"], False, False), + ({"CHATGPT_IMAGEGEN_REQUIRE_PROJECT": "1"}, ["--no-require-project"], False, False), + ({"IMAGE_USE_REQUIRE_PROJECT": "0"}, ["--require-project"], True, False), + ({}, ["--require-project", "--no-require-project"], False, False), + ({}, ["--no-require-project", "--require-project"], True, False), + ({"IMAGE_USE_REQUIRE_PROJECT": "1"}, ["--no-require-project", "--keep-conversation"], False, True), + ({}, ["--require-project", "--keep-conversation"], True, True), + ): + args_seen = [] + def record_args(parser, argv): + """Record real parser output before any styles or backend work.""" + args = parse_args(parser, argv) + args_seen.append(args) + return args + with self.subTest(env=env, flags=flags), \ + unittest.mock.patch.dict(os.environ, env, clear=True), \ + unittest.mock.patch.object(sys, "argv", ["image-use", "prompt", *flags]), \ + unittest.mock.patch.object(cig, "_should_check_for_update", return_value=False), \ + unittest.mock.patch.object(argparse.ArgumentParser, "parse_args", + autospec=True, side_effect=record_args), \ + unittest.mock.patch.object(cig, "_load_styles", + side_effect=RuntimeError("parser checkpoint")): + with self.assertRaisesRegex(RuntimeError, "parser checkpoint"): + cig.main() + self.assertEqual(args_seen[0].require_project, expected) + self.assertEqual(args_seen[0].keep_conversation, keep) + + def test_cli_opt_out_allows_plain_chat_or_non_web_backend_without_changing_env(self): + """A per-run opt-out passes validation while the exported default stays on.""" + parse_args = argparse.ArgumentParser.parse_args + for env_name in ("IMAGE_USE_REQUIRE_PROJECT", "CHATGPT_IMAGEGEN_REQUIRE_PROJECT"): + for backend, project in (("web", ""), ("auto", ""), ("codex", "Art"), + ("gemini", "Art"), ("agy", "Art")): + args_seen = [] + def record_args(parser, argv): + """Observe the actual CLI parser without bypassing validation.""" + args = parse_args(parser, argv) + args_seen.append(args) + return args + with self.subTest(env=env_name, backend=backend, project=project), \ + unittest.mock.patch.dict(os.environ, {env_name: "1"}, clear=True), \ + unittest.mock.patch.object(sys, "argv", ["image-use", "prompt", + "--no-require-project", "--backend", backend, "--project", project]), \ + unittest.mock.patch.object(cig, "_should_check_for_update", return_value=False), \ + unittest.mock.patch.object(argparse.ArgumentParser, "parse_args", + autospec=True, side_effect=record_args), \ + unittest.mock.patch.object(cig, "_load_styles", + side_effect=RuntimeError("parser checkpoint")), \ + unittest.mock.patch.object(cig, "_dispatch") as dispatch: + with self.assertRaisesRegex(RuntimeError, "parser checkpoint"): + cig.main() + self.assertEqual(os.environ[env_name], "1") + self.assertFalse(args_seen[0].require_project) + self.assertEqual(args_seen[0].backend, backend) + self.assertEqual(args_seen[0].project, project) + dispatch.assert_not_called() + + def test_cli_invalid_route_stops_before_styles_or_backend(self): + """Invalid required targets and backends fail before downstream work.""" + for flags in (["--require-project", "--project", ""], + ["--require-project", "--backend", "codex"], + ["--project", "https://example.com/project"]): + with self.subTest(flags=flags), \ + unittest.mock.patch.dict(os.environ, {}, clear=True), \ + unittest.mock.patch.object(sys, "argv", ["image-use", "prompt", *flags]), \ + unittest.mock.patch.object(cig, "_should_check_for_update", return_value=False), \ + unittest.mock.patch.object(cig, "_load_styles") as styles, \ + unittest.mock.patch.object(cig, "_dispatch") as dispatch, redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as error: + cig.main() + self.assertEqual(error.exception.code, 2) + styles.assert_not_called() + dispatch.assert_not_called() + + +class RequiredProjectDispatch(unittest.TestCase): + """The real dispatch boundary rejects incompatible backends and fallbacks.""" + + def setUp(self): + """Provide a valid required-Project request for dispatch boundary tests.""" + self.args = argparse.Namespace(backend="auto", project="Art", require_project=True, + ref=None, resolved_refs=None) + + @contextmanager + def _backends(self, failure=None): + """Record every backend/token boundary without opening a browser or lock.""" + from contextlib import ExitStack, nullcontext + with ExitStack() as stack: + stack.enter_context(unittest.mock.patch.object(cig, "_codex_only_options", return_value=[])) + for lock in ("_chatgpt_web_turn", "_concurrency_slot"): + stack.enter_context(unittest.mock.patch.object(cig, lock, side_effect=lambda *a: nullcontext())) + calls = {name: stack.enter_context(unittest.mock.patch.object(cig, name)) + for name in ("run_web", "run_codex", "run_gemini", "run_agy", "_codex_token_present")} + calls["run_web"].side_effect = failure + calls["run_web"].return_value = (b"image", {}) + yield calls + + def test_required_unavailable_web_never_reads_codex_token_or_falls_back(self): + """Unavailable web stops required mode before Codex credentials or calls.""" + with self._backends(cig.WebUnavailable("relay offline")) as calls: + with self.assertRaisesRegex(SystemExit, "required"): + cig._dispatch(self.args, False, 300, 0) + calls["run_web"].assert_called_once() + calls["run_codex"].assert_not_called() + calls["_codex_token_present"].assert_not_called() + + def test_required_non_web_backends_and_empty_target_stop_before_work(self): + """Reject explicit incompatible routes, even when their clients are ready.""" + for backend, project in (("codex", "Art"), ("gemini", "Art"), + ("agy", "Art"), ("auto", ""), ("web", " ")): + with self.subTest(backend=backend, project=project), self._backends() as calls: + self.args.backend, self.args.project = backend, project + with self.assertRaises(SystemExit): + cig._dispatch(self.args, False, 300, 0) + for call in calls.values(): + call.assert_not_called() + + def test_required_generation_error_does_not_suggest_codex(self): + """Required-mode failures never recommend switching to Codex.""" + with self._backends(cig.GatewayError("required Project changed")) as calls: + with self.assertRaises(SystemExit) as error: + cig._dispatch(self.args, False, 300, 0) + self.assertNotIn("rerun with --backend codex", str(error.exception)) + calls["run_codex"].assert_not_called() + + def test_optional_auto_fallback_is_unchanged(self): + """Existing users retain fallback when they have not required a Project.""" + self.args.require_project = False + with self._backends(cig.WebUnavailable("relay offline")) as calls: + calls["_codex_token_present"].return_value = True + cig._dispatch(self.args, False, 300, 0) + calls["run_codex"].assert_called_once() + + if __name__ == "__main__": unittest.main()