agalwood/Motrix · error · PluginCodedError

plugin.ffmpeg.destination_phase_disallowed

plugin.ffmpeg.destination_phase_disallowed

Error message

ffmpeg output ${userOutput} resolves outside saveDir and plugin storage

What it means

During a hook (phase !== 'idle') with the Plan-C context active (both hookSaveDir and hookPluginStorageRoot set), gateFfmpegOutput classifies the user-supplied ffmpeg output path. If it resolves to neither the task's saveDir nor the plugin's storage root (classifyFfmpegOutput returns 'other'), the host rejects it. This is the filesystem boundary that stops a plugin from writing arbitrary paths via ffmpeg.

Source

Thrown at src/core/plugin/host/capability-bridge.ts:1081

   * Pre-Plan-C (orchestrator wires real pluginStorageRoot in PR-2), call sites
   * pass `pluginStorageRoot: ''`. Empty pluginStorageRoot short-circuits the
   * gate so legacy tests keep passing; once PR-2 lands, every Plan-C call site
   * has a real root and the gate becomes effective everywhere.
   */
  private async gateFfmpegOutput(userOutput: string): Promise<string> {
    if (this.currentPhase === 'idle') return userOutput
    if (!this.hookSaveDir || !this.hookPluginStorageRoot) {
      // Plan-B context or pre-PR-2 placeholder — gate inactive.
      return userOutput
    }
    const kind = classifyFfmpegOutput(
      userOutput,
      this.hookSaveDir,
      this.hookPluginStorageRoot
    )
    if (kind === 'pluginStorage') return userOutput
    if (kind === 'other') {
      throw new PluginCodedError(
        'plugin.ffmpeg.destination_phase_disallowed',
        `ffmpeg output ${userOutput} resolves outside saveDir and plugin storage`
      )
    }
    // kind === 'saveDir' — beforeFinalize + staging only.
    if (this.currentPhase !== 'beforeFinalize') {
      throw new PluginCodedError(
        'plugin.ffmpeg.destination_phase_disallowed',
        `ffmpeg cannot write to saveDir in ${this.currentPhase}; move this call into beforeFinalize or write to plugin storage`
      )
    }
    if (!this.hookStaging) {
      throw new PluginCodedError(
        'plugin.ffmpeg.destination_phase_disallowed',
        'ffmpeg saveDir write in beforeFinalize requires a FfmpegStaging in HookContextArgs'
      )
    }
    const staged = this.hookStaging.redirectOutput(userOutput)

View on GitHub (pinned to 1a708ee577)

Solutions

  1. Write ffmpeg output under the plugin's storage root — allowed in every phase.
  2. Or write under the task saveDir, but only inside the beforeFinalize hook (see error 162).
  3. Avoid absolute paths and os.tmpdir() for ffmpeg output inside hooks.
  4. Derive any scratch path from ctx.saveDir or ctx.pluginStorageRoot.

Example fix

// before
const out = path.join(os.tmpdir(), 'out.mp4')
await ffmpeg.transcode({ input, output: out })
// after — plugin storage is allowed in all phases
const out = path.join(ctx.pluginStorageRoot, 'transcoded.mp4')
await ffmpeg.transcode({ input, output: out })
Defensive patterns

Strategy: validation

Validate before calling

import path from 'node:path'
function assertFfmpegOutputAllowed(output: string, saveDir: string, pluginStorageRoot: string): void {
  const under = (base: string) => {
    if (path.isAbsolute(output) !== path.isAbsolute(base)) return false
    const rel = path.relative(base, output)
    return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
  }
  if (!under(saveDir) && !under(pluginStorageRoot)) {
    throw new Error(`ffmpeg output ${output} is outside saveDir and pluginStorageRoot`)
  }
}
// call before ffmpeg.run/transcode/extractAudio/mergeStreams/generateThumbnail:
assertFfmpegOutputAllowed(opts.output, ctx.saveDir, ctx.pluginStorageRoot)

Type guard

function isOutputUnderRoots(output: string, saveDir: string, pluginStorageRoot: string): boolean {
  const under = (base: string) => {
    if (path.isAbsolute(output) !== path.isAbsolute(base)) return false
    const rel = path.relative(base, output)
    return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
  }
  return under(saveDir) || under(pluginStorageRoot)
}

Try / catch

try {
  const { opId } = await ffmpeg.transcode(opts)
} catch (e) {
  if ((e as { code?: string }).code === 'plugin.ffmpeg.destination_phase_disallowed' && /outside/.test(e.message)) {
    opts.output = path.join(ctx.pluginStorageRoot, path.basename(opts.output))
    // retry once with the rewritten output
  } else throw e
}

Prevention

When it happens

Trigger: A plugin calls ffmpeg.run/transcode/extractAudio/mergeStreams/generateThumbnail inside beforeCreate/beforeFinalize/afterComplete/onError with output/outputPath set to an absolute path (/etc/x, /tmp/x, os.tmpdir()) or a relative path that escapes both saveDir and pluginStorageRoot.

Common situations: Plugin hardcodes a temp or absolute output path; reuses a scratch path from a non-task context; uses ../ traversal; writes to a directory discovered outside the hook context.

Related errors


AI-assisted analysis of agalwood/Motrix@1a708ee577 (2026-08-12). Data as JSON: /api/errors/15b3e7fdcce2600c. Report an issue: GitHub.