I found the answer in the sugarcube Story API.
With Sugarcube, you need to use state.active.variables
rather than state.history[0].variables
.
For example (using Twine 1.4 with a Sugarcube 1.0.19 story format), you can add a passage with the script tag and do this
prerender.foo = function(x) {
state.active.variables["bar"]="quux";
}
And in a passage you can do
bar is <<print $bar>>
The prerender function (arbitrarily named "foo") will be run before any passage, and set the "bar" variable to "quux". In the passage you can access this as $bar.
To do the reverse (use a Twine variable in JavaScript code), just do the reverse:
in a passage, do
<<set $bar = "quux">>
and in javascript:
var bar = state.active.variables.bar;
To do the same thing in the built-in story formats (Sugarcane, Jonah, Responsive) you just need to use state.history[0] instead of state.active.