Spaces:
Running
Running
| """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}], "" | |
| 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:[<block>], 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 - <argv>` 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)" | |