file_convert renders a workspace file from one format to another by dispatching
over a data-driven recipe table. The conversion runs at daemon privilege behind a
hardened security seam, so an agent can produce (for example) a PDF without
holding shell_exec and without routing through a privileged helper agent.
The v1 (MVP) recipe table ships exactly one row — md -> pdf, wrapping
scripts/build-pdf.sh (pandoc + typst). New formats are added as manifest rows,
with zero Rust changes.
file_convert(format, input, output?, preset?)
| Param | Required | Meaning |
|---|---|---|
format |
yes | Target format (e.g. "pdf"). Must match a recipe's to. |
input |
yes | Workspace-relative path to the source file. Its extension selects the recipe's from. |
output |
no | Workspace-relative output path. Defaults to the input path with the recipe's out_ext. |
preset |
no | Render preset key (e.g. "mobile", "desktop") selecting manifest-authored size/scale. Must be one the target recipe offers; omit to use the recipe's default_preset. Ignored by recipes with no presets. |
Success and conversion failures are returned as a structured JSON envelope:
{ "ok": true, "format": "pdf", "output_path": "out/report.pdf" }{
"ok": false,
"format": "pdf",
"error": {
"code": "MISSING_DEP",
"message": "file_convert md->pdf needs 'typst', not found on PATH. Recipe present, binary missing."
}
}Error codes: UNKNOWN_FORMAT, UNKNOWN_PRESET, BAD_PATH, MISSING_DEP, CONVERT_FAILED.
(Genuinely malformed calls — e.g. a missing format/input argument — surface
as a plain tool error, not an envelope.)
Some recipes (e.g. html -> png) render at a chosen size. Rather than letting
a caller pass raw width/height/scale text into the conversion command, a recipe
declares a named-preset menu and the caller selects one by key. The
dimension/scale strings that reach the conversion command are manifest-
authored, never caller text -- so presets add dimension control with no new
argv trust surface.
[[recipe]]
from = "html"
to = "png"
argv = ["{script}/html2png.sh", "{input}", "-o", "{output}", "--viewport", "{viewport}", "--scale", "{scale}"]
needs = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
out_ext = "png"
default_preset = "mobile"
[recipe.presets.mobile]
viewport = "390,844"
scale = "3"
[recipe.presets.desktop]
viewport = "1280,800"
scale = "2"A call of file_convert("png", "mock.html", preset="desktop") substitutes the
desktop preset's {viewport}/{scale} into argv. Omitting preset uses
default_preset. An unknown preset, or a preset on a recipe that defines none,
returns UNKNOWN_PRESET (fail-closed, no spawn).
Preset rules enforced at manifest load (a violation is a hard Invalid error):
default_presetis required iffpresetsis non-empty, and must name a real preset.- Every argv
{var}beyond{script}/{input}/{output}must be defined by every preset (so no preset leaves a token unsubstituted). - A preset may not reuse the reserved names
script/input/output.
file_convert is a normal grantable capability. Add it to an agent's allowed
tools in its agent.toml:
[capabilities]
tools = [
# ... existing tools ...
"file_convert",
]An agent that holds the grant sees file_convert advertised; one that does not,
will not. The tool is deliberately not a shell tool, so granting it does not
grant shell access, and it cannot bypass the approval gate via exec_policy.
Conversions are data. To add one, drop a recipe row into the manifest at
<openfang_home>/convert/recipes.toml (openfang_home = $OPENFANG_HOME, else
~/.openfang):
[[recipe]]
from = "html"
to = "pdf"
argv = ["{script}/html2pdf.sh", "{input}", "-o", "{output}"]
needs = ["wkhtmltopdf"]
out_ext = "pdf"Tokens, substituted at dispatch time (never by a shell):
| Token | Becomes |
|---|---|
{script} |
<openfang_home>/scripts (absolute) |
{input} |
sandbox-resolved absolute input path |
{output} |
sandbox-resolved absolute output path |
argv[0] is the program; every other entry is exactly one literal argument.
There is no shell parsing, globbing, or word-splitting.
Manifest rules:
- Absent manifest -> the compiled-in default table (the single
md -> pdfrow) is used, so a fresh install converts with no config. - Present manifest -> it replaces the default entirely and is validated. A present-but-malformed manifest is a hard error (fail-closed) — never silently downgraded to the default.
- Each
(from, to)pair must be unique;argvandout_extare required;needsis optional.
- Allowlisted formats — only
(from, to)pairs present in the recipe table convert. Unknown pairs fail closed (UNKNOWN_FORMAT). - Workspace-scoped paths — both
inputandoutputare resolved through the workspace sandbox:.., absolute escapes, and symlink escapes are rejected (BAD_PATH). - No shell string, ever — the recipe
argvis spawned as an argv array. Caller-supplied paths are substituted as literal arguments, so a filename likeevil; $(rm -rf ~)reaches the program as one inert argument. - Absolute launcher —
argv[0]must resolve to an absolute, existing file. The daemon's bare PATH is never trusted to find the launcher. - Call-time preflight — the launcher and every
needsbinary must resolve before the subprocess starts. A miss isMISSING_DEPwith no partial run. - Guaranteed PATH prefix — the child gets a cleared environment plus a known
tool-dir prefix on PATH, so a launcher's own
command -vlookups resolve even under launchd's thin PATH.
Once file_convert is trusted in production, the existing PDF helpers collapse
onto it:
pdf-attachskill — thin it to: callfile_convert(format="pdf", ...), then emit the<openfang:attach>tag for the produced path. The skill stops owning the pandoc/typst invocation.assistant-pdf-worker— retired for this md->pdf use-case; no-shell agents get PDFs directly viafile_convertinstead of routing a request to a privileged helper agent.scripts/build-pdf.shstays — it is recipe row one.file_convertwraps it; it is not replaced.
This retirement is doc only for the MVP. Executing it (editing the skill, decommissioning the worker) is a follow-up.
The dependency-surfacing model has three surfaces. v1 ships only the third:
- Provision-time loud line in
deploy-local.sh(file_convert: recipe md->pdf DEGRADED — 'typst' not found) — deferred. - Registry introspection (degraded / unavailable-with-reason; "what can you convert?") — deferred.
- Call-time backstop (
MISSING_DEPat invocation) — shipped in v1.
Surfaces 1 and 2 are ops ergonomics, not seam-critical. Tracked as a follow-up.