/**
 * Node implementation of the `agent_toolset_20260401` tools — `bash`, `read`,
 * `write`, `edit`, `glob`, `grep` — plus the workdir/skills
 * {@link AgentToolContext}.
 *
 * This mirrors `@anthropic-ai/sdk/tools/memory/node`: it is the explicit,
 * Node-only entry point for these implementations. Importing it pulls in
 * `node:child_process`, `node:fs`, etc., so it is kept separate from the rest of
 * the SDK — depending on it is an opt-in.
 *
 * **Node 22+ is required** for this module: the `glob` tool uses the native
 * `fs.glob`, added in Node 22. The rest of the SDK still supports Node 18+; only
 * the agent toolset has this requirement.
 *
 * The result of {@link betaAgentToolset20260401} is a plain `BetaRunnableTool[]`;
 * hand it to any tool runner — `client.beta.messages.toolRunner({ …, tools })`
 * for the Messages API, or `client.beta.sessions.events.toolRunner({ …, tools })`
 * for a managed-agents session:
 *
 * ```ts
 * import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node';
 *
 * const tools = betaAgentToolset20260401({ workdir: '/work' });
 * const tools2 = betaAgentToolset20260401({ workdir: '/work' }).filter((t) => t.name !== 'bash');
 * ```
 *
 * Trust model: the file tools confine to `workdir` (symlink-aware) and are safe
 * without a sandbox; `bash` is unrestricted and should run inside one. See
 * {@link AgentToolContext}.
 */

import * as fs from 'node:fs/promises';
import * as fssync from 'node:fs';
import * as path from 'node:path';
import * as cp from 'node:child_process';
import * as crypto from 'node:crypto';
import * as readline from 'node:readline';
import type { Anthropic } from '../../client';
import { AnthropicError } from '../../core/error';
import type { BetaRunnableTool } from '../../lib/tools/BetaRunnableTool';
import { ToolError } from '../../lib/tools/ToolError';
import { betaTool } from '../../helpers/beta/json-schema';
import { promiseWithResolvers } from '../../internal/utils/promise';
import { atomicWriteFile, confineToRoot, DIR_CREATE_MODE, fsErrorMessage } from './fs-util';

export { setupSkills, resolveSkillVersion, extractSkillArchive } from './skills';

const BASH_OUTPUT_LIMIT = 100 * 1024;
const BASH_DEFAULT_TIMEOUT_MS = 120_000;
// Default size cap for the read/edit tools (both load the whole file into
// memory) when AgentToolContext.maxFileBytes is unset. The reject-vs-truncate
// behaviour remains a separate question pending CMA validation.
const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
const GREP_OUTPUT_LIMIT = 100 * 1024;
const GREP_MAX_LINE_LENGTH = 2000;
const GLOB_RESULT_LIMIT = 200;

const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;

// `fs.glob` is Node 22+. `@types/node` may still target an older line, so the
// surface this module uses is typed locally rather than relying on the ambient
// declaration. At runtime this module requires Node 22 (see the file header).
type GlobFn = (
  pattern: string,
  options: {
    cwd?: string;
    withFileTypes?: boolean;
    exclude?: (entry: fssync.Dirent) => boolean;
  },
) => AsyncIterable<fssync.Dirent>;
const fsGlob = (fs as unknown as { glob: GlobFn }).glob;

function resolveMaxBytes(configured: number | null | undefined): number | null {
  return configured === undefined ? DEFAULT_MAX_FILE_BYTES : configured;
}

/**
 * Workdir + path-policy for the agent toolset.
 *
 * Trust model — two tiers:
 *
 * - The file tools ({@link betaReadTool}, {@link betaWriteTool},
 *   {@link betaEditTool}, {@link betaGlobTool}, {@link betaGrepTool}) confine to
 *   `workdir` unless `unrestrictedPaths` is set. {@link resolvePath}
 *   canonicalizes the target (resolving every symlink, including the leaf)
 *   before the check *and* returns that canonical path for the operation, so a
 *   symlink inside the workdir that points outside it neither passes the check
 *   nor gets followed afterwards — this is a real boundary, not a lexical hint
 *   (modulo the residual TOCTOU noted on {@link resolvePath}).
 * - {@link betaBashTool} runs an unrestricted `/bin/bash` and cannot be
 *   confined. Run it — and, for defense in depth, the whole toolset — inside a
 *   sandbox the host controls (e.g. a self-hosted environment runner).
 */
export interface AgentToolContext {
  /** Base directory for resolving relative tool paths. */
  workdir: string;
  /**
   * When `false` (default), the file tools reject paths that resolve outside
   * `workdir` (symlinks resolved). Does **not** constrain {@link betaBashTool}.
   */
  unrestrictedPaths?: boolean;
  /**
   * Anthropic client. Optional — the bare toolset needs no client; it is only
   * used by `setupSkills`, which (together with {@link AgentToolContext.sessionId})
   * fetches the session's resolved agent and downloads each of its skills into
   * `{workdir}/skills/<name>/`.
   */
  client?: Anthropic;
  /** Session whose agent's skills `setupSkills` should download. */
  sessionId?: string;
  /**
   * Optional environment for the bash subprocess. When unset, the bash tool
   * inherits the process environment with the runner's `ANTHROPIC_*`
   * credentials scrubbed. When provided, it FULLY REPLACES that default
   * environment — the mapping is used verbatim and is NOT merged with or added
   * to the scrubbed process environment. To keep the defaults plus extra vars,
   * build the combined mapping yourself before passing it.
   */
  env?: NodeJS.ProcessEnv;
  /**
   * Size cap for the `read` and `edit` tools, which both load the whole file into
   * memory. `undefined` (default) uses the built-in 256 KiB cap; a positive number
   * sets a custom cap; `null` disables the cap entirely. Disabling it reintroduces
   * the OOM risk on a model-controlled path, so pass `null` only when the sandbox
   * can absorb arbitrarily large files. The non-regular-file (FIFO/device) guard
   * always applies regardless of this value.
   */
  maxFileBytes?: number | null;
}

/**
 * Returns the `agent_toolset_20260401` implementations bound to `ctx`. The
 * result is a plain array of `BetaRunnableTool`; filter or extend it before
 * handing it to a tool runner:
 *
 * ```ts
 * const tools = [...betaAgentToolset20260401(ctx), myCustomTool];
 * const tools = betaAgentToolset20260401(ctx).filter((t) => t.name !== 'grep');
 * ```
 *
 * Concurrency note: `client.beta.sessions.events.toolRunner` dispatches a
 * session's tool calls serially (the sessions API delivers one `agent.tool_use`
 * at a time). `client.beta.messages.toolRunner` runs a turn's `tool.run` calls
 * via `Promise.all`. The toolset below is safe under either model —
 * {@link betaBashTool} serializes its persistent shell internally and the FS
 * tools are independent per call — but {@link betaEditTool}/{@link betaWriteTool}
 * cannot synchronize concurrent writes to the *same* file across processes, so a
 * multi-edit turn touching one path is still subject to inherent FS lost-update
 * races. Custom tools that close over mutable state should do their own queueing.
 */
export function betaAgentToolset20260401(ctx: AgentToolContext): BetaRunnableTool[] {
  return [
    betaBashTool(ctx),
    betaReadTool(ctx),
    betaWriteTool(ctx),
    betaEditTool(ctx),
    betaGlobTool(ctx),
    betaGrepTool(ctx),
  ];
}

/**
 * Resolve `p` against `ctx.workdir`. Absolute and relative inputs go through
 * the same canonicalise-then-contain check — an absolute path that lands inside
 * the workdir is permitted, only paths that resolve *outside* are rejected.
 * Every symlink in `p` (including the leaf, even a dangling one) is resolved
 * before the workdir check, and the resolved path is what the tool then operates
 * on, so a symlink inside the workdir that points outside it can neither pass
 * the check nor be followed afterwards. See the trust model on
 * {@link AgentToolContext}.
 *
 * Residual TOCTOU: a component could still be swapped for a symlink between this
 * call and the eventual `fs` operation. Closing that fully needs per-component
 * `O_NOFOLLOW`/`openat`, which Node does not expose ergonomically; the same
 * residual exposure exists in `tools/memory/node` and is why a sandbox is still
 * recommended for the toolset as a whole.
 */
export function resolvePath(ctx: AgentToolContext, p: string): Promise<string> {
  return confineToRoot(ctx.workdir, p, { allowOutside: ctx.unrestrictedPaths ?? false });
}

// ---- bash ----------------------------------------------------------------

/**
 * Build the environment for the spawned bash shell. The runner process holds
 * Anthropic credentials in `ANTHROPIC_*` env vars — the API key, the auth token,
 * and the per-work session token among them. `bash` runs an unrestricted shell,
 * so any command the agent runs could read those straight out of `process.env`;
 * strip the whole `ANTHROPIC_*` namespace from the child's environment.
 * Everything else (PATH, HOME, locale, …) is passed through unchanged.
 *
 * Passing an explicit `env` to {@link AgentToolContext} does NOT add to this
 * default — it FULLY REPLACES it. The provided mapping becomes the entire bash
 * environment verbatim; nothing here is merged in, so callers who want the
 * scrubbed process environment plus extras must build that mapping themselves.
 */
function scrubbedShellEnv(): NodeJS.ProcessEnv {
  const env: NodeJS.ProcessEnv = {};
  for (const [key, value] of Object.entries(process.env)) {
    if (key.startsWith('ANTHROPIC_')) continue;
    env[key] = value;
  }
  return env;
}

/**
 * A persistent /bin/bash process. State (cwd, env, background jobs) survives
 * across exec() calls. Uses pipes rather than a PTY so input is never echoed.
 */
export class BashSession {
  #proc: cp.ChildProcessWithoutNullStreams;
  #buf = '';
  #truncated = false;
  #closed = false;
  // While a command is in flight, the resolver to fire once its sentinel lands
  // in `#buf` (or once the shell dies). Event-driven: no polling loop.
  #waiting: { sentinel: string; resolve: () => void } | null = null;

  constructor(dir: string, env: NodeJS.ProcessEnv = scrubbedShellEnv()) {
    this.#proc = cp.spawn('/bin/bash', ['--noprofile', '--norc'], {
      cwd: dir,
      // `env` is the full base environment (the scrubbed process env by
      // default, or the verbatim replacement from `AgentToolContext.env`).
      // PS1/PS2/TERM are shell-control settings BashSession always applies so
      // the pipe-based sentinel exec parsing works — not part of the
      // user-facing environment.
      env: { ...env, PS1: '', PS2: '', TERM: 'dumb' },
      stdio: ['pipe', 'pipe', 'pipe'],
      detached: true,
    });
    this.#proc.stdout.setEncoding('utf8');
    this.#proc.stderr.setEncoding('utf8');
    this.#proc.stdout.on('data', (d: string) => this.#append(d));
    this.#proc.stderr.on('data', (d: string) => this.#append(d));
    this.#proc.once('close', () => {
      this.#closed = true;
      // Wake any in-flight exec so it fails fast instead of waiting for its deadline.
      const w = this.#waiting;
      this.#waiting = null;
      w?.resolve();
    });
  }

  /** Whether the underlying shell process has exited. */
  get closed(): boolean {
    return this.#closed;
  }

  // Cap the buffer during accumulation so a command that streams unboundedly
  // can't OOM the runner. Keeps the tail so the sentinel stays detectable.
  // Also resolves the in-flight exec the instant its sentinel is buffered.
  #append(d: string): void {
    this.#buf += d;
    if (this.#buf.length > BASH_OUTPUT_LIMIT) {
      this.#buf = this.#buf.slice(this.#buf.length - BASH_OUTPUT_LIMIT);
      this.#truncated = true;
    }
    if (this.#waiting && this.#buf.indexOf(this.#waiting.sentinel) >= 0) {
      const w = this.#waiting;
      this.#waiting = null;
      w.resolve();
    }
  }

  async exec(
    command: string,
    opts: { timeoutMs?: number; signal?: AbortSignal | null | undefined } = {},
  ): Promise<{ output: string; exitCode: number }> {
    if (this.#closed) {
      throw new AnthropicError('bash session terminated');
    }
    const timeoutMs = opts.timeoutMs ?? BASH_DEFAULT_TIMEOUT_MS;
    const signal = opts.signal;
    if (signal?.aborted) {
      throw new AnthropicError('bash command aborted');
    }
    this.#buf = '';
    this.#truncated = false;
    // Per-call nonce so a command that prints a fixed marker can't spoof the
    // exit-code framing. The `''` split keeps the literal out of what we write
    // to stdin — only the shell's printf reassembles it.
    const sentinel = `__ANT_CMD_${crypto.randomUUID()}_DONE__`;
    const sentinelSplit = `${sentinel.slice(0, 8)}''${sentinel.slice(8)}`;
    // </dev/null: a stdin-reading command (`cat`, `read`) gets EOF instead of
    // blocking on the shared pipe until the timeout.
    const wrapped = `{ ${command}\n} </dev/null 2>&1; printf '\\n${sentinelSplit}%d\\n' $?\n`;
    this.#proc.stdin.write(wrapped);

    if (this.#buf.indexOf(sentinel) < 0) {
      // Park until the sentinel lands, the deadline passes, the caller aborts,
      // or the shell dies — whichever comes first. `#append` (and the `close`
      // handler) resolve `sentinelSeen`; the deadline / abort reject.
      const { promise: sentinelSeen, resolve } = promiseWithResolvers<void>();
      this.#waiting = { sentinel, resolve };
      let timer: ReturnType<typeof setTimeout> | undefined;
      let onAbort: (() => void) | undefined;
      try {
        await Promise.race([
          sentinelSeen,
          new Promise<never>((_, reject) => {
            timer = setTimeout(
              () => reject(new AnthropicError(`bash command timed out after ${timeoutMs}ms`)),
              timeoutMs,
            );
          }),
          new Promise<never>((_, reject) => {
            if (!signal) return;
            onAbort = () => reject(new AnthropicError('bash command aborted'));
            signal.addEventListener('abort', onAbort, { once: true });
          }),
        ]);
      } finally {
        if (timer) clearTimeout(timer);
        if (onAbort && signal) signal.removeEventListener('abort', onAbort);
        this.#waiting = null;
      }
    }

    const idx = this.#buf.indexOf(sentinel);
    if (idx < 0) {
      // The shell closed (or was killed) before emitting the sentinel.
      throw new AnthropicError('bash session terminated');
    }
    const tail = this.#buf.slice(idx + sentinel.length);
    const m = tail.match(/^(-?\d+)/);
    const exitCode = m ? parseInt(m[1]!, 10) : -1;
    let out = this.#buf.slice(0, idx).replace(ANSI_RE, '').replace(/\n+$/, '');
    if (this.#truncated) {
      out = `[output truncated]\n${out}`;
    }
    return { output: out, exitCode };
  }

  close(): void {
    if (this.#closed) return;
    this.#closed = true;
    const w = this.#waiting;
    this.#waiting = null;
    w?.resolve();
    this.#proc.stdout.destroy();
    this.#proc.stderr.destroy();
    this.#proc.stdin.destroy();
    try {
      // Negative PID targets the process group so foreground jobs (e.g. a
      // hung sleep) die with the shell.
      process.kill(-this.#proc.pid!, 'SIGKILL');
    } catch {
      this.#proc.kill('SIGKILL');
    }
    this.#proc.unref();
  }
}

export function betaBashTool(ctx: AgentToolContext): BetaRunnableTool {
  let session: BashSession | undefined;
  // Concurrent run() callers chain onto this promise so writes to the shared
  // shell's stdin can't interleave (which would corrupt the sentinel-match
  // exit-code parsing in BashSession.exec). Each call replaces `tail` with a
  // promise that resolves only after its own exec settles.
  let tail: Promise<unknown> = Promise.resolve();
  return betaTool({
    name: 'bash',
    description: 'Run a bash command in a persistent shell. State (cwd, env vars) persists across calls.',
    inputSchema: {
      type: 'object',
      properties: {
        command: { type: 'string', description: 'The command to run' },
        restart: { type: 'boolean', description: 'Restart the persistent shell before running' },
        timeout_ms: { type: 'integer', description: 'Per-call timeout in milliseconds' },
      },
    },
    run: async ({ command, restart, timeout_ms }, context) => {
      const prev = tail;
      const gate = promiseWithResolvers<void>();
      tail = gate.promise;
      // Swallow prior rejections — earlier callers got their own error path;
      // we just need to wait for the shell to be free.
      try {
        await prev;
      } catch {
        // ignore
      }
      try {
        if (restart) {
          session?.close();
          session = undefined;
        }
        if (!command) {
          if (restart) return 'bash session restarted';
          throw new ToolError('bash: command is required');
        }
        session ??= new BashSession(ctx.workdir, ctx.env);
        try {
          const { output, exitCode } = await session.exec(command, {
            timeoutMs: timeout_ms ?? BASH_DEFAULT_TIMEOUT_MS,
            signal: context?.signal,
          });
          if (exitCode !== 0) throw new ToolError(output || `exit ${exitCode}`);
          return output;
        } catch (e) {
          if (e instanceof ToolError) throw e;
          // Timeout, abort, or terminated: the still-running command will emit
          // a stale sentinel, so discard this session and let the next call
          // start fresh.
          session.close();
          session = undefined;
          throw new ToolError(`bash: ${e instanceof Error ? e.message : String(e)}`);
        }
      } finally {
        gate.resolve();
      }
    },
    close: () => {
      session?.close();
      session = undefined;
    },
  });
}

// ---- fs ------------------------------------------------------------------

export function betaReadTool(ctx: AgentToolContext): BetaRunnableTool {
  return betaTool({
    name: 'read',
    description: 'Read a UTF-8 text file relative to the workdir.',
    inputSchema: {
      type: 'object',
      properties: {
        file_path: { type: 'string' },
        view_range: {
          type: 'array',
          items: { type: 'integer' },
          description: '[start_line, end_line] 1-indexed inclusive',
        },
      },
      required: ['file_path'],
    },
    run: async ({ file_path, view_range }) => {
      if (!file_path) throw new ToolError('read: file_path is required');
      const abs = await resolvePath(ctx, file_path);
      let data: string;
      try {
        // stat() before any open(): the size cap stops a multi-GB file from
        // OOM'ing the runner, and isFile() rejects FIFOs/devices/dirs without
        // opening them (open() on an unconnected FIFO blocks indefinitely).
        const st = await fs.stat(abs);
        if (!st.isFile()) {
          throw new ToolError(`read: ${file_path} is not a regular file`);
        }
        const limit = resolveMaxBytes(ctx.maxFileBytes);
        if (limit !== null && st.size > limit) {
          throw new ToolError(
            `read: ${file_path} is ${st.size} bytes, exceeds ${limit}-byte limit. ` +
              'Use bash (head/tail/sed) to read a slice.',
          );
        }
        data = await fs.readFile(abs, 'utf8');
      } catch (e) {
        if (e instanceof ToolError) throw e;
        throw new ToolError(`read: ${fsErrorMessage(e, file_path)}`);
      }
      if (!view_range) return data;
      if (view_range.length !== 2) throw new ToolError('read: view_range must be [start_line, end_line]');
      const [startLine, endLine] = view_range as [number, number];
      const lines = data.split('\n');
      const start = Math.max(0, startLine - 1);
      const end = endLine > 0 ? endLine : lines.length;
      return lines.slice(start, end).join('\n');
    },
  });
}

export function betaWriteTool(ctx: AgentToolContext): BetaRunnableTool {
  return betaTool({
    name: 'write',
    description: 'Write a UTF-8 text file relative to the workdir, creating parent directories as needed.',
    inputSchema: {
      type: 'object',
      properties: { file_path: { type: 'string' }, content: { type: 'string' } },
      required: ['file_path', 'content'],
    },
    run: async ({ file_path, content }) => {
      if (!file_path) throw new ToolError('write: file_path is required');
      const abs = await resolvePath(ctx, file_path);
      try {
        await fs.mkdir(path.dirname(abs), { recursive: true, mode: DIR_CREATE_MODE });
        await atomicWriteFile(abs, content ?? '');
      } catch (e) {
        throw new ToolError(`write: ${fsErrorMessage(e, file_path)}`);
      }
      return `wrote ${Buffer.byteLength(content ?? '')} bytes to ${file_path}`;
    },
  });
}

export function betaEditTool(ctx: AgentToolContext): BetaRunnableTool {
  return betaTool({
    name: 'edit',
    description:
      'Replace old_string with new_string in a file. old_string must be unique unless replace_all.',
    inputSchema: {
      type: 'object',
      properties: {
        file_path: { type: 'string' },
        old_string: { type: 'string' },
        new_string: { type: 'string' },
        replace_all: { type: 'boolean' },
      },
      required: ['file_path', 'old_string', 'new_string'],
    },
    run: async ({ file_path, old_string, new_string, replace_all }) => {
      if (!file_path) throw new ToolError('edit: file_path is required');
      if (!old_string) throw new ToolError('edit: old_string is required');
      const abs = await resolvePath(ctx, file_path);
      let data: string;
      try {
        // stat() before any open() — same guard as `read`: the size cap stops a
        // multi-GB file from OOM'ing the runner, and isFile() rejects
        // FIFOs/devices/dirs without opening them (open() on an unconnected FIFO
        // blocks indefinitely). The edit path is model-controlled, so it needs
        // the same bound `read` already has.
        const st = await fs.stat(abs);
        if (!st.isFile()) {
          throw new ToolError(`edit: ${file_path} is not a regular file`);
        }
        const limit = resolveMaxBytes(ctx.maxFileBytes);
        if (limit !== null && st.size > limit) {
          throw new ToolError(
            `edit: ${file_path} is ${st.size} bytes, exceeds ${limit}-byte limit. ` +
              'Use bash (sed/awk) to edit a large file.',
          );
        }
        data = await fs.readFile(abs, 'utf8');
      } catch (e) {
        if (e instanceof ToolError) throw e;
        throw new ToolError(`edit: ${fsErrorMessage(e, file_path)}`);
      }
      const count = data.split(old_string).length - 1;
      if (count === 0) throw new ToolError(`edit: old_string not found in ${file_path}`);
      let updated: string;
      if (replace_all) {
        updated = data.split(old_string).join(new_string);
      } else {
        if (count > 1)
          throw new ToolError(`edit: old_string appears ${count} times in ${file_path} (must be unique)`);
        // Callback form so `$&`/`$1`/`` $` `` in new_string are inserted
        // literally instead of expanded as replacement patterns.
        updated = data.replace(old_string, () => new_string);
      }
      try {
        await atomicWriteFile(abs, updated);
      } catch (e) {
        throw new ToolError(`edit: write: ${fsErrorMessage(e, file_path)}`);
      }
      return `edited ${file_path} (${replace_all ? count : 1} replacement(s))`;
    },
  });
}

// ---- search --------------------------------------------------------------

export function betaGlobTool(ctx: AgentToolContext): BetaRunnableTool {
  return betaTool({
    name: 'glob',
    description:
      'Match files under the workdir against a glob pattern. Results are mtime-sorted, newest first.',
    inputSchema: {
      type: 'object',
      properties: {
        pattern: { type: 'string' },
        path: { type: 'string', description: 'Directory to search in. Defaults to the workdir.' },
      },
      required: ['pattern'],
    },
    run: async ({ pattern, path: searchPath }) => {
      if (!pattern) throw new ToolError('glob: pattern is required');
      let root = path.resolve(ctx.workdir);
      let pat = pattern;
      if (path.isAbsolute(pattern)) {
        if (!ctx.unrestrictedPaths) throw new ToolError('glob: absolute pattern not permitted');
        root = path.parse(pattern).root;
        pat = path.relative(root, pattern);
      } else if (searchPath) {
        root = await resolvePath(ctx, searchPath);
      }
      // A `..` in the *pattern itself* (e.g. `../../*`) walks `fs.glob` out of
      // the search root — this is separate from the `searchPath` confinement
      // above, which only covers the path argument. Reject it outright when the
      // toolset is confined.
      if (!ctx.unrestrictedPaths && pat.split(/[\\/]/).includes('..')) {
        throw new ToolError('glob: ".." is not permitted in the pattern');
      }
      // Compare canonical against canonical: a workdir that is itself a
      // symlink would otherwise falsely reject every realpath'd match below.
      const realRoot = ctx.unrestrictedPaths ? root : await fs.realpath(root).catch(() => root);
      const matches: { path: string; mtime: number }[] = [];
      try {
        // Native `fs.glob` (Node 22+). `exclude` prunes the noisy dirs the
        // legacy walker skipped; only regular files are collected.
        for await (const entry of fsGlob(pat, {
          cwd: root,
          withFileTypes: true,
          exclude: (d) => d.name === '.git' || d.name === 'node_modules',
        })) {
          if (!entry.isFile()) continue;
          const full = path.join(entry.parentPath, entry.name);
          // Drop any match that resolves outside the search root. A pattern
          // that *names* a symlinked directory (the model controls the
          // pattern, and the bash tool in the same session can plant the
          // link) makes `fs.glob` descend through it and report entries with
          // the raw parent path, so a lexical check on `full` alone would
          // pass `root/link_out/secret` even though it lives outside the
          // jail. Resolve-failure (ELOOP, EACCES, a racing unlink) is a deny.
          if (!ctx.unrestrictedPaths) {
            let real: string;
            try {
              real = await fs.realpath(full);
            } catch {
              continue;
            }
            if (!isWithin(realRoot, real)) continue;
          }
          let mtime = 0;
          try {
            mtime = (await fs.stat(full)).mtimeMs;
          } catch {
            // unreadable — keep it in the list with mtime 0
          }
          matches.push({ path: full, mtime });
        }
      } catch (e) {
        throw new ToolError(`glob: ${e instanceof Error ? e.message : String(e)}`);
      }
      if (matches.length === 0) return 'no matches';
      matches.sort((a, b) => b.mtime - a.mtime);
      return matches
        .slice(0, GLOB_RESULT_LIMIT)
        .map((m) => m.path)
        .join('\n');
    },
  });
}

export function betaGrepTool(ctx: AgentToolContext): BetaRunnableTool {
  return betaTool({
    name: 'grep',
    description: 'Search file contents for a regex. Uses ripgrep if available, otherwise a built-in walker.',
    inputSchema: {
      type: 'object',
      properties: { pattern: { type: 'string' }, path: { type: 'string' } },
      required: ['pattern'],
    },
    run: async ({ pattern, path: p }, context) => {
      if (!pattern) throw new ToolError('grep: pattern is required');
      let searchPath = path.resolve(ctx.workdir);
      if (p) searchPath = await resolvePath(ctx, p);
      const rg = await findRg();
      return rg ?
          runRipgrep(rg, pattern, searchPath, context?.signal)
        : runWalkGrep(pattern, searchPath, context?.signal);
    },
  });
}

function runRipgrep(
  rg: string,
  pattern: string,
  searchPath: string,
  signal?: AbortSignal | null | undefined,
): Promise<string> {
  return new Promise((resolve, reject) => {
    const proc = cp.spawn(rg, ['-n', '--no-heading', '-e', pattern, '--', searchPath], {
      ...(signal ? { signal } : {}),
    });
    let out = '';
    let errOut = '';
    let truncated = false;
    proc.stdout.on('data', (d) => {
      if (truncated) return;
      out += d;
      if (out.length > GREP_OUTPUT_LIMIT) {
        truncated = true;
        out = out.slice(0, GREP_OUTPUT_LIMIT);
        proc.kill('SIGKILL');
      }
    });
    proc.stderr.on('data', (d) => (errOut += d));
    proc.on('close', (code) => {
      if (signal?.aborted) return reject(new ToolError('grep: aborted'));
      if (truncated) return resolve(out + `\n[output truncated at ${GREP_OUTPUT_LIMIT} bytes]`);
      if (code === 0) return resolve(out);
      if (code === 1) return resolve('no matches');
      reject(new ToolError(`grep: rg failed: ${errOut || `exit ${code}`}`));
    });
    proc.on('error', (e) => {
      if (signal?.aborted) return reject(new ToolError('grep: aborted'));
      reject(new ToolError(`grep: rg failed: ${e.message}`));
    });
  });
}

async function runWalkGrep(
  pattern: string,
  root: string,
  signal?: AbortSignal | null | undefined,
): Promise<string> {
  let re: RegExp;
  try {
    re = new RegExp(pattern);
  } catch (e) {
    throw new ToolError(`grep: invalid regex: ${e instanceof Error ? e.message : String(e)}`);
  }
  const hits: string[] = [];
  let budget = GREP_OUTPUT_LIMIT;
  const push = (line: string): boolean => {
    budget -= line.length + 1;
    if (budget < 0) {
      hits.push(`[output truncated at ${GREP_OUTPUT_LIMIT} bytes]`);
      return false;
    }
    hits.push(line);
    return true;
  };
  const stat = await fs.stat(root).catch(() => null);
  if (stat?.isFile()) {
    await grepFile(root, re, push);
  } else {
    await walk(root, '', (rel) => grepFile(path.join(root, rel), re, push), signal);
  }
  if (signal?.aborted) throw new ToolError('grep: aborted');
  if (hits.length === 0) return 'no matches';
  return hits.join('\n');
}

async function grepFile(file: string, re: RegExp, push: (line: string) => boolean): Promise<boolean> {
  const stream = fssync.createReadStream(file, { encoding: 'utf8' });
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
  let i = 0;
  try {
    for await (const line of rl) {
      i++;
      // Cap line length: `pattern` is model-supplied and JS regexes backtrack,
      // so a pathological pattern against a very long line is a ReDoS.
      if (line.length > GREP_MAX_LINE_LENGTH) continue;
      if (re.test(line) && !push(`${file}:${i}:${line}`)) return false;
    }
  } catch {
    // unreadable / binary
  } finally {
    stream.destroy();
  }
  return true;
}

// ---- utils ---------------------------------------------------------------

/** True when `p` is `root` itself or lexically contained within it. */
function isWithin(root: string, p: string): boolean {
  const rel = path.relative(root, p);
  return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
}

const WALK_MAX_DEPTH = 40;
const WALK_MAX_ENTRIES = 50_000;

/**
 * Bounded recursive walk. `fn` may return `false` to abort. Only real
 * directories are descended into and only real files are handed to `fn` —
 * symlinks (and devices/fifos/sockets) are skipped entirely so a symlink inside
 * the root cannot be followed out of it.
 */
async function walk(
  root: string,
  rel: string,
  fn: (rel: string) => boolean | void | Promise<boolean | void>,
  signal?: AbortSignal | null | undefined,
): Promise<void> {
  let remaining = WALK_MAX_ENTRIES;
  async function inner(rel: string, depth: number): Promise<boolean> {
    if (depth > WALK_MAX_DEPTH) return true;
    if (signal?.aborted) return false;
    let entries: fssync.Dirent[];
    try {
      entries = await fs.readdir(path.join(root, rel), { withFileTypes: true });
    } catch {
      return true;
    }
    for (const e of entries) {
      if (e.name === '.git' || e.name === 'node_modules') continue;
      if (remaining-- <= 0) return false;
      if (signal?.aborted) return false;
      const childRel = rel ? path.join(rel, e.name) : e.name;
      if (e.isDirectory()) {
        if (!(await inner(childRel, depth + 1))) return false;
      } else if (e.isFile()) {
        if ((await fn(childRel)) === false) return false;
      }
      // Symlinks, devices, fifos and sockets are intentionally skipped.
    }
    return true;
  }
  await inner(rel, 0);
}

async function findRg(): Promise<string | null> {
  const dirs = (process.env['PATH'] ?? '').split(path.delimiter);
  for (const d of dirs) {
    const candidate = path.join(d, 'rg');
    try {
      await fs.access(candidate, fssync.constants.X_OK);
      return candidate;
    } catch {
      // not here
    }
  }
  return null;
}
