The fieldChanged handler repeated the hook invoke in each branch, and
factorio.shutdown_timeout had no invoke at all, so plugins were never
told about a change to it. Do the field specific side effect, then
invoke the hook once for every field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
handleDebugDumpWsRequest sets clusterio_ignore_dump on the current
socket so the WsServer send wrapper does not echo the dumper's own
frames back into debugEvents. The flag was only set on the connector
"connect" event, but WsServerConnector.continue() installs a fresh
socket and emits "resume", not "connect", so a resumed session lost
the flag.
After a resume the next outgoing frame was echoed to debugEvents,
ws_dumper sent a DebugWsMessageEvent, the wrapper echoed that too,
and so on: infinite synchronous recursion ending in a RangeError:
Invalid string length from JSON.stringify in link.ts sendEvent. That
uncaught error made handleUnhandledErrors exit the controller.
Repro: admin sends DebugDumpWsRequest, the control connection drops
and resumes (e.g. a network blip), controller crashes on resume.
Set the flag on "resume" as well as "connect".
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
_watchServerLogActions dereferenced two regex results with ! and
threw on null. The name regex /^([^ ]+)/ returns null when a line
classified as an action starts with a space, and the BAN reason
regex /\. Reason: (.+)\.$/ returns null for a [BAN] line with no
". Reason: X." tail. The output listener runs synchronously off the
stdout LineSplitter, so the throw becomes an uncaughtException and
takes the whole host down with every instance on it.
Repro: instance with enable_save_patching off and enable_script_commands
on; a control client sends /sc print("2000-01-01 00:00:00 [BAN] evil").
The raw print reaches stdout, parses as a dated BAN action, and the
reason deref throws.
Skip the line when the name regex misses and default the ban reason
to empty when the tail is absent. Apply the same name guard to
_watchPlayerPromote. The autosave ![1] in server.ts is safe because
its filter regex already guarantees the capture.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A permission function runs during ingress permission checking, before
the request body is validated. UserBulkImportRequest.permission read the
raw, unvalidated message.data and threw a bare Error for an unknown
importType. That error is not a PermissionError, so _processMessage
rethrew it to the connector "message" listener, which emitted "error" on
the connector. Controller-side connectors have no "error" listener, so
Node rethrew and handleUnhandledErrors exited the process.
Repro: a control client with client-side validateSent disabled sends
UserBulkImportRequest with importType "foo"; the client gets a normal
RequestError response and the controller then logs a fatal uncaught
exception and exits.
Fix both layers: _processMessage now logs and drops an unexpected error
from the permission check (control connections only, where a response
was already sent) instead of rethrowing, and UserBulkImportRequest
throws a PermissionError for an unknown importType so it is denied
cleanly. A genuine PermissionError still denies silently as before.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Drop the special case that deferred terminating the connection issuing
the delete, so deleting yourself behaves the same as revoke-token and
every session of the deleted user is closed before the handler returns.
Set tokenValidAfter to the current time in createUser instead of 0.
Deleting a user and recreating it under the same name left tokens issued
before the deletion valid, as the check is iat < tokenValidAfter. Doing
it in createUser covers every path that makes a user. A token signed in
the same second as creation is still accepted, so create-admin and the
other production paths sign after the user exists and are unaffected.
The integration harness signed the control token at import time, before
bootstrap create-admin created the user. Sign it in the root before hook
instead and reuse it in link.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ControlConnection keeps the User object it authenticated with and checks
permissions against it. Deleting the user removed the record but left
its sessions open, so a deleted user kept all its permissions until the
WebSocket closed.
Repro: create "victim" with role 0 and connect as it. Another admin
sends UserDeleteRequest("victim"). Victim can still send
UserCreateRequest("mallory") and UserUpdateRolesRequest("mallory", [0]).
Terminate the deleted user's connections like revoke-token does. When
a user deletes itself, defer terminating the requesting connection so
the response is sent first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config changed handler ran updateFactorioSettings and
updateFactorioWhitelist as soon as the field changed. While the instance
is starting, stopping, creating_save or exporting_data the server is not
ready for RCON, so the update threw and was only logged. The three list
update events pushed from the controller had the same problem, except
Link._processEvent swallowed the rejection and the update was lost
without a trace.
Add _waitForSettledStatus, which resolves once notifyStatus lands on
running or stopped. notifyStatus is the only place _status changes and
notifyExit always ends on stopped, so a waiter cannot hang. The settings,
whitelist and list updates now go through it.
Nothing is applied when the instance settles on stopped, because there is
no server to apply it to. factorio.settings is written to
server-settings.json and the lists to the list files on the next start,
and enableWhitelist is read from the config when the next Instance is
constructed.
The plugin hook now runs immediately for the two config fields instead of
being chained onto the update. Every other field already invokes it right
away, and waiting for the instance to settle would hold the notification
back for the whole startup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The close handler removed the log subscription transport from the global
lib logger, but updateLogSubscriptions adds it to the controller's
clusterLogger. Every control connection that subscribed to logs stayed
attached to clusterLogger after closing, keeping the connection alive and
running every later cluster log line through its filter.
handleDebugDumpWsRequest added a new debugEvents listener on each call
and overwrote ws_dumper, so the close handler only removed the last one.
Repeated requests stacked listeners, triggered MaxListenersExceededWarning
after 11 calls, and left the earlier listeners behind. Ignore the request
if the connection is already dumping.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
assignInstance sets instance.assigned_host to the new host before
sending InstanceAssignInternalRequest. If the host fails that request
the controller kept the new host id while the host never added the
instance. Assigning to the same host again then returned early as a
no-op. Repro: make the host's instances directory read-only, assign
instance 3001 to host 10 (EACCES on mkdir), make it writable, assign
again (ok, nothing happens), start (instance does not exist on host).
Now a failed send leaves the instance unassigned and rethrows.
InstanceCreateRequest applied instance.assigned_host from the request
config, creating an instance assigned to a host that never heard of
it. Repro: InstanceCreateRequest({"instance.id": 4001, "instance.name":
"c1", "instance.assigned_host": 10}). Reject a non-null value with the
same error the config set requests use.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fieldChanged handler in the Instance constructor ran
updateFactorioSettings and updateFactorioWhitelist with only a finally
attached. A rejection from either went unhandled, and the host exits on
unhandled rejections, taking every instance on it down.
Two ways to hit it against a real host:
- Start an instance and immediately toggle factorio.enable_whitelist.
updateFactorioWhitelist calls sendRcon while the server state is still
"new" and it throws "Expected state running,stopping but state is new".
- Set factorio.settings tags to [1] on a running instance. applyAsConfig
called tag.replace outside its try block and threw
"tag.replace is not a function".
Catch and log both on the instance logger before invoking the hook, and
convert tags with String() so non-string entries no longer throw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two writes to the same file at the same time shared one temporary name, so the second write clobbered the first's temporary and the first rename left the second call with nothing to rename, failing with ENOENT. The host does exactly that when it saves an instance config while the instance saves it too.
The temporary now carries a random tag and is created exclusively, so concurrent writers never touch each other's file. Since the temporary is always new, the mode is applied on creation and the chmod for reused leftovers goes away. A failed rename removes its temporary instead of leaving it for the next write to overwrite.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The metrics endpoint fans out a gather to every connected host, so
anyone who could reach the controller could read cluster statistics and
put load on all hosts at will. Scraping now needs a user token holding
the new core.controller.metrics permission. The token is accepted via
Authorization: Bearer as well as X-Access-Token because Prometheus can
only send the former. Concurrent scrapes share one gather so hosts see
at most one metrics request at a time.
The three connection gauges in controller.ts now no-op before the
controller exists, like the other gauges there, so the endpoint can be
tested against the mock controller.
Closes#1009
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rethrow errors other than ENOENT and ENOTDIR when reading the package
spec as a directory, mark the tests that run npm as slow so FAST_TEST
skips them, and use ./external_plugins/my_plugin in the README examples.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Plugins with only host, instance or ctl code have no reason to ship a
webpack build, yet the plugin list showed "Missing dist/web/manifest.json"
as an error for them and the controller logged a warning on every start.
Decide whether a build is expected from the plugin declaration, using the
same rule the create tool uses to decide when to generate one: a web or
controller entrypoint, or any config fields. Plugins outside that rule get
no bundle path and no error, the web UI skips loading them, and the plugin
pages show them as having no web module instead of an error or a prompt to
reload.
Fixes#1004
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The mod pack's Factorio version ends up in mod-settings.dat. Factorio
refuses to read that file when the version is newer than the game and
falls back to default settings, which can wreck the save. Check the
versions in syncMods before touching the mods folder.
Closes#1006
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Config files hold the auth secret and tokens, so the default 0o644 lets
every user on the system read them. Config.save, the ctl config written
by the controller bootstrap, ctl host create-config and the config
migration in create now pass mode 0o600.
safeOutputFile also applies the mode to the temporary file it writes,
since writeFile only honours mode on creation and a leftover temporary
from a failed write would otherwise keep its old mode.
Closes#1005
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Replace invokeHook and the per plugin on* methods with AsyncHook
collections on the controller, host, instance, clusterioctl and web
control objects. Plugin entrypoints export a default function that
receives a load context and attaches handlers to the hooks it needs.
Class exports still load through fromContext with a deprecation
warning. The create templates and docs use the entrypoint function.
This is clusterio/clusterio#921 recreated on top of master after the
ESM migration and the PluginDeclaration permissions change.
Closes#730
Co-Authored-By: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds a permissions array to PluginDeclaration, a type-level Permissions
registry that plugins extend with module augmentation, and
registerPluginPermissions() called from all entrypoints. checkPermission
and the web UI hasPermission helpers now take PermissionName so typos are
compile errors. Core definitions move into a corePermissions array that
the interface derives its keys from.
Closes#987
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Migrate the usage of CommonJS specific code and imports to ESM. Roughly
summarised this:
- Changes all require() calls to import statement or expressions.
- Changes usage of module.exports to export statements.
- Replaces require.resolve with import.meta.resolve.
- Replaces conditional imports with conditional subpath imports.
- Replaces module === require.main with import.meta.main.
- Adds the full filename with extention to all relative imports.
- Changes all usage of __dirname and __filename to their import.meta
equivalents.
- Sets "type": "module" in package.json files.
- Removes usage of "use strict".
- Adds .ts an an alias of .js for the webpack build.
Due to differences in how require and import resolves files the plugin
entrypoints will have to include the filename and extension after this
migration in order to work.
resolveHost, resolveInstance, resolveModPack and retrieveRole treated any
integer like argument as an id and never looked it up as a name. An
instance created as "10" could not be referenced by name afterwards. The
lookup now treats the argument as an id only if an entry with that id
exists, and otherwise falls back to a name match.
Closes#886
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Use a fictional restart_test mock plugin added as a before step instead
of global_chat for the .checkRestartRequired() test. Prevents the test
from failing if the integration tests are not also run.
Also fixes the fixture not accounting for setting load_plugin causing
restartRequired to be set to true due to the integration tests having
already set it to the same value.
Installing a plugin used to take two steps, npm install followed by plugin
add, and both silently did their work in whatever directory they were run
from. The new plugin install <package> subcommand runs npm install --save
and then discovers the plugin through the existing loadPluginList logic,
so the plugin list is updated in the same step.
The command refuses to run outside a Clusterio installation, detected by
the package.json the installer writes or one that depends on a Clusterio
package. plugin add with a bare package name warns in the same situation.
Reinstalling a plugin that is already listed reports it as such instead of
failing. Installing a package without the clusterio-plugin keyword fails.
Fixes#1008
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Updating a single plugin can leave the installation with multiple
versions of @clusterio/lib. Passing the core package and every npm
installed plugin to a single npm update call lets npm resolve them
together.
UpdateAllRequest targets the controller or a host, requires both the
core update permission and core.plugin.update, and is gated by both
allow_remote_updates and allow_plugin_updates. The web UI Updates modal
gets an Update All action and ctl gets --all on the update commands.
Closes#910
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Generic error handlers such as FactorioServer.handle() and the event
handler catch in Link only log err.stack, so the ajv errors attached to
an InvalidMessage were lost unless the catch site checked for the class
explicitly. Appending the errors to the stack in the constructor makes
every stack-logging handler show which field failed validation without
special casing each one.
Fixes#986
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The create instance modal can now copy the active save of the instance
whose config is copied, optionally saving the running game first, and
start the new instance once the copy is done. The new instance is
assigned to the host of the source so the save can be transferred.
Saving a running game is a new InstanceSaveGameRequest handled by the
host through /server-save, which waits for the save to finish. It is
also exposed as clusterioctl instance save-game.
Closes#405
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add option in the controller config to set where static assets are
loaded from in order to support deployments where the static assets are
server via a CDN while the API is directly accessed.
This is useful because some CDNs put low limits on how large requests
bodies can be, which causes issues with uploading large mods and saves.
The codecov/patch check flagged the new routes code as uncovered. The
unit tests never hit /api/plugins, so cover the static/ stripping and the
manifest error paths directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Support enemy_expansion.build_base_unit_dispatch_cooldown in exchange strings
Factorio 2.1.13 added build_base_unit_dispatch_cooldown as the last
field of enemy_expansion in the map exchange string, and rejects map
settings without it. Track the full version in the reader state and
read the field for 2.1.13 and later. Adds a fixture string generated
with 2.1.14 and uses it in the load-scenario integration test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Replace version flags with versionAtLeast in exchange string reader
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Filter dependency resolution by the mod pack's Factorio version
The dependency resolver picked the newest release satisfying the version
range regardless of which Factorio version it targeted, so a 2.0 mod pack
could end up with 2.1 dependencies. Only consider portal releases and
local mods whose factorio_version matches the mod pack.
Fixes#965
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Move dependency resolver tests to test/messages/mod.test.js
Also update the existing local mod fixtures to carry a factorio_version
and drop enable-all-feature-flags from the live expectations: the 2.0
releases of pyalternativeenergy only list it as optional, it was only
pulled in before because 2.1 releases were being selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add NO_EXTERNAL_TEST flag to skip tests using external APIs
Adds an externalTest(this) helper alongside slowTest that skips the test
when NO_EXTERNAL_TEST is set and raises the timeout to 60s, plus a
no-external-test script. Marks the tests hitting factorio.com, the mod
portal and GitHub with it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Disable user verification in start-all and auto_start tests
Factorio contacts auth.factorio.com on startup when
require_user_verification is enabled, which made these tests fail
offline. Match the other integration tests by turning it off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Ignore Factorio download directory created by host tests
The live-API download test in test/host/server.js writes a full headless
Factorio install (~140 MB) to test/file/factorioDownload and does not
remove it, which then shows up as untracked files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Download Factorio in host tests to temp/test instead of test/file
The live-API download test wrote a full headless install (~140 MB) into
test/file/factorioDownload, which is tracked test fixture territory and
showed up as untracked files. temp/ is already gitignored and is where
the other tests put generated data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Factorio 2.1.13 added build_base_unit_dispatch_cooldown as the last
field of enemy_expansion in the map exchange string, and rejects map
settings without it. Track the full version in the reader state and
read the field for 2.1.13 and later. Adds a fixture string generated
with 2.1.14 and uses it in the load-scenario integration test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Factorio contacts auth.factorio.com on startup when
require_user_verification is enabled, which made these tests fail
offline. Match the other integration tests by turning it off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an externalTest(this) helper alongside slowTest that skips the test
when NO_EXTERNAL_TEST is set and raises the timeout to 60s, plus a
no-external-test script. Marks the tests hitting factorio.com, the mod
portal and GitHub with it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>