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
- Write ffmpeg output under the plugin's storage root — allowed in every phase.
- Or write under the task saveDir, but only inside the beforeFinalize hook (see error 162).
- Avoid absolute paths and os.tmpdir() for ffmpeg output inside hooks.
- 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
- Never pass absolute or os.tmpdir() paths as ffmpeg output inside a hook.
- Derive every ffmpeg output path from ctx.saveDir or ctx.pluginStorageRoot.
- Treat 'outside saveDir and plugin storage' as a hard security boundary, not a soft warning.
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
- PluginRuntimeFault
- PluginRuntimeFault
- plugin.ffmpeg.op_not_found
- unsupported-kind
- manifest too large: ${text.length} > ${max}
AI-assisted analysis of agalwood/Motrix@1a708ee577 (2026-08-12).
Data as JSON: /api/errors/15b3e7fdcce2600c.
Report an issue: GitHub.