Repository navigation
Add one-command Codex and pi integration with MCP search and startup hooks - #160
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Final review identified one critical and eight moderate issues affecting installer safety, configuration handling, runtime cleanup, and bridge reliability.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a one-command, checkout-local tgrep integration for Codex and pi using MCP search tools, startup hooks, and shared services.
Changes:
- Adds transactional project/user installation, repair, doctor, and uninstall workflows.
- Adds MCP runtime, pi bridge, freshness handling, cancellation, and path validation.
- Adds documentation, integration tests, and Linux/macOS CI coverage.
File summaries
| File | Review summary |
|---|---|
scripts/agent/test_pi.mjs |
Tests the generated pi bridge contract; no findings. |
scripts/agent/test_agent.py |
Tests installer and runtime integration; no findings. |
scripts/agent/runtime.py |
Moderate: count output is not structured or normalized; malformed service responses can crash requests; process cleanup has a poll()/killpg() race. |
scripts/agent/README.md |
Documents setup and runtime behavior; no findings. |
scripts/agent/pi-extension.ts |
Moderate: a child exit after connection can cause tool calls to wait 40 seconds; check liveness and reject or reconnect. |
scripts/agent/install.py |
Critical: reject symlinked parent components before writes. Moderate: prefer inline hooks, migrate paths after checkout moves, use single-pass substitutions, and honor exclusions for non-Git roots. |
README.md |
Links to the integration installer and guide; no findings. |
install-agent.sh |
Provides the installer entry point; no findings. |
.github/workflows/ci.yml |
Runs Unix integration tests; no findings. |
Review details
Suppressed comments (7)
scripts/agent/install.py:215
- When both an inline TOML
hookstable and ahooks.jsonfile exist, this condition always chooses the JSON representation, even though the documented behavior is to use the inline TOML hooks whenever they are present. The newly added hook can therefore be written to a file Codex does not load while the inline configuration remains unchanged. Prefer the inline branch whenever"hooks" in parsedand only fall back tohooks.jsonwhen it is absent.
if hooks_raw is not None or "hooks" not in parsed:
hooks = json.loads(hooks_raw or b"{}")
scripts/agent/install.py:235
- These replacements are applied sequentially to the template and also scan values inserted by earlier replacements. A valid checkout or configuration path containing a token such as
__TOOLS__or__CONFIG__will therefore be rewritten by a later iteration, producing invalid TypeScript or incorrect absolute paths. Use a single-pass placeholder substitution or collision-proof markers.
for key, value in substitutions.items():
template = template.replace(key, json.dumps(value))
scripts/agent/install.py:184
- For a supported project root outside Git, tgrep intentionally ignores
.gitignoreunless--no-require-gitis supplied (seeAGENTS.md:209-214). The defaultflagshere remain empty even though the installer adds/.tgrep-agent/to.gitignore, so the service indexes its own managed runtime/configuration and does not honor that exclusion. Detect non-Git project roots and include the matching index flag, or exclude the managed directory another way.
flags = []
if args.no_require_git:
flags.append("--no-require-git")
scripts/agent/pi-extension.ts:43
- If the MCP child exits after
connect()resolves but before this request is sent,childis undefined and the optional write becomes a no-op. The returned promise then waits the full 40-second timer instead of reporting the disconnect or reconnecting, so a crashed adapter can make every next tool call hang. Check that the child/stdin is still alive before writing and reject or reconnect when it is not.
child?.stdin?.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
scripts/agent/runtime.py:238
output_mode="count"takes the-c --with-filenamebranch, but this fallback stores the whole CLI line astext. Unlike the content/files modes, callers therefore receive an absolutepath:countstring (the command searches an absolute root), not a normalized path and count field. Parse the final count and normalize the filename before adding the result so the advertised count mode is structured and does not expose the checkout path.
else:
item = {"text": text}
scripts/agent/runtime.py:121
- A valid JSON scalar or array can be returned by a stale/unrelated process at the port in
serve.json;reply.getthen raisesAttributeError, which is not caught here. Indexed requests can consequently crash instead of treating the service as unavailable and falling back to a scan. Validate the response shape or includeAttributeErrorin the handled failures.
reply = json.loads(stream.readline(65536))
return reply.get("result")
except (OSError, ValueError, KeyError, TypeError):
scripts/agent/runtime.py:280
- The
poll()check andkillpg()are racy: the query can exit afterpoll()returnsNone, causingos.killpgto raiseProcessLookupErrorand mask the actual search result during normal cleanup/cancellation. Treat an already-gone process group as successfully terminated before waiting and closing the pipes.
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
@microsoft-github-policy-service agree |
- Reject symlinked components in owned installation paths before any read, write, backup or lock creation, and stop resolving project-owned bases through symlinks. - Rebase manifest records when a project is moved or copied: `repair --root` migrates owned files to the new location, `doctor` reports integrations that still point at the old root, and the old project is never read or changed. - Prefer inline TOML hooks over hooks.json when both exist, leaving the JSON file untouched. - Substitute pi extension placeholders in a single pass so a path containing a token such as __TOOLS__ stays literal. - Honor .gitignore exclusions in non-Git session roots by adding --no-require-git to service and query commands at runtime. - Return structured repository-relative count results parsed from JSON end records instead of raw `path:count` CLI lines. - Treat malformed service status replies as unavailable instead of raising, and ignore an already-exited process group during query cleanup. - Reject pi bridge tool calls when the MCP child is gone instead of waiting for the 40-second request timer, so the next call reconnects.
|
All findings from the review are addressed in fe7d85a, with a regression test for each:
|
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect reinstall behavior, path safety, result paths, and MCP lifecycle handling.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (6)
Previously missed (1) — in code that hasn't changed since the last review.
scripts/agent/pi-extension.ts:97
- Tool cancellation is ignored during connection setup:
executewaits forconnect()before passing the signal to the actual call, whileconnect()initializes the MCP child withrequest()without that signal. If startup hangs (or the signal is already aborted), the tool can wait the full 40-second initialization timeout and leave an unnecessary adapter process instead of rejecting promptly. Thread the abort signal through connection/initialization and check it before spawning.
scripts/agent/install.py:312
- This health check launches the installed runtime with the interpreter running
doctor, but both Codex's MCP command and the generated pi extension embedsys.executablefrom installation time. If that Python path is later removed or changed (a documented repair case),doctorcan report OK while the agent cannot start the integration. Record and probe the configured interpreter, or otherwise validate the embedded command before reporting success.
process = subprocess.Popen([sys.executable, runtime_record["path"], "mcp", "--config", config_record["path"]],
cwd=root, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
scripts/agent/install.py:396
- These project paths are preflighted even for
--scope user, although a user-scoped installation never owns or writes them. Consequently, an unrelated symlinkedAGENTS.mdor.gitignorein the current repository makes user-scope install, repair, or uninstall fail before touching the user destination. Restrict these two checks to project scope.
for path in (manifest_path, base / "install.lock", base / "backups", root / "AGENTS.md", root / ".gitignore"):
reject_symlinks(path)
scripts/agent/install.py:216
- This always appends a
[mcp_servers.tgrep]table. A valid existing TOML configuration such asmcp_servers = { other = { command = "other" } }cannot be followed by a table header because the inline table is already closed, so the validation immediately after this block fails and installation cannot preserve that configuration. Merge inline-table forms (or explicitly report/support the representation) before appending the managed block.
block = "\n".join([
"[mcp_servers.tgrep]", f"command = {json.dumps(sys.executable)}",
"args = " + json.dumps([str(runtime), "mcp", "--config", str(configuration)]),
"startup_timeout_sec = 10", "tool_timeout_sec = 40",
scripts/agent/runtime.py:128
- The discovery file contains a PID and port, but this probe accepts any local process that returns a plausible status object and never verifies the recorded server identity or repository. After a crash, another tgrep server can reuse the saved port;
ensure_serverwill then skip startup and indexed queries can be answered by the wrong repository. Validate the server/process identity before treating this status as live.
info = json.loads((self.index / "serve.json").read_text())
with socket.create_connection(("127.0.0.1", info["port"]), timeout=0.3) as sock:
sock.sendall(b'{"jsonrpc":"2.0","id":1,"method":"status"}\n')
with sock.makefile("rb") as stream:
reply = json.loads(stream.readline(65536))
if not isinstance(reply, dict) or reply.get("jsonrpc") != "2.0" or reply.get("id") != 1:
scripts/agent/runtime.py:97
- The scalar-string limit is not applied to array members. An MCP caller can send up to 100 arbitrarily large
file_typesorglobstrings, causing oversized subprocess argument lists or excessive memory use before tgrep runs. Apply the same per-item length bound (and ideally a total argument budget) when validating lists.
if isinstance(value, list) and (len(value) > 100 or any(type(v) is not str or "\0" in v for v in value)):
raise ValueError(f"Invalid list for {key}")
- Files reviewed: 9/9 changed files
- Comments generated: 3
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
| path = Path(path) | ||
| if path not in self.before: | ||
| self.before[path] = read(path) | ||
| return self.after.get(path, self.before[path]) |
| p = Path(item["path"]) | ||
| if p.is_absolute(): | ||
| item["path"] = str(p.relative_to(self.root)) |
- Refuse inline or dotted mcp_servers/hooks definitions with an actionable error instead of appending a block TOML cannot parse, and type-check mcp_servers before merging. - Record the interpreter at installation time and probe it in doctor, so a removed or replaced Python fails verification instead of reporting success. - Preflight project-owned AGENTS.md/.gitignore only for project scope, so unrelated symlinks in the current repository no longer block user scope. - Reject a symlinked tgrep-agent cache directory at install time and symlinked per-repository state/index directories at runtime. - Bound file_types/glob entries by length and total size, not just count. - Require the recorded server pid to be alive before trusting serve.json, and shape-check pid/port before connecting. - Thread the abort signal through pi bridge startup, check it before spawning, and reject calls that abort while initialization is still in flight. - Document the TOML table requirement, interpreter probe, service state symlink rule and list limits, and cover each change with a regression test.
|
Second pass addressed in a4bbe75. Seven findings applied, two I could not reproduce — details and evidence below. Applied
Not reproduced
|
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in installer validation, runtime path safety, and pi reconnection handling.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
scripts/agent/install.py:138
- When both agents are installed into a previously absent shared
AGENTS.mdor.gitignore, the second block sees the first managed block viaself.get(path)and recordscreated=False. Removing both blocks then leaves an empty file instead of deleting the file created by the integration. Base the flag on the original value inself.before[path], which is shared across all blocks for that path.
records.append({"kind": "block", "path": str(path), "text": value, "created": original is None})
scripts/agent/install.py:336
doctoronly verifies that each managed block/file can be removed; it never parses the surrounding Codexconfig.toml. A user edit outside the owned block can leave that file syntactically invalid, while the direct MCP probe below still succeeds and doctor reports OK even though Codex cannot load the integration. Parse the host configuration (and the selected hook representation) before reporting success.
for record in entry["records"]:
if not Path(record["path"]).exists():
raise ValueError(f"Missing managed configuration: {record['path']}")
check.remove_record(record)
scripts/agent/install.py:458
- The previous-binary reuse path bypasses the symlink check: a retained private
base/bin/tgrepcan be replaced with a symlink,is_file()follows it, andbinary_path()then resolves and executes the external target for--version/--helpand future hooks. Validate the manifest's private binary destination with the same owned-path check before assigning it (while keeping explicitly user-supplied binaries separate).
if not args.binary and len(previous_binaries) == 1:
previous_binary = previous_binaries.pop()
if Path(previous_binary).is_file():
args.binary = previous_binary
scripts/agent/runtime.py:155
- A malformed but readable
serve.jsonwith a large integer PID or a port outside the socket range passes the current type checks;os.kill()/socket.create_connection()can then raiseOverflowError, which escapesstatus()instead of treating the record as unavailable and falling back to a scan. CatchOverflowErrorhere (or validate the ranges before using these values).
except (OSError, ValueError, KeyError, TypeError):
- Files reviewed: 9/9 changed files
- Comments generated: 4
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
| interpreter = config.get("python") or sys.executable | ||
| subprocess.run([interpreter, "--version"], capture_output=True, check=True, timeout=5) | ||
| subprocess.run([config["binary"], "--version"], capture_output=True, check=True, timeout=5) |
| reject_symlinks(self.state) | ||
| reject_symlinks(self.index) |
Refactor error handling during initialization to ensure proper stopping of the process. Co-authored-by: Copilot Autofix powered by AI <[email protected]> Signed-off-by: MR.GOOD <[email protected]>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical installer path-safety issues and moderate configuration and runtime issues block approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (7)
scripts/agent/install.py:468
- When a reinstall/repair supplies any one index option, this condition skips preservation of the entire previous option set. For example, changing only
--max-filesizesilently drops an existing--no-require-gitsetting (and vice versa), so unrelated indexing behavior changes unexpectedly. Preserve each old flag unless that specific option was overridden.
if old and not (args.max_filesize or args.no_max_filesize or args.no_require_git):
scripts/agent/install.py:174
Path.absolute()does not normalize... If a user setsCODEX_HOMEorPI_CODING_AGENT_DIRto a path containing.., installation records that spelling, butprepare_records()later rejects any..component (line 312), so the installation cannot be repaired, doctored, or uninstalled. Normalize these paths lexically without resolving symlinks before recording them.
return Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))).expanduser().absolute()
return Path(os.environ.get("PI_CODING_AGENT_DIR", str(Path.home() / ".pi/agent"))).expanduser().absolute()
scripts/agent/install.py:459
- When reinstalling/repairing both agents, this only reuses a previous binary if all selected agents share one path. If Codex and pi were installed with different valid
--binaryvalues, a later combined operation silently replaces both with the PATH/private binary even though the documented behavior says reinstall preserves each previous binary unless a replacement is supplied. Resolve the binary per agent (or reject the ambiguous combined operation) instead of collapsing the set.
previous_binaries = {manifest["agents"][a]["config"]["binary"] for a in agents if a in manifest["agents"]}
if not args.binary and len(previous_binaries) == 1:
previous_binary = previous_binaries.pop()
if Path(previous_binary).is_file():
args.binary = previous_binary
binary = binary_path(args, base)
scripts/agent/runtime.py:361
- The adapter accepts up to 16 active calls but runs only four workers. Calls five through 16 can remain queued behind long-running searches; cancellation only sets their event, which is not observed until a worker starts the task, so a cancelled request can wait for the earlier 30-second timeouts instead of rejecting promptly. Track the submitted futures or otherwise remove/cancel queued work when
notifications/cancelledarrives.
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
scripts/agent/runtime.py:173
- The symlink check only covers the state/index directories, but this open follows a pre-existing
state/serve.logsymlink. That lets startup diagnostics be written outside the managed cache despite the service-state path checks (and the same audit is needed for other state files opened by the launcher). Reject leaf symlinks before opening these paths.
with (self.state / "serve.log").open("ab") as log:
scripts/agent/runtime.py:208
find_filesinvokestgrep --fileswithout passing the requested pattern, then filters only after every indexed/filesystem path has been emitted. A query such as*.rsor a no-match pattern therefore scans and transfers the entire repository before returning, which can dominate the 30-second budget on large trees; apply a bounded/index-side filename filter or add a dedicated filename query instead.
if name == "find_files":
cmd += ["--files", "--null", str(path)]
else:
scripts/agent/runtime.py:225
- Count mode still requests JSON match records for every matching line and
record()discards those records, keeping only the laterendstatistics. A heavily matching file can therefore produce and parse an unbounded stream despite the response byte/record caps, causing unnecessary work or a timeout; the CLI needs a count-only JSON/summary path that emits per-file stats without match events.
# CLI count output cannot escape arbitrary filenames. JSON end
# records provide matched-line counts with unambiguous paths.
cmd += ["--json", "-C", "0"]
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
| data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) | ||
| base = (root / ".tgrep-agent" if args.scope == "project" else data_home.resolve() / "tgrep-agent") | ||
| reject_symlinks(base) |
Co-authored-by: Copilot Autofix powered by AI <[email protected]> Signed-off-by: MR.GOOD <[email protected]>
There was a problem hiding this comment.
🔵 Needs a closer look
Four moderate findings remain in path normalization, validation, and symlink handling.
Review details
Suppressed comments (4)
scripts/agent/install.py:421
- Calling
data_home.resolve()removes any symlink components beforereject_symlinks(base)runs. With a symlinkedXDG_DATA_HOME, user-scope installation therefore writes through the symlink target instead of rejecting it, contrary to the advertised protection for owned installation paths and the equivalent checks used for project state and agent destinations. Keep the absolute, non-resolved path for the preflight check.
base = (root / ".tgrep-agent" if args.scope == "project" else data_home.resolve() / "tgrep-agent")
scripts/agent/runtime.py:260
- When the requested
pathis a subdirectory, tgrep emits paths relative to that search directory (for examplemain.rsfor a query rooted atrepo/src), but this converts them directly relative toself.rootand returnsmain.rsinstead ofsrc/main.rs. The same missing prefix affects JSON content/count records below and makes repository-relative results and repository-relativefind_filesglobs incorrect for scoped searches; normalize the CLI path relative to the requested directory before applying the repository-root conversion in every output branch.
resolved = Path(text)
if not resolved.is_absolute():
resolved = self.root / resolved
if not resolved.resolve().is_relative_to(self.root):
return
text = str(resolved.relative_to(self.root))
pattern = args.get("pattern", "*")
if name == "find_files" and not (fnmatch.fnmatchcase(text, pattern) or fnmatch.fnmatchcase(Path(text).name, pattern)):
scripts/agent/runtime.py:155
- An out-of-range integer in a stale or corrupted
serve.json(for example, a port above 65535) passes these type checks and can makesocket.create_connectionraiseOverflowError. That exception is not caught below, sostatus()abortsensure_serverinstead of treating the server as unavailable and falling back to a fresh scan; catchOverflowErroror validate port/PID bounds with the malformed-record checks.
except (OSError, ValueError, KeyError, TypeError):
scripts/agent/runtime.py:131
- These checks only reject symlinked directories, not symlinked files within them. A pre-created
state/start.lockorstate/serve.logis followed byopen(), and the child similarly followsindex/serve.lock/serve.json, so a local redirect can make the integration lock or overwrite files outside the cache despite the documented symlink protection. Validate every service-owned leaf before launch (or use no-follow/atomic creation) as well.
reject_symlinks(self.state)
reject_symlinks(self.index)
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
Agents currently need manual setup to discover tgrep, keep its server running, and choose it for repository searches. This adds a checkout-local installer that configures MCP search tools, startup prewarming, and usage guidance for Codex and pi in one command.
Changes
install-agent.shwith interactive selection and project/user scopedinstall,doctor,repair, anduninstallcommands. Preserve existing configuration, track owned sections/files, back up changes, and roll back failed writes.repair --rootrebases owned files after a project is moved or copied, anddoctorreports integrations that still point at an old root.search_codeandfind_files. Reuse the existing CLI, bound output and execution time, reject paths outside the repository, and terminate query subprocesses on cancellation/shutdown.freshness=currentuses--no-indexto verify recent edits.path:counttext.Scope and behavior
This version supports Linux/macOS and Python 3.11+. It encourages use of tgrep without overriding built-in tools or rewriting shell commands. Codex hooks still require the host's normal trust review. The integration uses a separate cache rather than adopting manually managed indexes whose settings cannot be verified. Uninstall retains indexes, shared services, binaries, and recovery backups.
Validation
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --workspacepython3 -B -m unittest discover -s scripts/agent -p 'test_*.py' -v— 31 tests covering configuration preservation, rollback, install/repair/uninstall, moved checkouts, symlinked destinations, service startup/recovery, freshness, path boundaries, cancellation, structured output, and real MCP calls through the pi bridge.bash -n install-agent.shandgit diff --cached --checkLocal verification was performed on Linux. The pi bridge test exercises the extension contract without a model/API key; it is not an end-to-end interactive agent session test.