Skip to content
Merged
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ versioning: [SemVer](https://semver.org/).
clarification request — that auto-proceed round overwrote the real report
and burned DR quota. Clarification detection now applies only to short
(≤ 1200 char) done-texts.
- PII redaction no longer corrupts calendar dates: "2026-05-26" (and
`DD-MM-YYYY` / datetime / date-range forms) satisfied the phone-number
pattern and came back as `<PHONE>` in memories, tasks, and conversation
titles. Phone masking still applies to actual phone shapes — including a
phone that follows a date inside the same greedy match
("2026-05-26 617-555-0123" keeps the date, masks the phone).
- `deep_research` / `deep_research_heavy` now append an explicit "⚠ Report
may be incomplete" note when the SSE stream ended without the server
marking the response finished (`terminated_abnormally`) or when completion
polling timed out — previously a truncated report was indistinguishable
from a complete one.
- `gpt2agent install`: writing a symlinked agent config (dotfile-repo
setups) now writes through to the symlink target instead of replacing the
link with a plain file and stranding the real config.
- `gpt2agent setup`: `~/.gpt2agent/config.toml` is backed up
(`.bak-gpt2agent`) before being overwritten and is written atomically;
a rewrite with identical content is a no-op.
- Removed the `browser-use` session-cookie fallback that saved a NextAuth
session cookie as `access_token` — the saved "token" 401'd on every API
call. Extraction now fails visibly so the user can paste a real token.
The manual paste prompt likewise no longer suggests the
`__Secure-next-auth.session-token` cookie and refuses to save values that
are not 3-segment JWTs (a pasted session cookie only produced 401s).

## [0.0.8] - 2026-06-27

Expand Down
36 changes: 18 additions & 18 deletions gpt2agent/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,23 @@ def _from_browser() -> dict | None:
print(
' copy(JSON.parse(localStorage["@@auth0spajs@@::..."] || "{}").body?.access_token'
)
print(" OR go to:")
print(" Application → Cookies → __Secure-next-auth.session-token")
print()
print(" Then paste the token below.")
print(" Then paste the access_token below. Browser cookies such as")
print(" __Secure-next-auth.session-token are NOT access tokens — the API")
print(" rejects them with 401, so they are not accepted here.")
print(" (Tip: running `codex login` once lets gpt2agent reuse that token automatically.)")
print()
webbrowser.open("https://chat.openai.com")
token = input(" Paste access_token (or session token): ").strip()
if token:
return {"access_token": token, "source": "browser"}
return None
token = input(" Paste access_token: ").strip()
if not token:
return None
if not token.startswith("eyJ") or token.count(".") != 2:
# ChatGPT access tokens are 3-segment JWTs; session cookies (5-segment
# JWE) or random strings only produce 401s downstream — fail here.
print(" That does not look like a JWT access_token (expected eyJ...x.y.z);")
print(" not saving it. Use `codex login` for the reliable path.")
return None
return {"access_token": token, "source": "browser"}


def _from_browser_use() -> dict | None:
Expand Down Expand Up @@ -122,17 +128,11 @@ def _from_browser_use() -> dict | None:
except Exception:
pass

# Try cookies fallback
result = subprocess.run(
["browser-use", "cookies", "get", "--url", "https://chat.openai.com"],
capture_output=True,
text=True,
timeout=15,
)
cookies = json.loads(result.stdout or "[]")
for c in cookies:
if "session-token" in c.get("name", ""):
return {"access_token": c["value"], "source": "browser-use-cookie"}
# No cookie fallback: the __Secure-next-auth.session-token cookie is a
# NextAuth session cookie, not the chatgpt.com-scoped access-token JWT
# the backend sends as `Authorization: Bearer` — saving it "succeeds"
# here and then every API call 401s. Better to fail visibly.
print(" browser-use found no access_token in localStorage.")

except Exception as e:
print(f" browser-use extraction failed: {e}")
Expand Down
5 changes: 5 additions & 0 deletions gpt2agent/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ def _atomic_write(path: Path, content: str) -> None:
contain MCP server commands that the agent will exec, so they should not
be world-readable on shared systems.
"""
if path.is_symlink():
# Users symlink agent configs into dotfile repos; rename() over the
# symlink would replace the link with a plain file and strand the real
# target. Write through to the target instead.
path = path.resolve()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + f".tmp-{os.getpid()}")
# Open the temp file at 0o600 up front so a secret-bearing config is never
Expand Down
29 changes: 29 additions & 0 deletions gpt2agent/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ def load_config(path: Path | None = None) -> dict[str, Any]:
# ── server ───────────────────────────────────────────────────────────────────


def _dr_incomplete_note(timed_out: bool) -> str:
"""Marker appended when a DR stream never reached finished_successfully.

Without it, a truncated or timed-out report is indistinguishable from a
complete one — the caller would archive partial research as final.
"""
note = (
"\n\n---\n**⚠ Report may be incomplete** — the stream ended before the "
"server marked the response finished"
)
if timed_out:
note += " (completion polling timed out)"
return note + ". Retry, or use get_conversation to check for a fuller report."


def build_server(cfg: dict[str, Any]) -> FastMCP:
srv = cfg["server"]
models = cfg["models"]
Expand Down Expand Up @@ -149,13 +164,17 @@ async def deep_research(query: str, auto_confirm: bool = True) -> str:
final_text = ""
tool_calls: list[str] = []
refs: list = []
truncated = False
timed_out = False

async for event in conv.deep_research(q):
if event["type"] == "tool":
tool_calls.append(event["call"])
elif event["type"] == "done":
final_text = event["text"]
refs = event.get("content_references", [])
truncated = bool(event.get("terminated_abnormally"))
timed_out = bool(event.get("timeout"))
Comment on lines +167 to +177

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Incomplete note never fires for timeout-only case.

The PR objective is to warn when a report "may be incomplete due to timeout or abnormal termination," but the gate on line 192 (if truncated:) and line 255 only checks terminated_abnormally. If a done event sets timeout: true without terminated_abnormally, timed_out is computed but never consulted for the append decision — the note is silently skipped. Same bug in both deep_research (line 192) and deep_research_heavy (line 255).

This matches the regression tests too: only terminated_abnormally=True is exercised; there's no test asserting the note appears when only timeout is set.

🐛 Proposed fix (apply to both tools)
-                truncated = bool(event.get("terminated_abnormally"))
-                timed_out = bool(event.get("timeout"))
+                timed_out = bool(event.get("timeout"))
+                truncated = bool(event.get("terminated_abnormally")) or timed_out

Also applies to: 192-194, 214-225, 255-257

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gpt2agent/server.py` around lines 167 - 177, The incomplete-note gate in
deep_research and deep_research_heavy only checks terminated_abnormally via
truncated, so timeout-only completions never append the warning. Update the
done-event handling in both paths to treat timed_out as part of the same
incomplete condition, and make the final append decision use the timeout flag as
well as abnormal termination. Verify the logic around the deep_research event
loop, the deep_research_heavy flow, and the final note construction uses the
existing timed_out variable consistently.


# Append a brief sources section if citations were returned
if refs:
Expand All @@ -170,6 +189,9 @@ async def deep_research(query: str, auto_confirm: bool = True) -> str:
lines.append(f"- [{title}]({url})")
final_text += "\n".join(lines)

if truncated:
final_text += _dr_incomplete_note(timed_out)

return final_text or "(no response)"

@mcp.tool()
Expand All @@ -189,6 +211,8 @@ async def deep_research_heavy(query: str, auto_confirm: bool = True) -> str:
refs: list = []
connector_failed = False
tool_error_msg = ""
truncated = False
timed_out = False

async for event in conv.deep_research_heavy(q, model=heavy_dr_model):
etype = event.get("type")
Expand All @@ -197,6 +221,8 @@ async def deep_research_heavy(query: str, auto_confirm: bool = True) -> str:
refs = event.get("content_references", [])
if event.get("connector_failed"):
connector_failed = True
truncated = bool(event.get("terminated_abnormally"))
timed_out = bool(event.get("timeout"))
elif etype == "tool_error":
tool_error_msg = event.get("message", "")

Expand Down Expand Up @@ -226,6 +252,9 @@ async def deep_research_heavy(query: str, auto_confirm: bool = True) -> str:
warning += f"\n\n*Server message:* `{first_line}`"
final_text += warning

if truncated:
final_text += _dr_incomplete_note(timed_out)

return final_text or "(no response)"

@mcp.tool()
Expand Down
10 changes: 9 additions & 1 deletion gpt2agent/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ def detect_plan() -> str:


def write_mcp_config(plan: str) -> None:
from gpt2agent.install import _atomic_write, _backup

chat_model = "gpt-5-5-pro" if plan == "pro" else "gpt-5-3"
cfg = f"""[server]
# Loopback only — the HTTP transport is unauthenticated and proxies your full
Expand All @@ -157,7 +159,13 @@ def write_mcp_config(plan: str) -> None:
[models]
chat = "{chat_model}"
"""
MCP_CONFIG_PATH.write_text(cfg)
# Users hand-edit this file (models, host opt-ins); don't clobber their
# copy silently — keep a .bak and write atomically.
if MCP_CONFIG_PATH.exists():
if MCP_CONFIG_PATH.read_text() == cfg:
return
_backup(MCP_CONFIG_PATH)
_atomic_write(MCP_CONFIG_PATH, cfg)


# ── final summary ────────────────────────────────────────────────────────────
Expand Down
17 changes: 16 additions & 1 deletion gpt2agent/tools/_redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
# PII patterns.
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
_PHONE_RE = re.compile(r"\+?\d[\d ()\-]{8,}\d")
# Calendar dates satisfy _PHONE_RE ("2026-05-26" is 10 digit/dash chars), so a
# phone match that *starts* with a date shape keeps the date — dates in
# memories/tasks/conversations vastly outnumber phone numbers written with a
# leading date. Only the date itself is preserved; the rest of the match is
# re-scanned so "2026-05-26 617-555-0123" still masks the phone.
_DATE_PREFIX_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}|\d{1,2}-\d{1,2}-\d{4})(?=$|[^\d])")


def _phone_repl(m: re.Match) -> str:
text = m.group(0)
dm = _DATE_PREFIX_RE.match(text)
if not dm:
return "<PHONE>"
prefix = dm.group(1)
return prefix + _PHONE_RE.sub(_phone_repl, text[len(prefix):])
Comment on lines +14 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Where is redact() invoked, and on what kind of content?
rg -nP '\bredact\s*\(' -g '!tests/**' -C2

Repository: robotlearning123/gpt2agent

Length of output: 164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- gpt2agent/tools/_redact.py ---'
cat -n gpt2agent/tools/_redact.py

echo
echo '--- usages of redact / _phone_repl / _DATE_PREFIX_RE / _PHONE_RE ---'
rg -n "_phone_repl|_DATE_PREFIX_RE|_PHONE_RE|\bredact\b" gpt2agent -g '!tests/**' -C 2

Repository: robotlearning123/gpt2agent

Length of output: 12486


🏁 Script executed:

python3 - <<'PY'
import re, sys

_PHONE_RE = re.compile(r"\+?\d[\d ()\-]{8,}\d")
_DATE_PREFIX_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}|\d{1,2}-\d{1,2}-\d{4})(?=$|[^\d])")

calls = 0
max_depth = 0
depth = 0

def _phone_repl(m: re.Match) -> str:
    global calls, max_depth, depth
    calls += 1
    depth += 1
    max_depth = max(max_depth, depth)
    try:
        text = m.group(0)
        dm = _DATE_PREFIX_RE.match(text)
        if not dm:
            return "<PHONE>"
        prefix = dm.group(1)
        return prefix + _PHONE_RE.sub(_phone_repl, text[len(prefix):])
    finally:
        depth -= 1

def redact(s: str) -> str:
    return _PHONE_RE.sub(_phone_repl, s)

print("recursionlimit=", sys.getrecursionlimit())
for n in [1, 2, 3, 10, 50, 100, 200, 500, 900, 1000, 1100]:
    calls = max_depth = depth = 0
    s = " ".join([f"2026-05-{i:02d}" for i in range(1, n+1)])
    try:
        out = redact(s)
        print(n, "ok", "calls=", calls, "max_depth=", max_depth, "len(out)=", len(out))
    except RecursionError as e:
        print(n, "RecursionError", "calls=", calls, "max_depth=", max_depth, type(e).__name__)
        break
PY

Repository: robotlearning123/gpt2agent

Length of output: 683


🏁 Script executed:

python3 - <<'PY'
import re, sys

_PHONE_RE = re.compile(r"\+?\d[\d ()\-]{8,}\d")
_DATE_PREFIX_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}|\d{1,2}-\d{1,2}-\d{4})(?=$|[^\d])")

calls = 0
max_depth = 0
depth = 0

def _phone_repl(m: re.Match) -> str:
    global calls, max_depth, depth
    calls += 1
    depth += 1
    max_depth = max(max_depth, depth)
    try:
        text = m.group(0)
        dm = _DATE_PREFIX_RE.match(text)
        if not dm:
            return "<PHONE>"
        prefix = dm.group(1)
        return prefix + _PHONE_RE.sub(_phone_repl, text[len(prefix):])
    finally:
        depth -= 1

def redact(s: str) -> str:
    return _PHONE_RE.sub(_phone_repl, s)

def valid_dates(n):
    # Cycle within valid month/day ranges to keep every token date-shaped.
    months = [f"{m:02d}" for m in range(1, 13)]
    days = [f"{d:02d}" for d in range(1, 29)]
    out = []
    i = 0
    for _ in range(n):
        out.append(f"2026-{months[i % 12]}-{days[i % 28]}")
        i += 1
    return " ".join(out)

print("recursionlimit=", sys.getrecursionlimit())
for n in [1, 2, 3, 10, 50, 100, 200, 500, 800, 900, 950, 1000, 1050, 1100]:
    calls = max_depth = depth = 0
    s = valid_dates(n)
    try:
        out = redact(s)
        print(n, "ok", "calls=", calls, "max_depth=", max_depth, "len(out)=", len(out))
    except RecursionError as e:
        print(n, "RecursionError", "calls=", calls, "max_depth=", max_depth, type(e).__name__)
        break
PY

Repository: robotlearning123/gpt2agent

Length of output: 549


Avoid recursive re-entry in _phone_repl
A long chain of date-shaped tokens can recurse once per token and eventually raise RecursionError, which breaks redact() on large conversation/memory blobs. An iterative loop would keep the same behavior without the stack risk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gpt2agent/tools/_redact.py` around lines 14 - 20, _recursive_repl in
_phone_repl currently recurses through date-shaped prefixes, which can overflow
the stack on long token chains. Replace the recursive re-entry with an iterative
loop that keeps peeling off the _DATE_PREFIX_RE prefix and applying _PHONE_RE
until no prefix remains, while preserving the same redaction behavior for
redact().


# Secret patterns. Users routinely paste API keys / tokens into ChatGPT, so they
# end up in memories, tasks, and custom instructions — which these tools return
Expand All @@ -25,5 +40,5 @@ def redact(s: object) -> object:
s = _APIKEY_RE.sub("<APIKEY>", s)
s = _GH_TOKEN_RE.sub("<TOKEN>", s)
s = _EMAIL_RE.sub("<EMAIL>", s)
s = _PHONE_RE.sub("<PHONE>", s)
s = _PHONE_RE.sub(_phone_repl, s)
return s
Loading
Loading