diff --git a/README.md b/README.md index 3035270..4849593 100644 --- a/README.md +++ b/README.md @@ -432,7 +432,7 @@ Then either pick **codex** in `harvey setup` (it suggests `gpt-5.5`), run `harve ```yaml brain: provider: codex - model: gpt-5.5 # the verified text-only Codex model (see Models) + model: gpt-5.5 # or an available Luna, Sol, or Terra model ID reasoning_effort: "" # optional: minimal | low | medium | high | xhigh ``` @@ -443,7 +443,7 @@ What to know: - **No tools, verified before every prompt.** Prompts contain untrusted text (prospect replies, scraped pages), so the model must have nothing to act with. Harvey disables every tool-providing Codex feature, web search, sub-agents, hooks, skills and project instructions, starts the thread with no execution environment, and then checks Codex's own reports (effective config, account type, thread settings, MCP status, hooks) before sending anything. Anything it can't confirm stops the call. During the turn, any attempted tool call, approval request or non-text item kills the runtime. A read-only sandbox is also set, as extra protection only. - **Your login only goes to OpenAI.** Before your login is put into the runtime at all, Harvey starts Codex without it and checks that requests would go to the built-in OpenAI provider and the default ChatGPT endpoint. Any custom provider, overridden built-in provider, base URL, telemetry exporter or other endpoint in any Codex config layer (for example `/etc/codex/config.toml` or a managed config) stops the call there. - **Login handling.** Harvey supports Codex's file credential storage (`~/.codex/auth.json`, or `$CODEX_HOME/auth.json`, mode `600`): the default unless you've configured an OS keyring. Keyring storage isn't supported. On every call Harvey reads that file (read-only, never writing or moving it) and gives the runtime a private copy *without the refresh token*, deleted after the call. Codex therefore can't change or renew your real login. When the login has expired, or is due for Codex's routine renewal (after about 8 days), Codex calls stop with "Your Codex login has expired or is due for renewal". Use Codex once yourself (it renews the login automatically) or run `codex login`, and Harvey picks up the renewed login on its next call. Harvey never renews it for you. -- **Models.** Use `model: gpt-5.5`. It's the model Harvey has verified as text-only with Codex 0.159.3 (no code mode, no extra tools), and it works on ChatGPT plans even though Codex's live catalog hides it from the model picker. Codex's newer "code mode" models (`gpt-6-*`, `gpt-5.6-*`) always carry built-in code-execution tools that no setting removes, so Harvey refuses them. If you leave `model` blank, Harvey only auto-selects a model the catalog *lists* and that has no code mode or extra tools. On current ChatGPT accounts no listed model qualifies, so a blank `model` fails with an error naming the hidden models you can set explicitly, plus each model's reason. If you set `model`, Harvey uses exactly that model or fails; it never substitutes another. Naming a code-mode model gives an error listing the models that work. +- **Models.** Set `brain.model` to an exact model ID from your account's catalog, including Luna, Sol, and Terra models such as `gpt-5.6-luna`, `gpt-6.1-sol`, and `gpt-5.6-terra`. `gpt-5.5` remains supported when present in your account's catalog and is the setup suggestion. Harvey creates a private catalog copy that preserves model capabilities while selecting direct tool mode and removing experimental tools. The model receives no tools under Harvey's lockdown. A blank `model` selects the highest-priority listed model that can use this configuration; hidden models require an explicit ID. An explicit model is used exactly or the call fails. Unknown model IDs and unrecognized tool modes are rejected. Availability depends on your ChatGPT account. - **Quota.** The budget gate and dashboard read your ChatGPT plan's real rate-limit windows from Codex, for the limit bucket that applies to your model, and compare them to `usage.max_daily_claude_percent`. If Codex reports that usage is blocked, or a call fails on the plan's usage limit, Harvey treats the plan as full (even when no window is reported) and stops Codex calls for an hour. If the limits can't be read, the dashboard and `harvey usage` say "Quota unavailable" and the budget falls back to Harvey's own daily call count. Usage is recorded per provider; Codex calls are logged with tokens but no dollar cost, because the plan isn't billed per token. - **When Codex can't be used, Harvey pauses rather than guessing.** Before each heartbeat, Harvey checks everything a call needs except the prompt itself: the Codex CLI version, the isolation, your login (expired or due for renewal), your ChatGPT account, and that your configured model can run with no tools. No prompt is sent, so nothing is used from your plan beyond a quick account check. If something is wrong, the whole cycle is skipped: no reply is read or marked handled, nothing is drafted or sent. The log line, `harvey status`, and the dashboard's Actions card say "Codex paused" and why. If Codex stops working part-way through a cycle (for example the plan hits its usage limit), the reply being handled is left exactly as it was for a clean retry, not escalated, half-recorded or lost, and that cycle sends nothing. Fix the cause (usually `codex login`), and Harvey resumes on its next heartbeat: a renewed login or reinstalled CLI is noticed immediately, a usage-limit pause ends after an hour, and other fixes are picked up within 30 minutes. `harvey status --check` runs the same check on demand and exits non-zero if it fails. Brief failures (a crash or timeout) just skip one cycle. Harvey never falls back to Claude or an API key. - **Requirements.** **macOS or Linux**, Codex CLI **0.159.3** exactly, and Node.js for its npm package. **Windows isn't supported for Codex:** Harvey refuses it there before reading your login; use the Claude provider on Windows. Harvey checks the Codex version before every call and refuses any other version, including newer ones, because a different Codex can add tools Harvey's lockdown doesn't know about. Support for a new version comes with a Harvey update. Live use with a real ChatGPT account has been verified on macOS and ARM64 Linux. The offline suites also passed on x86-64 Linux under CPU emulation; CI covers Linux and macOS. The Docker image ships the Claude CLI only, so Codex isn't supported in Docker. To re-verify the lockdown offline against your installed CLI, with no credentials or network: `HARVEY_CODEX_CLI_TESTS=1 pytest tests/test_codex_cli_surface.py`. @@ -487,7 +487,7 @@ Harvey automates outreach, but **you are the sender.** Cold email is legal in mo **Claude headless mode fails** — `claude login`, and confirm the subscription is active. Test with `claude -p "say hi"`. In Docker, mount `~/.claude` into the container. -**Codex calls fail** — Read the log line; every Codex error says what to do. "Not logged in" or "expired or is due for renewal" → use Codex once or run `codex login` with ChatGPT. "Hasn't been verified with Harvey" → install the supported Codex version named in the message. "Isolation could not be verified" → a Codex config layer outside your `~/.codex` (for example a managed or `/etc/codex` config) forces a tool, hook, MCP server, instructions or custom endpoint back on; Harvey refuses to send prompts until it's gone. "Always exposes tools" or "no Codex model is available" → set `brain.model: gpt-5.5` (the message lists each model and why). Harvey never switches to Claude on its own. +**Codex calls fail** — Read the log line; every Codex error says what to do. "Not logged in" or "expired or is due for renewal" → use Codex once or run `codex login` with ChatGPT. "Hasn't been verified with Harvey" → install the supported Codex version named in the message. "Isolation could not be verified" → a Codex config layer outside your `~/.codex` (for example a managed or `/etc/codex` config) forces a tool, hook, MCP server, instructions or custom endpoint back on; Harvey refuses to send prompts until it's gone. Model catalog errors → choose an available model ID from the catalog shown in the message, or leave `brain.model` blank to select a listed model. Harvey never switches to Claude on its own. **Discovery finds nothing** — Confirm signals first (`harvey signals --confirm free`); nothing is collected until you do. If you're on the free OpenStreetMap source, it only covers mapped trades and is thin for service-area businesses — `harvey discover --providers` shows the alternatives. diff --git a/harvey/codex.py b/harvey/codex.py index 98dfd9d..4fe3b97 100644 --- a/harvey/codex.py +++ b/harvey/codex.py @@ -19,9 +19,9 @@ endpoint, ChatGPT-only login, file credential storage, no custom provider definitions and no other endpoint anywhere in the config. Only then is the login exposed: the runtime is restarted with it, and checked again. -2. The model must not be one of Codex's "code mode" models, which always - carry code-execution tools no setting removes (checked against the model - catalog ``codex debug models`` reports for this runtime). +2. The model must exist in the runtime catalog. A private copy keeps its + capabilities while forcing direct tool mode and no experimental tools. + The restarted runtime must report that exact private catalog path. 3. ``account/read`` must report a ChatGPT account (never an API key, so a call is never billed per token). 4. The thread starts with no environments (no exec or file-edit tools), no @@ -94,9 +94,8 @@ def require_supported_platform() -> None: # tool Harvey's lockdown doesn't know about. SUPPORTED_CODEX_VERSIONS = ("0.159.3",) CODEX_INSTALL_HINT = f"npm install -g @openai/codex@{SUPPORTED_CODEX_VERSIONS[-1]}" -# The model setup suggests: verified text-only (no code mode, no extra tools) -# with 0.159.3, and callable on ChatGPT plans even where the live catalog -# hides it from the picker, so blank auto-selection can't find it. +# Keep the setup suggestion stable. Explicit newer models and catalog-based +# auto-selection use the same private text-only catalog. SUGGESTED_MODEL = "gpt-5.5" # An access token closer than this to expiry is treated as expired: the @@ -528,7 +527,7 @@ def build_child_env(private_root: str, codex_home: str, base_env=None) -> dict: return env -def attest_effective_config(response) -> list[str]: +def attest_effective_config(response, model_catalog_path: str = "") -> list[str]: """Check config/read output; return inherited MCP server names.""" if not isinstance(response, dict) or not isinstance(response.get("config"), dict): raise _isolation_error("config/read returned no effective config") @@ -544,6 +543,10 @@ def attest_effective_config(response) -> list[str]: if config.get("chatgpt_base_url") not in (None, DEFAULT_CHATGPT_BASE_URL): raise _isolation_error("a custom ChatGPT endpoint is configured") for key in MUST_BE_EMPTY: + if key == "model_catalog_json" and model_catalog_path: + if config.get(key) != model_catalog_path: + raise _isolation_error("the private model catalog was overridden") + continue if config.get(key) not in (None, "", [], {}): raise _isolation_error(f"setting '{key}' is set by another config layer") endpoint = _find_custom_endpoint(config) @@ -666,21 +669,33 @@ def parse_model_catalog(raw: bytes) -> dict: def model_tools_removable(entry) -> bool: - """Whether Harvey's lockdown leaves this model with no tools at all. + """Whether an effective catalog entry respects the text-only lockdown. - Models in Codex's code mode (``tool_mode``) always get its JavaScript - ``exec``/``wait``/user-input tools, and some get extra experimental - tools; no supported setting removes them. Verified against the Codex - CLI with a local mock model server: other models receive an empty tool - list under Harvey's lockdown. + Direct mode adds no execution wrapper. With no environments and all + tool-providing features disabled, the pinned CLI sends no tools. """ return ( isinstance(entry, dict) - and entry.get("tool_mode") in (None, "") + and entry.get("tool_mode") in (None, "", "direct") and not entry.get("experimental_supported_tools") ) +def text_only_catalog(catalog: dict) -> dict: + """Keep model capabilities, but remove model-forced tools in our runtime. + + The pinned CLI honors a private model_catalog_json before building its + tool registry. Direct mode respects the existing no-environments and + disabled-feature lockdown. Unknown tool modes still fail closed. + """ + return { + slug: {**entry, "tool_mode": "direct", "experimental_supported_tools": []} + if entry.get("tool_mode") in (None, "", "direct", "code_mode", "code_mode_only") + else dict(entry) + for slug, entry in catalog.items() + } + + def _safe_name(value) -> str: """Catalog text (from the backend) made safe to log.""" return re.sub(r"[^A-Za-z0-9._:-]", "?", str(value))[:40] @@ -690,7 +705,7 @@ def describe_model(entry) -> str: """Why a catalog entry can or can't be used, in a few words.""" if not isinstance(entry, dict): return "unreadable" - if entry.get("tool_mode") not in (None, ""): + if entry.get("tool_mode") not in (None, "", "direct"): return f"code mode ({_safe_name(entry['tool_mode'])})" extra = entry.get("experimental_supported_tools") if extra: @@ -937,6 +952,7 @@ def __init__(self, codex_path: str): self.codex_home = "" self.workspace = "" self.version = "" + self.model_catalog_path = "" self.proc: asyncio.subprocess.Process | None = None self._next_id = 1 self._stderr_tail = b"" @@ -976,6 +992,17 @@ def install_auth(self, snapshot: dict): kind="runtime", ) from None + def install_model_catalog(self, catalog: dict) -> None: + """Install Harvey's generated catalog in this disposable runtime only.""" + self.model_catalog_path = os.path.join(self.codex_home, "text-models.json") + with open(self.model_catalog_path, "x", encoding="utf-8") as f: + json.dump({"models": list(catalog.values())}, f) + path = os.path.join(self.codex_home, "config.toml") + with open(path, encoding="utf-8") as f: + config = f.read() + with open(path, "w", encoding="utf-8") as f: + f.write(f"model_catalog_json = {_toml_value(self.model_catalog_path)}\n" + config) + def _child_kwargs(self) -> dict: # Own process group, so the whole tree can be killed. return {"cwd": self.workspace, "env": build_child_env(self.root, self.codex_home), @@ -1307,12 +1334,14 @@ async def _open_session(self, server: _AppServer, deadline: float, server.install_auth(snapshot) model = self.model if toolless_model or not model: - catalog = await server.read_catalog(deadline) + catalog = text_only_catalog(await server.read_catalog(deadline)) model = model or self._default_model(catalog) if toolless_model: require_toolless_model(catalog, model) + server.install_model_catalog(catalog) await server.launch(deadline) - mcp_names = attest_effective_config(await server.read_config(deadline)) + mcp_names = attest_effective_config( + await server.read_config(deadline), server.model_catalog_path) await self._require_chatgpt(server, deadline) return model, mcp_names @@ -1321,9 +1350,10 @@ async def _attested_thread(self, server: _AppServer, deadline: float) -> tuple[s the configured model; returns (thread id, model). Nothing is sent to the model.""" model, mcp_names = await self._open_session(server, deadline, toolless_model=True) + params = build_thread_params(server.workspace, model, mcp_names) + params["config"]["model_catalog_json"] = server.model_catalog_path thread_id, model = attest_thread(await server.request( - "thread/start", build_thread_params(server.workspace, model, mcp_names), - deadline), model) + "thread/start", params, deadline), model) attest_mcp_status( await self._mcp_status_pages(server, thread_id, deadline), mcp_names) attest_hooks(await server.request( diff --git a/harvey/web/index.html b/harvey/web/index.html index b092374..bc5da05 100644 --- a/harvey/web/index.html +++ b/harvey/web/index.html @@ -403,7 +403,7 @@
brain.provider: codex, set brain.model: gpt-5.5 (the verified text-only model; a blank model fails on current ChatGPT accounts and code-mode models are refused), run codex login and choose "Sign in with ChatGPT" (API-key logins are refused). Harvey never renews your Codex login: if the log says it has expired or is due for renewal, use Codex once or run codex login. Harvey needs Codex CLI 0.159.3 on macOS or Linux (Codex isn't supported on Windows; use Claude there) and refuses other versions. While Codex can't be used, Harvey pauses (the Actions card shows "Codex paused" and why), leaves replies and queued emails untouched, and resumes once it's fixed; harvey status --check checks on demand. If the log says isolation could not be verified, a Codex config layer is forcing a tool, hook, MCP server or custom endpoint back on — Harvey refuses to send prompts until it is removed. Harvey never falls back to Claude on its own.brain.provider: codex, set brain.model to an available model ID, including Luna, Sol, or Terra (for example gpt-6.1-sol), or leave it blank to select a listed model. Harvey runs these models without tools; gpt-5.5 also remains supported when present in your account's catalog. Run codex login and choose "Sign in with ChatGPT" (API-key logins are refused). Harvey never renews your Codex login: if the log says it has expired or is due for renewal, use Codex once or run codex login. Harvey needs Codex CLI 0.159.3 on macOS or Linux (Codex isn't supported on Windows; use Claude there) and refuses other versions. While Codex can't be used, Harvey pauses (the Actions card shows "Codex paused" and why), leaves replies and queued emails untouched, and resumes once it's fixed; harvey status --check checks on demand. If the log says isolation could not be verified, a Codex config layer is forcing a tool, hook, MCP server or custom endpoint back on — Harvey refuses to send prompts until it is removed. Harvey never falls back to Claude on its own.