Download src/sr_harness/tools/workspace_shell.py from YuMeow/SRHarness: direct link, hf CLI and curl.
- Browser
- Download file 46 kB
-
https://huggingface.co/spaces/YuMeow/SRHarness/resolve/main/src/sr_harness/tools/workspace_shell.py
- Command line
-
hf download hf://spaces/YuMeow/SRHarness/src/sr_harness/tools/workspace_shell.py
-
curl -L -o workspace_shell.py https://huggingface.co/spaces/YuMeow/SRHarness/resolve/main/src/sr_harness/tools/workspace_shell.py
46 kB
| # Copyright (c) 2026-present, Yumeow. Licensed under the MIT License. | |
| """工作区 Shell 工具。 | |
| 提供一个隔离的工作区目录,允许 Agent 通过受限的 shell 风格命令操作 | |
| 文件。 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import gzip | |
| import logging | |
| import os | |
| import re | |
| import shlex | |
| import shutil | |
| import stat | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any, Callable, Dict, List, Never | |
| from ..runtime.workspace import Workspace | |
| from .base_tool import BaseTool, ToolMetadata | |
| from .code_executor import LimitedWriter | |
| from ..utils import log_exception | |
| _logger = logging.getLogger(f"sr_harness.{__name__}") | |
| # WorkspaceShellTool 支持的命令白名单 | |
| ALLOWED_COMMANDS = { | |
| "ls", "cat", "head", "tail", "wc", "grep", | |
| "sort", "cut", "cp", "mv", "rm", "mkdir", | |
| "gunzip", "gzip", "unzip", "tar", | |
| } | |
| class CommandParseError(ValueError): | |
| """Raised when the restricted command language cannot parse an invocation.""" | |
| class CommandArgumentParser(argparse.ArgumentParser): | |
| """An ``argparse`` parser that reports errors instead of exiting the process.""" | |
| def __init__(self, command: str): | |
| super().__init__(prog=command, add_help=False, allow_abbrev=False, exit_on_error=False) | |
| def error(self, message: str) -> Never: | |
| """Run the ``error`` operation. | |
| Args: | |
| message: Message text or provider message payload. | |
| Returns: | |
| Never: The operation result. | |
| """ | |
| raise CommandParseError(f"{self.prog}: {message}") | |
| def exit(self, status: int = 0, message: str | None = None) -> Never: | |
| """Run the ``exit`` operation. | |
| Args: | |
| status: Run completion status. | |
| message: Message text or provider message payload. | |
| Returns: | |
| Never: The operation result. | |
| """ | |
| raise CommandParseError(message or f"{self.prog}: invalid arguments") | |
| class WorkspaceShellTool(BaseTool): | |
| """Implementation of the workspace shell tool.""" | |
| metadata = ToolMetadata(name="workspace_shell") | |
| DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024 | |
| MAX_OUTPUT_LIMIT_BYTES = 1024 * 1024 | |
| def get_doc(cls) -> dict[str, str]: | |
| """Return documentation exposed as a runtime skill. | |
| Returns: | |
| dict[str, str]: The operation result. | |
| """ | |
| return { | |
| "name": "workspace-shell", | |
| "description": ( | |
| "Inspect and modify workspace files with a safe, restricted shell-like " | |
| "command language." | |
| ), | |
| "content": ( | |
| "# Workspace Shell\n\n" | |
| "`workspace_shell` implements a small command language in Python. It does not " | |
| "start a system shell or execute operating-system commands. Use it to inspect, " | |
| "filter, copy, move, remove, and unpack files in the current workspace.\n\n" | |
| "## Syntax\n\n" | |
| "- Separate arguments with whitespace. Single quotes, double quotes, and " | |
| "backslash escapes follow POSIX shell lexical rules.\n" | |
| "- Join supported commands with `|`. A pipeline passes each command's text " | |
| "output to the next command in memory; spaces around `|` are optional.\n" | |
| "- Short flags may be combined or separated: `ls -la`, `ls -al`, " | |
| "`ls -l -a`, and `ls -a -l` are equivalent. Options and operands may usually " | |
| "be interspersed. Use `--` before a path beginning with `-`.\n" | |
| "- Every path must resolve inside the workspace. Absolute paths and paths or " | |
| "symbolic links that escape the workspace are rejected.\n" | |
| "- Redirection and other shell operators (`>`, `<`, `;`, `&&`, `||`, `&`) are " | |
| "not supported. There is no globbing, variable or tilde expansion, command " | |
| "substitution, background execution, or invocation of unlisted commands.\n\n" | |
| "## Supported commands\n\n" | |
| "```text\n" | |
| "ls [-a|--all] [-l] [PATH ...]\n" | |
| "cat [-n|--number] [FILE ...]\n" | |
| "head [-n N|--lines N|-N] [FILE]\n" | |
| "tail [-n N|--lines N|-N] [FILE]\n" | |
| "wc [-l] [-w] [-c] [-m] [FILE ...]\n" | |
| "grep [-i|--ignore-case] [-v|--invert-match] [-n|--line-number]\n" | |
| " [-c|--count] [-F|--fixed-strings] PATTERN [FILE]\n" | |
| "sort [-r|--reverse] [-n|--numeric-sort] [-u|--unique] [FILE]\n" | |
| "cut [-d DELIMITER|--delimiter DELIMITER] -f FIELDS [FILE]\n" | |
| "cp [-r|-R|--recursive] [-f|--force] SOURCE DESTINATION\n" | |
| "mv [-f|--force] SOURCE DESTINATION\n" | |
| "rm [-r|-R|--recursive] [-f|--force] PATH ...\n" | |
| "mkdir [-p|--parents] DIRECTORY ...\n" | |
| "gzip [-k|--keep] [-f|--force] FILE\n" | |
| "gunzip [-k|--keep] [-f|--force] FILE\n" | |
| "unzip [-d DIRECTORY|--directory DIRECTORY] FILE\n" | |
| "tar (-x|--extract) [-z|--gzip] (-f FILE|--file FILE)\n" | |
| " [-C DIRECTORY|--directory DIRECTORY]\n" | |
| "```\n\n" | |
| "`grep` performs fixed-string matching; `-F` is accepted for familiar grep " | |
| "syntax. `cut -f` accepts comma-separated, one-based field numbers. `tar` only " | |
| "supports extraction and accepts grouped flags such as `-xf` and `-xzf`. When " | |
| "a text-processing command has no file operand, it reads the previous pipeline " | |
| "stage.\n\n" | |
| "## Examples\n\n" | |
| "```text\n" | |
| "ls -la\n" | |
| "cat 'experiment 1.csv' | head -5\n" | |
| "cat data.csv|grep -i result|wc -l\n" | |
| "cut data.csv -d, -f1,3 | sort -u\n" | |
| "mkdir -p results/archive\n" | |
| "cp -r results results-copy\n" | |
| "tar -xzf results.tar.gz -C results/archive\n" | |
| "cat -- -notes\n" | |
| "```" | |
| ), | |
| } | |
| def execute( | |
| self, | |
| command: str, | |
| output_limit_bytes: int = DEFAULT_OUTPUT_LIMIT_BYTES, | |
| ) -> Dict[str, Any]: | |
| """Execute a restricted shell command in the workspace directory. | |
| Supported commands: ls, cat, head, tail, wc, grep, sort, cut, cp, mv, rm, mkdir, | |
| gunzip, gzip, unzip, tar. | |
| All file paths are relative to the workspace root. Absolute paths and path traversal | |
| (e.g., ../) are forbidden. | |
| Args: | |
| command: A shell command string. | |
| Examples: "ls", "cat data.csv | head -5", "gunzip data.csv.gz". | |
| output_limit_bytes: Maximum stdout size returned by each command segment. | |
| """ | |
| if self.context.workspace is None: | |
| return self._error("Workspace not initialized.") | |
| output_limit_bytes = self._bounded_output_limit(output_limit_bytes) | |
| try: | |
| pipeline = self._parse_pipeline(command) | |
| except CommandParseError as exc: | |
| return self._error(f"Command parse error: {exc}") | |
| stdin_text = "" | |
| for tokens in pipeline: | |
| try: | |
| result = self._execute_tokens(tokens, stdin_text) | |
| if not result.get("success", False): | |
| return result | |
| stdin_text = self._limit_output(result.get("stdout", ""), output_limit_bytes) | |
| except Exception as e: | |
| rendered = shlex.join(tokens) | |
| _logger.error(f"Error executing command segment '{rendered}': {log_exception(e)}") | |
| return self._error( | |
| f"Error executing command segment '{rendered}', ask human for help: " | |
| f"{self._safe_exception_text(e)}" | |
| ) | |
| return self._ok(stdin_text) | |
| def format_result_dict(cls, result: Dict[str, Any]) -> str: | |
| """Format a tool result for the language model. | |
| Args: | |
| result: Result mapping to format or update. | |
| Returns: | |
| str: The operation result. | |
| """ | |
| if not result.get("success", False): | |
| detail = result.get("error") or result.get("stderr") or "No error detail was provided." | |
| return f"Command failed: {detail}" | |
| parts = [] | |
| if result.get("stdout"): | |
| parts.append(result["stdout"]) | |
| if result.get("stderr"): | |
| parts.append(f"Command stderr:\n{result['stderr']}") | |
| return ( | |
| "\n".join(parts) if parts else "Command completed successfully and produced no output." | |
| ) | |
| def execute_single(self, command: str, stdin_text: str = "") -> Dict[str, Any]: | |
| """Execute one command segment after pipeline parsing. | |
| Args: | |
| command: Parsed command and arguments. | |
| stdin_text: Text received from the previous pipeline segment. | |
| Returns: | |
| Structured command output and status. | |
| """ | |
| if self.context.workspace is None: | |
| return self._error("Workspace not initialized.") | |
| try: | |
| parts = shlex.split(command) | |
| except ValueError as e: | |
| return self._error(f"Command parse error: {e}") | |
| return self._execute_tokens(parts, stdin_text) | |
| def _parse_pipeline(command: str) -> list[list[str]]: | |
| """Parse the deliberately small shell-like language into a pipeline AST. | |
| Quotes and backslash escapes follow POSIX shell lexical rules, but the only | |
| executable operator is a single pipe. Shell evaluation, expansion, | |
| redirection, conditionals, command substitution, and background jobs are | |
| intentionally outside this language. | |
| """ | |
| try: | |
| lexer = shlex.shlex(command, posix=True, punctuation_chars="|&;<>") | |
| lexer.whitespace_split = True | |
| lexer.commenters = "" | |
| tokens = list(lexer) | |
| except ValueError as exc: | |
| raise CommandParseError(str(exc)) from exc | |
| pipeline: list[list[str]] = [[]] | |
| for token in tokens: | |
| if token == "|": | |
| if not pipeline[-1]: | |
| raise CommandParseError("empty command before pipe") | |
| pipeline.append([]) | |
| elif token and all(char in "|&;<>" for char in token): | |
| raise CommandParseError(f"operator {token!r} is not supported") | |
| else: | |
| pipeline[-1].append(token) | |
| if not pipeline[-1]: | |
| message = "empty command" if len(pipeline) == 1 else "empty command after pipe" | |
| raise CommandParseError(message) | |
| return pipeline | |
| def _execute_tokens(self, parts: list[str], stdin_text: str) -> Dict[str, Any]: | |
| if not parts: | |
| return self._error("Empty command.") | |
| cmd_name = parts[0] | |
| args = parts[1:] | |
| if cmd_name not in ALLOWED_COMMANDS: | |
| return self._error( | |
| f"Command '{cmd_name}' is not allowed. " | |
| f"Allowed: {', '.join(sorted(ALLOWED_COMMANDS))}" | |
| ) | |
| handler = getattr(self, f"_cmd_{cmd_name}", None) | |
| if handler is None: | |
| return self._error(f"Command '{cmd_name}' is not implemented.") | |
| try: | |
| return handler(args, stdin_text) | |
| except CommandParseError as exc: | |
| return self._error(str(exc)) | |
| def _ok(stdout: str) -> Dict[str, Any]: | |
| return {"success": True, "stdout": stdout, "stderr": "", "exit_code": 0} | |
| def _error(msg: str) -> Dict[str, Any]: | |
| return {"success": False, "stdout": "", "stderr": "", "error": msg, "exit_code": 1} | |
| def _bounded_output_limit(cls, value: Any) -> int: | |
| try: | |
| parsed = int(value) | |
| except (TypeError, ValueError): | |
| parsed = cls.DEFAULT_OUTPUT_LIMIT_BYTES | |
| return max(1024, min(cls.MAX_OUTPUT_LIMIT_BYTES, parsed)) | |
| def _limit_output(stdout: str, output_limit_bytes: int) -> str: | |
| writer = LimitedWriter(output_limit_bytes) | |
| writer.write(stdout) | |
| return writer.getvalue() | |
| def _parse_args( | |
| command: str, | |
| args: list[str], | |
| configure: Callable[[CommandArgumentParser], None], | |
| ) -> argparse.Namespace: | |
| parser = CommandArgumentParser(command) | |
| configure(parser) | |
| try: | |
| return parser.parse_intermixed_args(args) | |
| except argparse.ArgumentError as exc: | |
| raise CommandParseError(f"{command}: {exc}") from exc | |
| def _read_text(self, path: Path) -> str: | |
| if not path.is_file(): | |
| raise CommandParseError(f"Not a regular file: {path.name}") | |
| try: | |
| with self._workspace.safe_open( | |
| path, "r", encoding="utf-8", errors="replace" | |
| ) as f: | |
| return f.read() | |
| except OSError as exc: | |
| raise CommandParseError( | |
| f"Cannot read file: {self._safe_exception_text(exc)}" | |
| ) from exc | |
| def _workspace(self) -> Workspace: | |
| workspace = self.context.workspace | |
| if workspace is None: | |
| raise RuntimeError("Workspace not initialized.") | |
| return workspace | |
| def _safe_exception_text(self, error: BaseException) -> str: | |
| """Remove host workspace and mount locations from a user-visible error.""" | |
| message = str(error) | |
| workspace = self.context.workspace | |
| if workspace is None: | |
| return message | |
| replacements = [ | |
| (workspace.path, "."), | |
| *( | |
| (source, os.fspath(logical.relative_to(workspace.path))) | |
| for logical, source in workspace.mount_map.items() | |
| ), | |
| ] | |
| for host_path, visible_path in sorted( | |
| replacements, | |
| key=lambda pair: len(os.fspath(pair[0])), | |
| reverse=True, | |
| ): | |
| message = message.replace(os.fspath(host_path), visible_path) | |
| return message | |
| def _copy_file(self, source: str | os.PathLike, destination: str) -> str: | |
| """Copy one regular file while enforcing safe source and destination access.""" | |
| ws = self._workspace | |
| with ws.safe_open(source, "rb") as input_stream: | |
| with ws.open_for_write(destination, "wb") as output_stream: | |
| shutil.copyfileobj(input_stream, output_stream) | |
| return os.fspath(destination) | |
| def _reject_tree_symlinks(directory: str, names: list[str]) -> set[str]: | |
| """Reject recursive copies containing symbolic links instead of following them.""" | |
| symbolic_links = [name for name in names if (Path(directory) / name).is_symlink()] | |
| if symbolic_links: | |
| raise ValueError( | |
| "recursive copies do not follow symbolic links: " | |
| + ", ".join(symbolic_links) | |
| ) | |
| return set() | |
| def _find_tree_symlink(root: Path) -> Path | None: | |
| """Return the first symbolic link below a directory without following links.""" | |
| for directory, directories, filenames in os.walk(root, followlinks=False): | |
| parent = Path(directory) | |
| for name in (*directories, *filenames): | |
| candidate = parent / name | |
| if candidate.is_symlink(): | |
| return candidate | |
| return None | |
| def _nonnegative_int(value: str) -> int: | |
| try: | |
| parsed = int(value) | |
| except ValueError as exc: | |
| raise argparse.ArgumentTypeError("must be an integer") from exc | |
| if parsed < 0: | |
| raise argparse.ArgumentTypeError("must be non-negative") | |
| return parsed | |
| def _cmd_ls(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-a", "--all", action="store_true") | |
| parser.add_argument("-l", action="store_true", dest="long") | |
| parser.add_argument("paths", nargs="*") | |
| options = self._parse_args("ls", args, configure) | |
| targets = options.paths or ["."] | |
| sections = [] | |
| for target in targets: | |
| if (path := ws.resolve(target)) is None: | |
| return self._error(f"Invalid path: {target}") | |
| if not path.exists(): | |
| return self._error(f"No such file or directory: {target}") | |
| if path.is_dir(): | |
| entries = sorted( | |
| ( | |
| entry | |
| for entry in path.iterdir() | |
| if options.all or not entry.name.startswith(".") | |
| ), | |
| key=lambda entry: entry.name, | |
| ) | |
| names = [ | |
| entry.name + ("@" if entry.is_symlink() else "/" if entry.is_dir() else "") | |
| for entry in entries | |
| ] | |
| if options.long: | |
| rendered = [] | |
| for name, entry in zip(names, entries): | |
| info = entry.lstat() | |
| modified = datetime.fromtimestamp(info.st_mtime).strftime("%Y-%m-%d %H:%M") | |
| rendered.append( | |
| f"{stat.filemode(info.st_mode)} {info.st_size:>10} {modified} {name}" | |
| ) | |
| output = "\n".join(rendered) | |
| else: | |
| output = "\n".join(names) | |
| if len(targets) > 1: | |
| sections.append(f"{target}:\n" + output) | |
| else: | |
| sections.append(output) | |
| else: | |
| if options.long: | |
| info = path.stat() | |
| modified = datetime.fromtimestamp(info.st_mtime).strftime("%Y-%m-%d %H:%M") | |
| sections.append( | |
| f"{stat.filemode(info.st_mode)} {info.st_size:>10} {modified} {path.name}" | |
| ) | |
| else: | |
| sections.append(path.name) | |
| return self._ok("\n\n".join(sections)) | |
| def _cmd_cat(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-n", "--number", action="store_true") | |
| parser.add_argument("files", nargs="*") | |
| options = self._parse_args("cat", args, configure) | |
| if not options.files: | |
| text = stdin | |
| if options.number: | |
| text = "\n".join( | |
| f"{index:6}\t{line}" for index, line in enumerate(text.splitlines(), 1) | |
| ) | |
| return self._ok(text) | |
| parts: List[str] = [] | |
| for arg in options.files: | |
| if (path := ws.resolve(arg)) is None: | |
| return self._error(f"Invalid path: {arg}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {arg}") | |
| try: | |
| parts.append(self._read_text(path)) | |
| except Exception as e: | |
| return self._error(f"Cannot read file: {self._safe_exception_text(e)}") | |
| text = "".join(parts) | |
| if options.number: | |
| text = "\n".join( | |
| f"{index:6}\t{line}" for index, line in enumerate(text.splitlines(), 1) | |
| ) | |
| return self._ok(text) | |
| def _cmd_head(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| normalized = [ | |
| part | |
| for arg in args | |
| for part in (["-n", arg[1:]] if re.fullmatch(r"-\d+", arg) else [arg]) | |
| ] | |
| def configure(parser): | |
| parser.add_argument("-n", "--lines", type=self._nonnegative_int, default=10) | |
| parser.add_argument("files", nargs="*") | |
| options = self._parse_args("head", normalized, configure) | |
| if len(options.files) > 1: | |
| return self._error("head: at most one file is supported") | |
| if options.files: | |
| path = ws.resolve(options.files[0]) | |
| if path is None: | |
| return self._error(f"Invalid path: {options.files[0]}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.files[0]}") | |
| text = self._read_text(path) | |
| else: | |
| text = stdin | |
| lines = text.splitlines()[:options.lines] | |
| return self._ok("\n".join(lines)) | |
| def _cmd_tail(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| normalized = [ | |
| part | |
| for arg in args | |
| for part in (["-n", arg[1:]] if re.fullmatch(r"-\d+", arg) else [arg]) | |
| ] | |
| def configure(parser): | |
| parser.add_argument("-n", "--lines", type=self._nonnegative_int, default=10) | |
| parser.add_argument("files", nargs="*") | |
| options = self._parse_args("tail", normalized, configure) | |
| if len(options.files) > 1: | |
| return self._error("tail: at most one file is supported") | |
| if options.files: | |
| path = ws.resolve(options.files[0]) | |
| if path is None: | |
| return self._error(f"Invalid path: {options.files[0]}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.files[0]}") | |
| text = self._read_text(path) | |
| else: | |
| text = stdin | |
| lines = text.splitlines()[-options.lines:] if options.lines else [] | |
| return self._ok("\n".join(lines)) | |
| def _cmd_wc(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-l", "--lines", action="store_true") | |
| parser.add_argument("-w", "--words", action="store_true") | |
| parser.add_argument("-c", "--bytes", action="store_true") | |
| parser.add_argument("-m", "--chars", action="store_true") | |
| parser.add_argument("files", nargs="*") | |
| options = self._parse_args("wc", args, configure) | |
| selected = [options.lines, options.words, options.bytes, options.chars] | |
| enabled_counts = selected if any(selected) else [True, True, True, False] | |
| sources: list[tuple[str, str]] = [] | |
| if options.files: | |
| for name in options.files: | |
| path = ws.resolve(name) | |
| if path is None: | |
| return self._error(f"Invalid path: {name}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {name}") | |
| sources.append((name, self._read_text(path))) | |
| else: | |
| sources.append(("", stdin)) | |
| def counts(text: str) -> list[int]: | |
| values = [ | |
| len(text.splitlines()), | |
| len(text.split()), | |
| len(text.encode("utf-8")), | |
| len(text), | |
| ] | |
| return [value for value, enabled in zip(values, enabled_counts) if enabled] | |
| output = [ | |
| " ".join(map(str, counts(text))) + (f" {name}" if name else "") | |
| for name, text in sources | |
| ] | |
| if len(sources) > 1: | |
| totals = [sum(values) for values in zip(*(counts(text) for _, text in sources))] | |
| output.append(" ".join(map(str, totals)) + " total") | |
| return self._ok("\n".join(output)) | |
| def _cmd_grep(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-i", "--ignore-case", action="store_true") | |
| parser.add_argument("-v", "--invert-match", action="store_true") | |
| parser.add_argument("-n", "--line-number", action="store_true") | |
| parser.add_argument("-c", "--count", action="store_true") | |
| parser.add_argument("-F", "--fixed-strings", action="store_true") | |
| parser.add_argument("pattern") | |
| parser.add_argument("files", nargs="*") | |
| options = self._parse_args("grep", args, configure) | |
| if len(options.files) > 1: | |
| return self._error("grep: at most one file is supported") | |
| if options.files: | |
| path = ws.resolve(options.files[0]) | |
| if path is None: | |
| return self._error(f"Invalid path: {options.files[0]}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.files[0]}") | |
| text = self._read_text(path) | |
| else: | |
| text = stdin | |
| pattern = options.pattern.casefold() if options.ignore_case else options.pattern | |
| matched = [] | |
| for number, line in enumerate(text.splitlines(), 1): | |
| searchable = line.casefold() if options.ignore_case else line | |
| include = pattern in searchable | |
| if include == options.invert_match: | |
| continue | |
| matched.append( | |
| f"{number}:{line}" if options.line_number and not options.count else line | |
| ) | |
| return self._ok(str(len(matched)) if options.count else "\n".join(matched)) | |
| def _cmd_sort(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-r", "--reverse", action="store_true") | |
| parser.add_argument("-n", "--numeric-sort", action="store_true") | |
| parser.add_argument("-u", "--unique", action="store_true") | |
| parser.add_argument("files", nargs="*") | |
| options = self._parse_args("sort", args, configure) | |
| if len(options.files) > 1: | |
| return self._error("sort: at most one file is supported") | |
| if options.files: | |
| path = ws.resolve(options.files[0]) | |
| if path is None: | |
| return self._error(f"Invalid path: {options.files[0]}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.files[0]}") | |
| text = self._read_text(path) | |
| else: | |
| text = stdin | |
| lines = text.splitlines() | |
| if options.unique: | |
| lines = list(dict.fromkeys(lines)) | |
| if options.numeric_sort: | |
| try: | |
| lines.sort(key=lambda line: float(line.strip()), reverse=options.reverse) | |
| except ValueError: | |
| return self._error("sort: non-numeric input with -n") | |
| else: | |
| lines.sort(reverse=options.reverse) | |
| return self._ok("\n".join(lines)) | |
| def _cmd_cut(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-d", "--delimiter", default="\t") | |
| parser.add_argument("-f", "--fields", required=True) | |
| parser.add_argument("files", nargs="*") | |
| options = self._parse_args("cut", args, configure) | |
| if len(options.delimiter) != 1: | |
| return self._error("cut: delimiter must be a single character") | |
| if len(options.files) > 1: | |
| return self._error("cut: at most one file is supported") | |
| if options.files: | |
| path = ws.resolve(options.files[0]) | |
| if path is None: | |
| return self._error(f"Invalid path: {options.files[0]}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.files[0]}") | |
| text = self._read_text(path) | |
| else: | |
| text = stdin | |
| # 解析字段索引(1-based) | |
| try: | |
| field_indices = [int(field) - 1 for field in options.fields.split(",")] | |
| if any(index < 0 for index in field_indices): | |
| raise ValueError | |
| except ValueError: | |
| return self._error(f"Invalid field specification: {options.fields}") | |
| output_lines = [] | |
| for line in text.splitlines(): | |
| parts = line.split(options.delimiter) | |
| selected = [parts[i] if i < len(parts) else "" for i in field_indices] | |
| output_lines.append(options.delimiter.join(selected)) | |
| return self._ok("\n".join(output_lines)) | |
| def _cmd_cp(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-r", "-R", "--recursive", action="store_true") | |
| parser.add_argument("-f", "--force", action="store_true") | |
| parser.add_argument("paths", nargs="+") | |
| options = self._parse_args("cp", args, configure) | |
| if len(options.paths) != 2: | |
| return self._error("cp: exactly one source and one destination are supported") | |
| source, destination = options.paths | |
| src_path = ws.resolve(source) | |
| dst_path = ws.resolve(destination) | |
| if src_path is None: | |
| return self._error(f"Invalid source path: {source}") | |
| if dst_path is None: | |
| return self._error(f"Invalid destination path: {destination}") | |
| if not src_path.exists(): | |
| return self._error(f"No such file: {source}") | |
| if src_path.is_dir() and not options.recursive: | |
| return self._error(f"cp: omitting directory {source!r}; use -r") | |
| effective_destination = dst_path / src_path.name if dst_path.is_dir() else dst_path | |
| destination_name = str( | |
| Path(destination) / src_path.name if dst_path.is_dir() else Path(destination) | |
| ) | |
| dst_path = ws.resolve( | |
| destination_name, | |
| access="remove" if effective_destination.exists() else "create", | |
| ) | |
| if dst_path is None: | |
| return self._error(f"Invalid destination path: {destination}") | |
| if dst_path.exists() and os.path.samefile(src_path, dst_path): | |
| return self._error("cp: source and destination identify the same file") | |
| if src_path.is_dir(): | |
| try: | |
| dst_path.relative_to(src_path) | |
| except ValueError: | |
| pass | |
| else: | |
| return self._error("cp: cannot copy a directory into itself") | |
| try: | |
| if src_path.is_dir(): | |
| if dst_path.exists() and not options.force: | |
| return self._error(f"cp: destination already exists: {destination}") | |
| ws.create_directory(destination_name, parents=True, exist_ok=options.force) | |
| for directory, directories, filenames in os.walk(src_path, followlinks=False): | |
| parent = Path(directory) | |
| symbolic_links = [ | |
| name for name in (*directories, *filenames) | |
| if (parent / name).is_symlink() | |
| ] | |
| if symbolic_links: | |
| raise ValueError( | |
| "recursive copies do not follow symbolic links: " | |
| + ", ".join(symbolic_links) | |
| ) | |
| relative = parent.relative_to(src_path) | |
| for name in directories: | |
| child = Path(destination_name) / relative / name | |
| ws.create_directory(str(child), parents=True, exist_ok=options.force) | |
| for name in filenames: | |
| child = Path(destination_name) / relative / name | |
| self._copy_file(parent / name, str(child)) | |
| else: | |
| self._copy_file(src_path, destination_name) | |
| return self._ok("") | |
| except Exception as e: | |
| return self._error(f"cp failed: {self._safe_exception_text(e)}") | |
| def _cmd_mv(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-f", "--force", action="store_true") | |
| parser.add_argument("paths", nargs="+") | |
| options = self._parse_args("mv", args, configure) | |
| if len(options.paths) != 2: | |
| return self._error("mv: exactly one source and one destination are supported") | |
| source, destination = options.paths | |
| src_path = ws.resolve(source, access="remove") | |
| destination_probe = ws.resolve(destination) | |
| destination_name = str( | |
| Path(destination) / src_path.name | |
| if src_path is not None and destination_probe is not None and destination_probe.is_dir() | |
| else Path(destination) | |
| ) | |
| effective_probe = ws.resolve(destination_name) | |
| dst_path = ws.resolve( | |
| destination_name, | |
| access=( | |
| "remove" if effective_probe is not None and effective_probe.exists() else "create" | |
| ), | |
| ) | |
| if src_path is None: | |
| return self._error(f"Invalid source path: {source}") | |
| if dst_path is None: | |
| return self._error(f"Invalid destination path: {destination}") | |
| if not src_path.exists(): | |
| return self._error(f"No such file: {source}") | |
| if src_path == ws.path: | |
| return self._error("mv: refusing to move the workspace root") | |
| if dst_path.exists() and os.path.samefile(src_path, dst_path): | |
| return self._error("mv: source and destination identify the same file") | |
| if src_path.is_dir(): | |
| try: | |
| dst_path.relative_to(src_path) | |
| except ValueError: | |
| pass | |
| else: | |
| return self._error("mv: cannot move a directory into itself") | |
| try: | |
| ws.move(source, destination_name, overwrite=options.force) | |
| return self._ok("") | |
| except Exception as e: | |
| return self._error(f"mv failed: {self._safe_exception_text(e)}") | |
| def _cmd_rm(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-r", "-R", "--recursive", action="store_true") | |
| parser.add_argument("-f", "--force", action="store_true") | |
| parser.add_argument("paths", nargs="+") | |
| options = self._parse_args("rm", args, configure) | |
| for name in options.paths: | |
| path = ws.resolve(name, access="remove") | |
| if path is None: | |
| return self._error(f"Invalid path: {name}") | |
| if path == ws.path: | |
| return self._error("rm: refusing to remove the workspace root") | |
| if not os.path.lexists(path): | |
| if options.force: | |
| continue | |
| return self._error(f"No such file: {name}") | |
| try: | |
| ws.remove(name, recursive=options.recursive, missing_ok=options.force) | |
| except IsADirectoryError: | |
| return self._error(f"rm: cannot remove directory {name!r} without -r") | |
| except Exception as e: | |
| return self._error(f"rm failed: {self._safe_exception_text(e)}") | |
| return self._ok("") | |
| def _cmd_mkdir(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-p", "--parents", action="store_true") | |
| parser.add_argument("directories", nargs="+") | |
| options = self._parse_args("mkdir", args, configure) | |
| for name in options.directories: | |
| try: | |
| ws.create_directory(name, parents=options.parents, exist_ok=options.parents) | |
| except Exception as e: | |
| return self._error(f"mkdir failed: {self._safe_exception_text(e)}") | |
| return self._ok("") | |
| def _cmd_gunzip(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-k", "--keep", action="store_true") | |
| parser.add_argument("-f", "--force", action="store_true") | |
| parser.add_argument("file") | |
| options = self._parse_args("gunzip", args, configure) | |
| path = ws.resolve(options.file) | |
| if path is None: | |
| return self._error(f"Invalid path: {options.file}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.file}") | |
| output_name = ( | |
| str(Path(options.file).with_suffix("")) | |
| if Path(options.file).suffix == ".gz" | |
| else str(Path(options.file).parent / (Path(options.file).name + ".out")) | |
| ) | |
| output_probe = ws.resolve(output_name) | |
| out_path = ws.resolve( | |
| output_name, | |
| access="remove" if output_probe is not None and output_probe.exists() else "create", | |
| ) | |
| remove_path = None if options.keep else ws.resolve(options.file, access="remove") | |
| if out_path is None or (not options.keep and remove_path is None): | |
| return self._error("gunzip: read-only workspace inputs cannot be modified") | |
| if out_path.exists() and os.path.samefile(path, out_path): | |
| return self._error("gunzip: input and output identify the same file") | |
| if out_path.exists() and not options.force: | |
| return self._error(f"gunzip: output already exists: {out_path.name}; use -f") | |
| try: | |
| with ws.safe_open(path, "rb") as f: | |
| with gzip.GzipFile(fileobj=f, mode="rb") as f_in: | |
| with ws.open_for_write(output_name, "wb") as f_out: | |
| shutil.copyfileobj(f_in, f_out) | |
| if remove_path is not None: | |
| ws.remove(options.file) | |
| return self._ok(f"Decompressed to {out_path.name}") | |
| except Exception as e: | |
| return self._error(f"gunzip failed: {self._safe_exception_text(e)}") | |
| def _cmd_gzip(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| def configure(parser): | |
| parser.add_argument("-k", "--keep", action="store_true") | |
| parser.add_argument("-f", "--force", action="store_true") | |
| parser.add_argument("file") | |
| options = self._parse_args("gzip", args, configure) | |
| path = ws.resolve(options.file) | |
| if path is None: | |
| return self._error(f"Invalid path: {options.file}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.file}") | |
| output_name = str(Path(options.file).parent / (Path(options.file).name + ".gz")) | |
| output_probe = ws.resolve(output_name) | |
| out_path = ws.resolve( | |
| output_name, | |
| access="remove" if output_probe is not None and output_probe.exists() else "create", | |
| ) | |
| remove_path = None if options.keep else ws.resolve(options.file, access="remove") | |
| if out_path is None or (not options.keep and remove_path is None): | |
| return self._error("gzip: read-only workspace inputs cannot be modified") | |
| if out_path.exists() and os.path.samefile(path, out_path): | |
| return self._error("gzip: input and output identify the same file") | |
| if out_path.exists() and not options.force: | |
| return self._error(f"gzip: output already exists: {out_path.name}; use -f") | |
| try: | |
| with ws.safe_open(path, "rb") as f_in: | |
| with ws.open_for_write(output_name, "wb") as output_stream: | |
| with gzip.GzipFile(fileobj=output_stream, mode="wb") as f_out: | |
| shutil.copyfileobj(f_in, f_out) | |
| if remove_path is not None: | |
| ws.remove(options.file) | |
| return self._ok(f"Compressed to {out_path.name}") | |
| except Exception as e: | |
| return self._error(f"gzip failed: {self._safe_exception_text(e)}") | |
| def _cmd_unzip(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| import zipfile | |
| def configure(parser): | |
| parser.add_argument("-d", "--directory", default=".") | |
| parser.add_argument("file") | |
| options = self._parse_args("unzip", args, configure) | |
| path = ws.resolve(options.file) | |
| destination = ws.resolve(options.directory) | |
| if destination is not None and not destination.exists(): | |
| destination = ws.resolve(options.directory, access="create") | |
| if path is None: | |
| return self._error(f"Invalid path: {options.file}") | |
| if destination is None: | |
| return self._error(f"Invalid destination path: {options.directory}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.file}") | |
| try: | |
| with ws.safe_open(path, "rb") as f: | |
| zf = zipfile.ZipFile(f, "r") | |
| with zf: | |
| ws.create_directory(options.directory, parents=True, exist_ok=True) | |
| for member in zf.infolist(): | |
| member_relative = Path(member.filename) | |
| if member_relative.is_absolute() or ".." in member_relative.parts: | |
| return self._error(f"Unsafe path in archive: {member.filename}") | |
| logical_member = str(Path(options.directory) / member_relative) | |
| if Path(logical_member) == Path(options.file): | |
| return self._error( | |
| f"Archive member would overwrite its source: {member.filename}" | |
| ) | |
| mode = member.external_attr >> 16 | |
| if stat.S_ISLNK(mode): | |
| return self._error( | |
| f"Symbolic links are not allowed in archives: {member.filename}" | |
| ) | |
| file_type = stat.S_IFMT(mode) | |
| if file_type and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)): | |
| return self._error( | |
| f"Special files are not allowed in archives: {member.filename}" | |
| ) | |
| if member.is_dir(): | |
| ws.create_directory(logical_member, parents=True, exist_ok=True) | |
| else: | |
| parent = str(Path(logical_member).parent) | |
| ws.create_directory(parent, parents=True, exist_ok=True) | |
| with zf.open(member, "r") as input_stream: | |
| with ws.open_for_write(logical_member, "wb") as output_stream: | |
| shutil.copyfileobj(input_stream, output_stream) | |
| return self._ok(f"Extracted to {options.directory}") | |
| except Exception as e: | |
| return self._error(f"unzip failed: {self._safe_exception_text(e)}") | |
| def _cmd_tar(self, args: list, stdin: str) -> Dict[str, Any]: | |
| ws = self._workspace | |
| import tarfile | |
| def configure(parser): | |
| parser.add_argument("-x", "--extract", action="store_true") | |
| parser.add_argument("-z", "--gzip", action="store_true") | |
| parser.add_argument("-f", "--file", required=True) | |
| parser.add_argument("-C", "--directory", default=".") | |
| options = self._parse_args("tar", args, configure) | |
| path = ws.resolve(options.file) | |
| destination = ws.resolve(options.directory) | |
| if destination is not None and not destination.exists(): | |
| destination = ws.resolve(options.directory, access="create") | |
| if path is None: | |
| return self._error(f"Invalid path: {options.file}") | |
| if destination is None: | |
| return self._error(f"Invalid destination path: {options.directory}") | |
| if not path.exists(): | |
| return self._error(f"No such file: {options.file}") | |
| if not options.extract: | |
| return self._error("Only tar extraction (-x) is supported.") | |
| mode = "r:gz" if options.gzip else "r:*" | |
| try: | |
| ws.create_directory(options.directory, parents=True, exist_ok=True) | |
| with ws.safe_open(path, "rb") as f: | |
| tf = tarfile.open(fileobj=f, mode=mode) | |
| with tf: | |
| for member in tf.getmembers(): | |
| if not (member.isfile() or member.isdir()): | |
| return self._error( | |
| f"Links and special files are not allowed in archives: " | |
| f"{member.name}" | |
| ) | |
| member_relative = Path(member.name) | |
| if member_relative.is_absolute() or ".." in member_relative.parts: | |
| return self._error(f"Unsafe path in archive: {member.name}") | |
| logical_member = str(Path(options.directory) / member_relative) | |
| if Path(logical_member) == Path(options.file): | |
| return self._error( | |
| f"Archive member would overwrite its source: {member.name}" | |
| ) | |
| if member.isdir(): | |
| ws.create_directory(logical_member, parents=True, exist_ok=True) | |
| else: | |
| parent = str(Path(logical_member).parent) | |
| ws.create_directory(parent, parents=True, exist_ok=True) | |
| input_stream = tf.extractfile(member) | |
| if input_stream is None: | |
| raise ValueError(f"Unable to read archive member: {member.name}") | |
| with input_stream: | |
| with ws.open_for_write(logical_member, "wb") as output_stream: | |
| shutil.copyfileobj(input_stream, output_stream) | |
| return self._ok(f"Extracted to {options.directory}") | |
| except Exception as e: | |
| return self._error(f"tar failed: {self._safe_exception_text(e)}") | |