---
url: 'https://adk.nht.io/batteries/sandbox/media-executor.md'
description: >-
  Adapt the media BinaryExecutor contract to a command sandbox without
  pretending settled output is streaming.
---

# Media executor

## LLM summary — media executor

* [`sandboxedExecutor`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/functions/sandboxedExecutor) wraps the existing media BinaryExecutor; it does not replace it or stream.
* The media `execaExecutor` supplies settled stdout/stderr and a status record; its bounded retention remains.
* `bypass` receives only `cmd`, never argv, and is audit-only. An interpreter must never be bypassed.
* Use [`createRunShellCommandTool`](https://adk.nht.io/api/@nhtio/adk/batteries/sandbox/functions/createRunShellCommandTool) for complete streaming command output.

The media battery already spawns binaries — `ffmpeg`, `ffprobe`, `soffice`, whatever your engines need.
Those spawns went through `BinaryExecutor` long before this battery existed, and they were unsandboxed
because there was nothing to sandbox them with.

`sandboxedExecutor` closes that without touching the media battery: it quotes `cmd + args`, asks a
`BinarySandbox` to wrap the invocation, and delegates to `inner.exec`. Reverting is deleting one
wrapper call — `inner` stays independently usable. **A swap, never a one-way door.**

::: warning This adapter does not stream, and the neighbouring shell tool does
`BinaryExecResult` returns settled `stdout`/`stderr` **strings** — a shipped contract the media engines
already depend on, so this battery does not widen it. This path therefore inherits execa's
accumulate-and-`maxBuffer` behaviour, and deliberately does **not** raise it: `maxBuffer: Infinity`
trades a bounded failure for an unbounded one.

The streaming path is [`run_shell_command`](./run-command.md), which goes through the sandbox's own
enforcer contract instead. Two paths, two memory profiles — do not read one section's guarantee onto the
other.
:::

## Worked adapter

```ts
import { sandboxedExecutor } from '@nhtio/adk/batteries/sandbox'
import { execaExecutor } from '@nhtio/adk/batteries/media/engines/execa_executor'

const inner = execaExecutor({ defaultTimeoutMs: 120_000 })
const executor = sandboxedExecutor({
  inner,
  sandbox: {
    async wrap(invocation) {
      // Return the invocation the inner executor should receive; do not execute here.
      return { ...invocation, command: await srtWrap(invocation.command) }
    },
  },
  bypass: (cmd) => cmd === 'ffprobe',
  onSandbox: (event) => audit.log(event),
})
const result = await executor.exec({
  cmd: 'ffprobe',
  args: ['-v', 'error', 'input.mp4'],
  timeoutMs: 10_000,
})
console.log(result.exitCode, result.stdout, result.stderr, result.failed)
```

**`bypass` takes the command only, never the argv, and that signature is the whole design.** The model
cannot call `bypass` — but it *can* influence arguments. So bypassing a binary means trusting it **with
hostile argv**, and the list may hold only binaries that survive that.

::: danger Never bypass an interpreter
`sh`, `bash`, `node`, `python` — anything that turns argv into code. Bypassing one of those does not
exempt a command from the sandbox; it hands the model an unsandboxed shell wearing a different name.

And `bypass: () => true` skips wrapping for everything:

**You have just handed the model an unsandboxed shell. That was a choice. Own it.**
:::

Every bypass emits a loud event on `onSandbox`. Keep that sink wired and read it: **bypass is audit,
not enforcement.** It records that you skipped the boundary; it does not make skipping it safe.

The inner `execaExecutor` accepts an optional `execa` resolver and otherwise lazily imports execa.
It calls execa with `reject: false`, `stripFinalNewline: true`, a timeout, and the invocation signal;
non-zero status returns in `BinaryExecResult`. This adapter intentionally never sets
`maxBuffer: Infinity`.

## When to use which seam

Use this adapter when an existing media engine already consumes `BinaryExecutor` and settled text is
sufficient. Use `createRunShellCommandTool` when a model needs one complete spooled artifact with
stdout/stderr merged in arrival order and denials woven into the stream. Do not claim the executor
adapter is a streaming replacement.

The executor and scratch workspace must share path visibility. A remote executor paired with local
workspace paths fails by composition, even if both individual components are correct.

## Troubleshooting: what this failure means

| Symptom | Cause |
| --- | --- |
| Output is truncated or execa rejects on volume | This is settled execa output with bounded retention. Use the streaming shell tool; do not silently raise the cap here. |
| Bypass audit event appears | The command matched `bypass`; the wrapper was intentionally skipped. Review the allowlist. |
| `"[object Object]"` in model output | A handler returned an object outside the adapter's supported result shapes; return strings, bytes, Media, or the appropriate artifact path. |
| `dubious ownership` | The shell-side SRT policy did not pass `gitSafeDirectories` through to the child. |
| A turn hangs | An upstream tool gate is waiting for a decider. Every sandbox tool, including reads, must have one. |
