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)"