"""create_file tool — write and edit deliverable files inside the sandbox, mirroring read_file.""" from __future__ import annotations import json import logging import os import shlex import sys from typing import Any from frontier_agent.core.tool import tool from plugins.tools._create_file import writer_src from plugins.tools._deliverable_policy import ( declared_output_paths, output_write_error, ) from plugins.tools._path_auth import _path_within from plugins.tools._sandbox import ( _DEFAULT_OUTPUTS_DIR, _DEFAULT_WORKSPACE_DIR, aget_sandbox, arun_sandbox_cmd, resolve_mount_dirs, resolve_runtime_path, resolve_sandbox_mode, ) logger = logging.getLogger(__name__) _WRITER_SRC = writer_src() _DOC_EXTS = {"docx", "xlsx", "pptx"} _TEXT_EXTS = {"txt", "md", "csv", "tsv", "json", "jsonl", "html", "htm"} _ALL_EXTS = _DOC_EXTS | _TEXT_EXTS _TIMEOUT = 120 #: Ceiling for the ASSEMBLED command, which reaches ``sh -c`` as one execve #: argument. Linux caps a single argument at MAX_ARG_STRLEN (32 pages = #: 131072 bytes); this leaves margin for any wrapper a backend prepends. #: #: Measured after ``shlex.quote``, not on the raw payload: quoting expands each #: apostrophe to four bytes (``'`` -> ``'"'"'``), so a 30KB body of apostrophes #: assembles into a 150KB command. Bounding the payload alone let exactly that #: case through to a bare E2BIG. _MAX_COMMAND_BYTES = 120 * 1024 _PATH_ARGUMENT_KEYS = frozenset({"path", "image_path", "out", "output"}) def _runtime_ops_paths(ops: list[dict[str, Any]]) -> list[dict[str, Any]]: """Rewrite only fields whose schema denotes a filesystem path. Text content may legitimately mention ``/outputs`` and must stay literal, so this intentionally does not rewrite arbitrary strings recursively. """ def rewrite(value: Any) -> Any: if isinstance(value, list): return [rewrite(item) for item in value] if not isinstance(value, dict): return value rewritten: dict[str, Any] = {} for key, item in value.items(): if key in _PATH_ARGUMENT_KEYS and isinstance(item, str): rewritten[key] = resolve_runtime_path(item) else: rewritten[key] = rewrite(item) return rewritten return rewrite(ops) def _inlined_ops_program(program_path: str) -> str | None: """Return an ``@program`` file's ops inlined with runtime paths resolved. ``None`` means "keep the ``@`` reference": the program is not readable from this process (container / e2b keep it in the sandbox namespace, where the aliases are the real mount points and need no rewrite), it is not a JSON array (let the writer report that, as before), or inlining it would push the assembled command past ``_MAX_COMMAND_BYTES`` — the large-program case the ``@`` form exists to serve. """ if resolve_sandbox_mode() != "native": return None try: with open(program_path, encoding="utf-8") as fh: program = json.load(fh) except (OSError, json.JSONDecodeError): return None if not isinstance(program, list): return None payload = json.dumps(_runtime_ops_paths(program), ensure_ascii=False) return payload if len(payload) <= _MAX_COMMAND_BYTES // 2 else None def _write_roots() -> tuple[str, ...]: """The roots ``create_file`` may write under, resolved per call. The container mount points (``/workspace`` / ``/outputs`` — the convention this tool's docstring teaches the model) plus whatever :func:`resolve_mount_dirs` currently maps them to. Native mode overrides them to real host directories and then hands the model those exact paths (``apodex.session._deliverable_context``), so accepting only the literals refused every native-mode write. """ workspace, outputs, _inputs = resolve_mount_dirs() return tuple(dict.fromkeys( (_DEFAULT_WORKSPACE_DIR, _DEFAULT_OUTPUTS_DIR, workspace, outputs), )) def _outside_write_roots(path: str) -> bool: """True when *path* is not contained by any write root. Containment is component-aware and computed on the normalized path, not by string prefix: with a resolved native root like ``/task/outputs``, a prefix test also accepts the sibling ``/task/outputs-escape/x.md`` and lets ``/task/outputs/../x.md`` through. Since the roots include real host directories, that is a host write escape rather than a cosmetic gap. ``normpath`` (not ``resolve``) keeps the check honest about what the writer will do: the sandbox resolves symlinks on its own filesystem, which may not be the one this process sees. """ normalized = os.path.normpath(path) return not any( _path_within(normalized, os.path.normpath(root)) for root in _write_roots() ) def _deliverable_ops_error(path: str, ops: list[dict[str, Any]]) -> str | None: """Guard the *secondary* write targets inside ops. ``export_pdf`` writes a second file: ``out``, or ``path`` with a ``.pdf`` extension when ``out`` is omitted. Without this check a publisher declaring ``/outputs/report.docx`` would silently also produce ``/outputs/report.pdf`` — exactly the undeclared sidecar the manifest exists to prevent. No-op for workflows that have not opted in. """ for i, it in enumerate(ops): if not isinstance(it, dict): continue for op, args in it.items(): params = args if isinstance(args, dict) else {} targets = [] explicit = str(params.get("out") or params.get("output") or "").strip() if explicit: targets.append(explicit) elif op == "export_pdf": targets.append(path.rsplit(".", 1)[0] + ".pdf") for target in targets: err = output_write_error(target) if err: return f"Error: ops[{i}] ({op}) writes {target!r}. {err}" return None def _ops_program_error(path: str, program_path: str) -> str | None: """Validate an ``@/workspace/program.json`` ops file against the manifest. The writer resolves the reference inside the sandbox, so its ops are invisible at this boundary. When a manifest is active and the program cannot be read here, fail closed rather than let an unaudited ``export_pdf`` target through. """ if declared_output_paths() is None: return None try: with open(program_path, encoding="utf-8") as fh: program = json.load(fh) except (OSError, json.JSONDecodeError): return ( f"Error: ops program {program_path!r} cannot be validated against " "the deliverable manifest here. Pass the ops array inline instead." ) if not isinstance(program, list): return f"Error: ops program {program_path!r} must contain a JSON array." return _deliverable_ops_error(path, program) def _validate_ops(ops: list[dict[str, Any]]) -> str | None: """Every ops entry must be a single-key object {op: {params}}; returns an error string when it is not, else None.""" for i, it in enumerate(ops): if not isinstance(it, dict) or len(it) != 1: return (f'Error: ops[{i}] must be a single-key object {{op: {{params}}}}, ' f'e.g. {{"set_cell": {{"sheet":"S","cell":"A1","value":1}}}}; got {it!r}') v = next(iter(it.values())) if v is not None and not isinstance(v, dict): return f"Error: ops[{i}] params must be an object, got {type(v).__name__}" return None def _failure_detail(result: Any) -> str: """Both of the writer's streams, stdout first. The writer reports *which* op failed and why on stdout — that receipt, and the containment refusals from ``_abort``, exist to be read by the model so it can correct its own call. Preferring ``stderr`` whenever it is non-empty throws the receipt away the moment anything else speaks up on that stream: an ``openpyxl`` warning, a line from ``soffice``, a stray installer message. A 50-op batch would then fail as "writer exited 1: UserWarning: ..." with no way to tell which op it was. """ parts = [ stream.strip() for stream in (getattr(result, "stdout", ""), getattr(result, "stderr", "")) if (stream or "").strip() ] return "\n".join(parts) or "(no output)" def desugar_text_shorthand( *, ops: list[dict[str, Any]] | str | None, content: str | None, rows: list[Any] | str | None, data: dict[str, Any] | list[Any] | str | None, overwrite: bool = False, ) -> tuple[list[dict[str, Any]] | str | None, str]: """Fold a top-level ``content``/``rows``/``data`` into one ``create`` op. Returns ``(ops, error)``; a non-empty error is the tool's whole reply. The model reaches for ``content=`` because it is the obvious name for a file's body, and because the tool guidance named it in a sentence about calling the tool when it is really a ``create`` op param. Measured over two runs: 8 calls lost to ``unexpected keyword argument 'content'``, from five different sub-agents, a turn each. Re-wording the guidance might help; desugaring cannot fail to. The op layer stays the single implementation, so the shorthand inherits its guards — notably the refusal to overwrite an existing file without being told to. """ shorthand: dict[str, Any] = { key: value for key, value in (("content", content), ("rows", rows), ("data", data)) if value is not None } if not shorthand: return ops, "" if ops is not None: # Silently preferring one would write a file the caller did not describe. return ops, ( "Error: pass EITHER ops, OR the content/rows/data shorthand — got " f"both (shorthand: {', '.join(sorted(shorthand))}). The shorthand " 'is exactly ops=[{"create": {...}}]; to do more than one operation, ' "put everything in ops." ) if len(shorthand) > 1: return ops, ( "Error: pass exactly ONE of content, rows, or data — got " f"{', '.join(sorted(shorthand))}. These inputs are alternatives; " "combining them would silently discard all but one." ) if overwrite: shorthand["overwrite"] = True return [{"create": shorthand}], "" @tool async def create_file( path: str, ops: list[dict[str, Any]] | str | None = None, content: str | None = None, rows: list[Any] | str | None = None, data: dict[str, Any] | list[Any] | str | None = None, overwrite: bool = False, ) -> str: """Create or edit a deliverable file in the sandbox — office (docx/xlsx/pptx) OR text (txt/md/csv/tsv/json/jsonl/html/htm). This is THE tool for producing EVERY deliverable file — .docx / .xlsx / .pptx (plus PDF via export_pdf), AND text files .txt / .md / .csv / .tsv / .json / .jsonl / .html / .htm. Author every deliverable through it. The ONLY case where you may fall back to Python libraries (python-docx / openpyxl / python-pptx / reportlab) or bash is when a create_file call has EXPLICITLY returned an unsupported-operation/extension error for what the task requires — try create_file first; do not decide on your own that a feature is unsupported. The fallback order is strict: first create_file; then Python libraries; only then, when Python still cannot cover the operation or the user requires accurate preservation of an existing template that Python would not preserve, use the runtime-advertised `docx` or `pptxgenjs` packages through bash. `NODE_PATH` is already configured, so load them by package name instead of a hard-coded install path. Never hand-build deliverables with bash (echo / cat / heredoc / redirection); a csv/md/txt deliverable goes through create_file, NOT a shell redirect. For a whole text file in one shot pass `content` (or `rows` / `data`) directly and leave `ops` out: create_file(path="/outputs/report.md", content="# Title\n..."). Write scratch to /workspace and ONLY the final deliverable(s) to /outputs, kept clean (no scratch or duplicate versions). Pass `ops`, a JSON array where EACH item is a single-key object {op_name: {params}}, applied IN ORDER to the same file (do MANY operations in ONE call): ops=[{"create":{"sheets":[...]}}, {"set_cell":{"sheet":"S","cell":"B2","value":42,"type":"number"}}, {"set_cell_format":{"sheet":"S","cell_range":"A1:C1","bold":true}}] Run set_cell 50 times = 50 items in ONE call (not 50 calls). One sandbox call runs them sequentially; if an op errors, execution STOPS there and the result lists what ran. Each item must have EXACTLY ONE key (the op name). For a large program, write the JSON array to a /workspace file and pass ops="@/workspace/program.json". PATHS — only two directories are writable and persistent in the sandbox: • /workspace — your private scratch dir; intermediate files + the ops JSON. • /outputs — final deliverables ONLY (this is what gets collected/graded). Exception: /outputs/scratch/ persists across rounds — put intermediate products worth reusing in a later round there. NOT a deliverable, never shown to the user; 512MB quota (over-quota writes error until you delete files there). Only the literal top-level /outputs/scratch/ counts. Any other location (e.g. /home/..., /tmp/...) is NOT mounted: writes/reads there FAIL or do not persist across calls. So `path` (and any ops "@file") must be under /workspace or /outputs — write the final deliverable to /outputs. Incremental load-modify-save (existing files are edited in place, untouched parts preserved). Anchors use STABLE references: xlsx by sheet name + A1 cell/range; pptx by 1-based slide number + placeholder role; docx by anchor TEXT (find / after_text) — never fragile positional indices. CREATE is for a NEW file. Calling `create` on a path that ALREADY exists is REFUSED (so you never silently wipe prior content) — to add to an existing file use insert_*/set_cell/add_slide/replace_text; pass {"overwrite":true} only if you truly mean to rebuild from scratch. XLSX FORMULAS — write Excel FORMULAS, do NOT compute values in your head and hardcode the number: use {"set_cell":{...,"value":"=SUM(B2:B9)","type":"formula"}} (or value starting with "="), NOT the literal sum. Formula caches are EMPTY until recalculated; create_file auto-runs LibreOffice recalc on save whenever the batch wrote any formula, fills the cached values, and reports any formula errors (#DIV/0!, #REF!, ...) back in the result so you can fix them. TEXT IS LITERAL — formatting goes through structured params, NEVER Markdown. Any "text" field accepts RichText: a plain string, OR a list of runs for inline formatting / links: [{"text":"Total ","bold":true}, {"text":"site","link":"https://x.com"}] Run fields (all optional except text): bold, italic, underline, strike, color("RRGGBB"), size(pt), font, link(url). Do NOT write "**bold**", "[t](url)", "- item" or "# h" in text — they are written verbatim; use the params below instead. FONTS — the deliverable is downloaded and opened on an unknown platform, so name only fonts that exist almost everywhere; anything else is silently substituted (different glyphs, different widths, shifted line/page breaks): Latin : Arial / Times New Roman / Courier New / Calibri / Cambria Chinese : SimSun (宋体, serif) / SimHei (黑体) / Microsoft YaHei (微软雅黑, sans) Japanese : MS Gothic / MS Mincho / Meiryo / Yu Gothic Korean : Malgun Gothic / Batang Use a font outside this list ONLY WHEN the task explicitly asks for it — Linux-only families in particular (Noto Sans CJK *, Source Han *, DejaVu *, Liberation *) are absent on stock Windows/macOS, so a reader always gets a substitution. When the text contains Chinese/Japanese/Korean, name a CJK family from the list above (`font` applies to the CJK characters too); a Latin-only font leaves CJK to whatever the reader's app falls back to. Fewer fonts = fewer surprises: prefer one family per document. op + args by format (args is a JSON object): docx: create{blocks:[], metadata?} — block.type: heading{text:RichText, level:1-9, align?} | paragraph{text:RichText, align?, style?, list?, page_break_before?, keep_with_next?, line_spacing?, space_before?(pt), space_after?(pt)} | table{rows:[[Cell,...]], header?(true→bold + repeating w:tblHeader), column_widths_in?} | image{path,width_in?,height_in?} | page_break list = {type:bullet|number, level:0-8} (use this, not "- "/"1.") Cell = RichText, or {content:RichText, bold?, align?, fill_color?} replace_text{find,replace,count?} | insert_paragraph{text:RichText,after_text?,style?,list?,page_break_before?,keep_with_next?} | insert_heading{text:RichText,level?,after_text?} | insert_table{rows,after_text?,header?,column_widths_in?,cant_split?} | format_text{find,bold?,italic?,underline?,strike?,size?,color?} | format_paragraph{find, line_spacing?, space_before?(pt), space_after?(pt), align?, keep_with_next?, page_break_before?} — tune an existing paragraph by anchor text | add_hyperlink{find,url} — turn existing text into a real hyperlink | set_page_number{location:footer|header, align?, start?, of_total?, fmt?} | set_page_margins{...} | set_page_orientation{...} | set_header_footer{...} | add_image{image_path,after_text?,width?,height?} xlsx: create{sheets:[{name,headers?,rows?}]} | set_cell{sheet,cell,value, type?:auto|number|text|formula|date|bool, number_format?} | set_range{sheet,start_cell,rows, types?} | add_sheet{sheet,headers?,rows?} | delete_sheet{sheet} | set_cell_format{sheet,cell_range, bold?,italic?,font_color?,font_size?,font_name?, fill_color?,align_h?,align_v?,wrap?,number_format?,border?} | add_table{sheet,cell_range,name?,headers?,style?} — make a real Excel Table (ListObject + filter) | add_chart{sheet,data_range,chart_type,anchor_cell?,title?,width?,height?} | clear_charts{sheet} | merge_cells | unmerge_cells | freeze_panes{sheet,cell} | set_column_width{sheet,columns,hidden?:[cols]} | set_row_height{sheet,rows,hidden?:[rownums]} | set_page_setup{sheet, orientation?:portrait|landscape, fit_to_width?, fit_to_height?, scale?, paper_size?:a4|letter|legal|a3, margins?:{left,right,top,bottom}, center_h?, center_v?, print_area?("A1:H40"), print_title_rows?("1:1"), print_title_cols?("A:A")} | rename_sheet{sheet,new} | hide_sheet{sheet} | show_sheet{sheet} | add_named_range | delete_named_range | add_data_validation | add_conditional_formatting | set_auto_filter | set_number_format | add_image number_format named enum: general/integer/number2/percent/percent2/currency_usd/ currency_eur/accounting/date_iso/date_us/datetime/time/scientific/text (or raw fmt) pptx: create{slides:[{layout?,title:RichText,subtitle:RichText,body?,table?,notes:RichText}]} | body = {items:[{text:RichText, level:0-4, bullet:true|false}], autofit?} (or ["a","b"]) | add_slide{layout?,title?,subtitle?,body?,notes?,index?} | set_text{slide,placeholder:title|body|subtitle,text:RichText} | add_textbox{slide,text:RichText,x?,y?,w?,h?,autofit?,align_h?} | add_table{slide,rows,...} | add_image{slide,...} | set_notes{slide,text:RichText} | replace_text{find,replace,slide?} | add_shape{slide,shape,...} | add_chart{slide,...} | format_text{slide,find,...} | duplicate_slide{slide} | delete_slide{slide} | set_slide_size{preset:16:9|4:3|16:10 OR width_in,height_in} — canvas size/aspect autofit = none|shrink_text|resize_shape (default shrink_text recommended) text (txt/md/csv/tsv/json/jsonl/html): content is written LITERALLY (no Markdown/ HTML parsing — for a .md/.html file the markup IS its content). create{content?, rows?, data?, overwrite?} — new file; supply ONE of: content = the full file text (string); for .json a string/obj both work | rows = list of rows → csv/tsv (list-of-lists, auto-quoted) | jsonl (list of objects, one JSON per line) | txt/md (one per line) | data = a JSON value → pretty-printed for .json (list → one-per-line for .jsonl) append{content? | rows?} — append (newline-separated) | replace_text{find, replace, count?} — literal find/replace any: export_pdf{out?} — set `path` to an existing docx/xlsx/pptx → converted to PDF (LibreOffice). Build the document first, then export_pdf. Args: path: Absolute path of the file to write — under /outputs for a final deliverable, or /workspace for an intermediate file. Not other dirs. ops: JSON array of operations; each item is a single-key object {op_name: {params}}, applied in order. May be a JSON array string, or "@/workspace/program.json" pointing to such an array. Omit it when you are using the content/rows/data shorthand below. content: Shorthand for a text file written in one shot — the literal file body. Equivalent to ops=[{"create": {"content": ...}}]. Text formats only (txt/md/csv/tsv/json/jsonl/html/htm). rows: Shorthand alternative to `content`: an array of csv/tsv rows, or of jsonl objects. data: Shorthand alternative to `content`: a JSON object/array to serialise into the file. overwrite: Allow the shorthand to rebuild a file that already exists. Without it an existing path is refused, so you never silently wipe prior content. Returns: A per-op result summary, or an error/hint message. """ if not path or not path.strip(): return "Error: path is required." ops, shorthand_error = desugar_text_shorthand( ops=ops, content=content, rows=rows, data=data, overwrite=overwrite, ) if shorthand_error: return shorthand_error deliverable_error = output_write_error(path) if deliverable_error: return f"Error: {deliverable_error}" ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" if ext not in _ALL_EXTS: return (f"[create_file] unsupported extension .{ext}; supports docx/xlsx/pptx " "and text formats txt/md/csv/tsv/json/jsonl/html/htm") # C1: the path must be under a write root (other directories are not mounted, so writes/reads spin on ENOENT). # Lexical only — the writer re-checks against the real filesystem it opens. roots = _write_roots() _bad_path = _outside_write_roots if _bad_path(path): return (f"Error: path must be under /workspace or /outputs (got {path!r}); " "other dirs like /home or /tmp are NOT mounted and will fail. " "Write final deliverables to /outputs, intermediates to /workspace.") if ops is None: return ('Error: `ops` is required — a JSON array of single-key ops, e.g. ' '[{"create":{...}}, {"set_cell":{"sheet":"S","cell":"A1","value":1}}]') runtime_path = resolve_runtime_path(path) if isinstance(ops, str): s = ops.strip() if s.startswith("@"): # @/workspace/program.json if _bad_path(s[1:]): return f"Error: ops file must be under /workspace (got {s[1:]!r})." runtime_program_path = resolve_runtime_path(s[1:]) err = _ops_program_error(path, runtime_program_path) if err: return err # The program's own path is resolved above; the paths INSIDE it # need the same treatment, or an ``@program`` that names # /workspace/chart.png fails in native mode while the identical # inline array works. Inlining the (already validated) program is # how those fields reach ``_runtime_ops_paths`` at all. payload = _inlined_ops_program(runtime_program_path) if payload is None: payload = "@" + runtime_program_path else: try: parsed = json.loads(s) except json.JSONDecodeError as e: return f"Error: ops is a string but not valid JSON: {e}" if not isinstance(parsed, list): return 'Error: ops must be a JSON array, e.g. [{"set_cell":{...}}, ...]' err = _validate_ops(parsed) or _deliverable_ops_error(path, parsed) if err: return err payload = json.dumps(_runtime_ops_paths(parsed), ensure_ascii=False) elif isinstance(ops, list): err = _validate_ops(ops) or _deliverable_ops_error(path, ops) if err: return err payload = json.dumps(_runtime_ops_paths(ops), ensure_ascii=False) else: return f"Error: ops must be a list (or JSON array string), got {type(ops).__name__}" # The writer source goes over stdin (input=_WRITER_SRC), leaving only # `python3 - ` on the command line — the same shape read_file uses for # its reader bundle. Echoing the bundle into argv instead overflows execve's # 128KB single-argument limit (MAX_ARG_STRLEN): the writer is ~131KB # base64-encoded, so *every* call failed with a bare "Argument list too # long". # # The roots ride along so the writer can re-check containment against the # filesystem it will actually open (see ``_escapes_write_roots``); the # lexical check above cannot follow symlinks in another namespace. writer_python = sys.executable if resolve_sandbox_mode() == "native" else "python3" cmd = ( f"{shlex.quote(writer_python)} - {shlex.quote(runtime_path)} {shlex.quote(payload)} " f"{shlex.quote(json.dumps(roots))}" ) # Bound the FINAL command, quoting included — see _MAX_COMMAND_BYTES. Done # before the sandbox is acquired: provisioning one only to discard the call # would cost a VM start on the remote backends. assembled = len(cmd.encode("utf-8")) if assembled > _MAX_COMMAND_BYTES: return ( f"Error: this call assembles a {assembled:,}-byte command, over the " f"{_MAX_COMMAND_BYTES:,}-byte limit for one command line (shell " "quoting can expand the ops payload several times over). Write the " "JSON array to a workspace file and pass it by reference instead — " 'create_file(path=..., ops="@/workspace/program.json") — which has ' "no size limit." ) try: sandbox = await aget_sandbox() except RuntimeError as e: raise RuntimeError(f"create_file could not acquire a sandbox: {e}") from e try: result = await arun_sandbox_cmd( sandbox, cmd, timeout=_TIMEOUT, input=_WRITER_SRC, ) except TimeoutError: return f"Error: create_file timed out after {_TIMEOUT}s" except Exception as e: logger.warning("create_file error for '%s': %s", path, e) raise RuntimeError(f"create_file failed for {path!r}: {e}") from e # Outside the try: a writer that ran and reported a failure is an ordinary # result, not an unexpected fault. Raising it inside would have it caught # by the handler above, re-wrapped with a second prefix and logged as if # the sandbox call itself had blown up. if result.exit_code != 0: raise RuntimeError( f"create_file writer exited {result.exit_code}: {_failure_detail(result)}" ) return result.stdout or "(no output)"