Spaces:
Running
Running
File size: 28,535 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 | """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:[<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)"
|