Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/portable.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Portable startup contracts
on: [push, pull_request]
jobs:
offline:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
php: ['8.3', '8.4', '8.5']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, openssl, curl
coverage: none
- run: composer install --no-interaction --no-plugins --no-scripts --no-dev
- run: php tests/windows/constructor.php
- run: php tests/windows/constructor.php vendor/autoload.php --negative-control
- run: php tests/windows/constructor.php vendor/autoload.php --cache-negative-control
- run: php tests/windows/portable.php
- run: php tests/windows/process.php
- run: php tests/windows/shell-guard.php
- run: php tests/windows/ready.php
- run: php tests/windows/ready.php vendor/autoload.php --unconfigured
- run: php -l bot.php
- run: php -l src/Civ13/Civ13.php
- run: php -l src/Civ13/GameServer.php
73 changes: 61 additions & 12 deletions bot.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,7 @@
return \getcwd() ?: __DIR__;
})();

if (! $autoloader = require file_exists(__DIR__.'/vendor/autoload.php') ? __DIR__.'/vendor/autoload.php' : $baseDir.'/vendor/autoload.php') {
throw new \Exception('Composer autoloader not found. Run `composer install`, or keep the binary inside the project directory.');
}
function loadEnv(string $filePath): void
function loadEnv(string $filePath, bool $explicit = false): array
{
if (! file_exists($filePath)) {
throw new Exception('The .env file does not exist.');
Expand All @@ -88,17 +85,42 @@ function loadEnv(string $filePath): void
$trimmedLines = array_map('trim', $lines);
$filteredLines = array_filter($trimmedLines, fn ($line) => $line && ! str_starts_with($line, '#'));

array_walk($filteredLines, function ($line) {
$names = [];
array_walk($filteredLines, function ($line) use ($explicit, &$names) {
[$name, $value] = array_map('trim', explode('=', $line, 2));
if (! array_key_exists($name, $_ENV)) {
$names[] = $name;
if ($explicit || ! array_key_exists($name, $_ENV)) {
putenv(sprintf('%s=%s', $name, $value));
}
});
return $names;
}
require_once __DIR__.'/src/Civ13/PortableProfile.php';
require_once __DIR__.'/src/Civ13/PortableProcess.php';
$portable = null;
$portableLock = null;
$portablePath = getenv('CIVILIZATIONBOT_PROFILE');
if (PHP_OS_FAMILY === 'Windows' && !$portablePath) {
throw new \RuntimeException('Windows requires an explicit portable profile; default production configuration is not permitted');
}
if ($portablePath) {
$portable = PortableProfile::load($portablePath);
PortableProfile::assertRoot($portable, __DIR__);
$baseDir = __DIR__;
$errors = PortableProfile::preflight($portable, extension_loaded(...));
if ($errors !== []) { throw new \RuntimeException(implode('; ', $errors)); }
$portableLock = \Civ13\PortableProcess::lock($portable['state_dir']);
if (is_file($portable['state_dir'].'/stop.request')) { throw new \RuntimeException('Unresolved stop request; inspect previous instance before starting'); }
chdir($portable['state_dir']);
}
if (! $autoloader = require file_exists(__DIR__.'/vendor/autoload.php') ? __DIR__.'/vendor/autoload.php' : $baseDir.'/vendor/autoload.php') {
throw new \Exception('Composer autoloader not found. Run `composer install`, or keep the binary inside the project directory.');
}
loadEnv($baseDir.'/.env');
$loadedNames = loadEnv($portable['env_file'] ?? $baseDir.'/.env', $portable !== null);
if ($portable !== null && (!in_array('TOKEN', $loadedNames, true) || !getenv('TOKEN'))) { throw new \RuntimeException('Separate profile environment must explicitly supply TOKEN'); }

file_put_contents($baseDir.'/output.log', ''); // Clear the contents of 'output.log'
$fileHandler = (new StreamHandler($baseDir.'/output.log', Level::Debug))->setFormatter(new LineFormatter(null, null, true, true, true));
if ($portable === null) { file_put_contents($baseDir.'/output.log', ''); } // Clear the contents of 'output.log'
$fileHandler = (new StreamHandler(($portable['state_dir'] ?? $baseDir).'/output.log', Level::Debug))->setFormatter(new LineFormatter(null, null, true, true, true));
$stdoutHandler = (new StreamHandler('php://stdout', Level::Debug))->setFormatter(new LineFormatter(null, null, true, true, true));
$logger = new Logger('Civ13', [$fileHandler, $stdoutHandler]);
Loop::addPeriodicTimer(60 * 10, fn () => $logger->reset()); // Flush all buffers every 10 minutes
Expand Down Expand Up @@ -138,7 +160,7 @@ function loadEnv(string $filePath): void
$filesystem = FilesystemFactory::create();
include $baseDir.'/variable_functions.php';

$http_whitelist = [
$http_whitelist = $portable !== null ? ['127.0.0.1'] : [
$civ13_ip = gethostbyname('www.civ13.com'),
$vzg_ip = gethostbyname('www.valzargaming.com'),
$val_ip = gethostbyname('www.valgorithms.com'),
Expand Down Expand Up @@ -435,13 +457,37 @@ function loadEnv(string $filePath): void
],
],
];
if ($portable !== null) {
// Replace the entire ambient destination/control configuration, never recursively merge it.
$options = PortableProfile::runtimeOptions($portable);
$hidden_options['http_key'] = PortableProfile::environmentValue('WEBAPI_TOKEN', $loadedNames);
$hidden_options['civ_token'] = PortableProfile::environmentValue('CIV_TOKEN', $loadedNames);
$civ13_server_settings = $portable['servers'];
$civ14_server_settings = [];
$hidden_options['civ13_server_settings'] = $civ13_server_settings;
$hidden_options['http_whitelist'] = ['127.0.0.1'];
$hidden_options['web_address'] = '127.0.0.1';
$hidden_options['http_port'] = $portable['http_port'];
$hidden_options['functions'] = []; // No ambient production callbacks.
$options['verify_url'] = ''; // No authority writes or fallback to the official verifier.
$options['webserver_url'] = '127.0.0.1';
}
$options = array_merge($options, $hidden_options);
if ($portable !== null) {
Loop::addPeriodicTimer(1, static function () use (&$civ13, $portable): void {
if (is_file($portable['state_dir'].'/stop.request')) {
unlink($portable['state_dir'].'/stop.request');
if ($civ13 !== null) { $civ13->stop(); } else { Loop::stop(); }
}
});
}

$civ13 = null;
$global_error_handler = async(function (int $errno, string $errstr, ?string $errfile, ?int $errline) use (&$civ13, &$logger, &$testing) {
/** @var ?Civ13 $civ13 */
if (
$civ13 && // If the bot is running
isset($civ13->channel_ids['staff_bot']) &&
($channel = $civ13->discord->getChannel($civ13->channel_ids['staff_bot']))
// fsockopen
&& ! str_ends_with($errstr, 'Connection timed out')
Expand Down Expand Up @@ -484,7 +530,7 @@ function loadEnv(string $filePath): void
use Psr\Http\Message\ServerRequestInterface;

$socket = new SocketServer(
sprintf('%s:%s', '0.0.0.0', getenv('http_port') ?: 55555),
sprintf('%s:%s', $portable !== null ? '127.0.0.1' : '0.0.0.0', $portable['http_port'] ?? (getenv('http_port') ?: 55555)),
[
'tcp' => [
'so_reuseport' => true,
Expand Down Expand Up @@ -565,7 +611,9 @@ function loadEnv(string $filePath): void

use VerifierServer\Server as VerifierServer;

Loop::futureTick(async(static function () use (&$civ13, &$logger, $options, $civ13_server_settings, $civ14_server_settings) {
Loop::futureTick(async(static function () use (&$civ13, &$logger, $options, $civ13_server_settings, $civ14_server_settings, $portable) {
$verifier_server = null;
if ($portable === null) {
$verifier_server = new VerifierServer(
getenv('VERIFIER_HOST_ADDR'),
intval(getenv('VERIFIER_HOST_PORT'))
Expand All @@ -591,6 +639,7 @@ function loadEnv(string $filePath): void
getenv('dwa_client_secret')
);

} // Portable instances do not construct the official verifier service.
$civ13 = new Civ13(
$options,
$civ13_server_settings,
Expand Down
33 changes: 33 additions & 0 deletions config/examples/windows-test1714.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"schema": "civilizationbot.portable.v1",
"root": "D:/GitHub/Civilizationbot",
"env_file": "D:/Civilizationbot-local/separate.env",
"state_dir": "D:/Civilizationbot-local/state",
"gitdir": "D:/GitHub/Civ13",
"http_port": 25565,
"options": {
"civ13_guild_id": "",
"owner_id": "",
"technician_id": "",
"channel_ids": {},
"role_ids": {},
"folders": {"json": "json/"},
"files": {}
},
"servers": {
"test1714": {
"basedir": "D:/Civ13-local/TEST1714",
"name": "TEST1714",
"host": "local operator",
"ip": "127.0.0.1",
"port": 1714,
"enabled": false,
"supported": false,
"moderate": false,
"legacy_relay": false,
"discussion": "", "playercount": "", "ooc": "", "lobby": "",
"asay": "", "ic": "", "transit": "", "adminlog": "",
"debug": "", "garbage": "", "runtime": "", "attack": ""
}
}
}
114 changes: 114 additions & 0 deletions docs/windows-test1714.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Windows TEST1714 startup (stable branch)

This profile is an explicit opt-in to the stable bootstrap. It does not import V5,
install a service, run a game server, or share the Debian bot environment file.
Windows refuses the ambient production bootstrap without a profile. Linux without
CIVILIZATIONBOT_PROFILE retains its existing behavior.

Install PHP 8.3+ CLI and Composer dependencies in this checkout with Composer's
platform checks enabled. Required extensions: json, mbstring, openssl, filter,
zlib, curl. Use a 64-bit PHP build. Optional event-loop accelerators are not
required. The portable preflight checks the PHP 8.3 floor before loading dependencies.
Do not copy an official token, environment file, verification store or runtime data.
A separate application/token must be provisioned directly by its operator.

Copy config/examples/windows-test1714.json to an operator-owned absolute path and
edit that copy. Set your own civ13_guild_id, owner_id and technician_id explicitly;
empty identity strings disable those identity matches and never inherit production
owner/technician IDs. owner_id must match the target guild owner's Discord ID for
message commands. The bot must already belong to the configured guild, with the
required gateway intents enabled for its separate application. Missing optional
verifier-status channels are ignored; a missing guild skips guild command
registration with a warning. Create the configured state directory and game directory first.
root must match this checkout; basedir must be the intended PC TEST1714 runtime
root (SQL files are relative to it). The game remains independently operated.
The sample server is disabled and destination maps empty intentionally. Before
activation, supply reviewed channel/role IDs and confirm both bot identities and
Administrator bypass: channel denies cannot isolate an Administrator. No broad
guild permission changes are implied. Keep the local bot unable to view official
channels and the official bot unable to view local channels.

From PowerShell, with explicit executable and profile paths:

```powershell
C:/php/php.exe scripts/windows/control.php check D:/Civilizationbot-local/profile.json
# Only after the separate activation gate:
C:/php/php.exe scripts/windows/control.php start D:/Civilizationbot-local/profile.json
# From another terminal, requests graceful shutdown of this profile only:
C:/php/php.exe scripts/windows/control.php stop D:/Civilizationbot-local/profile.json
```

check reads profile metadata and file presence only, never the environment contents
or Discord. start runs one shell-free foreground PHP child; the bot holds an
exclusive state lock. A duplicate launch cannot enter the bot bootstrap after
that lock. stop writes a one-use marker consumed by the owning loop, not a process
name kill. A stop marker left by an absent/crashed process requires operator
inspection before restart. A stop request is not evidence that a process exited.
Only WEBAPI_TOKEN and CIV_TOKEN explicitly present in the separate environment file
are used; inherited values are ignored. Cache files are pinned under state/json.
The check/start/stop tools reject a profile belonging to a different checkout.

Logs append to state/output.log, stdout.log and stderr.log. Protect this directory
with operator-only filesystem permissions and apply an external retention policy.
Restart commands exit gracefully in portable mode; the operator relaunches the
foreground supervisor. No self-spawning sudo/nohup path is used.

Portable construction skips public-IP discovery, so startup does not depend on
ipecho.net or grant that discovered address local trust. Linux defaults are unchanged.
The HTTP listener binds 127.0.0.1 on the explicit profile port (distinct from1714).
Portable mode removes ambient callback functions, official verifier server
construction and the official verification URL. Verification integration remains
unavailable until a separate reviewed authoritative read-only contract is supplied.
Native Host/Kill/Restart/MapSwap fail closed in portable/Windows mode before game
messages or timers. This change supplies no game lifecycle replacement.

Legacy shell maintenance is also unavailable in Windows/portable mode: chat
updatedeps/pullrepo/ts/ss14 and slash pull/update report unavailable rather than
claiming success; the staff panel omits its Git/Composer buttons. Legacy HTTP
/reset, /githubupdated, /pull, /update, /restart, /kill, /clearenv and
/cancelupdaterestart return 503 before any maintenance side effects. Use the
explicit foreground supervisor and stop marker for local bot lifecycle. The shared OS
helpers reject shell spawn, self-restart, process disown and port-based killing
before touching processes. This does not restrict the explicit owned argv-based
PHP supervisor or the separately configured local OpenDream adapter. Do code and
dependency maintenance manually while the bot is stopped.

## Offline verification and activation boundary

`php tests/windows/portable.php` uses temporary storage, synthetic environment
contents and an injected FakeProcess. It checks destination/path/port rejection,
dependency reporting, exclusive locking, stop replay, shell-free argv and actual
GameServer entry-point rejection before uninitialized game/Discord dependencies.
It never loads bot.php, connects Discord or launches a child process.

`php tests/windows/constructor.php [ABSOLUTE_VENDOR_AUTOLOAD]` additionally runs
actual Civ13 construction through afterConstruct with an injected Discord event
emitter, non-running loop, temporary state and disabled DNS connector. It verifies
slash listeners omit unavailable SS14 verification and the registered read route
returns 503, never a successful empty list. It does not invoke the credential-
loading test bootstrap or start an event loop. CI installs dependencies with
Composer plugins/scripts disabled before running this contract.

`php tests/windows/process.php` launches a real synthetic PHP child, checks shell
metacharacters survive as literal arguments, paths containing spaces, separate
stdout/stderr logs, cross-process lock exclusion, graceful stop marker consumption
and exit-code propagation. It cannot launch the bot or a game. CI runs these
checks on Windows and Linux with PHP 8.3, 8.4 and 8.5.

`php tests/windows/shell-guard.php` installs process-function tripwires and uses
synthetic messages to prove unsupported maintenance rejects before any shell,
process inspection, timer-based restart or misleading success reaction.

`php tests/windows/ready.php` emits init and fires the delayed bootstrap with a
controlled loop and synthetic Discord command repositories. It exercises real
Slash/Panels setup without connecting to Discord, and rejects network attempts.
The --unconfigured variant covers empty identities/channels and an absent guild;
both variants verify unsupported slash maintenance responds without adding timers.

These tests do not establish live startup, gateway intents, command registration,
permissions, TEST1714 file IPC, process binding, or delivery. Before an authorized
activation: review the exact candidate/CI, provision separate credentials and
private destinations directly, verify runtime root/ports and channel isolation,
then obtain the operational coordinator's explicit launch/registration/test gate.
Debian deployment, game save/load/restart, and official token reuse remain outside
this change.
23 changes: 23 additions & 0 deletions scripts/windows/control.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
require __DIR__.'/../../src/Civ13/PortableProfile.php';
require __DIR__.'/../../src/Civ13/PortableProcess.php';
use Civ13\PortableProfile;
use Civ13\PortableProcess;
try {
if (count($argv)!==3 || !in_array($argv[1],['check','start','stop'],true)) { throw new RuntimeException('Usage: php control.php check|start|stop ABSOLUTE_PROFILE'); }
$profile=PortableProfile::load($argv[2]);
PortableProfile::assertRoot($profile, dirname(__DIR__, 2));
if ($argv[1]==='stop') { PortableProcess::requestStop($profile['state_dir']); exit(0); }
$errors=PortableProfile::preflight($profile,extension_loaded(...));
if ($errors!==[]) { throw new RuntimeException(implode('; ',$errors)); }
if ($argv[1]==='check') { echo "Preflight passed; not started\n"; exit(0); }
if (is_file($profile['state_dir'].'/stop.request')) { throw new RuntimeException('Unresolved stop request; inspect previous instance before starting'); }
putenv('CIVILIZATIONBOT_PROFILE='.$argv[2]);
$pipes=[];
$process=PortableProcess::start(PortableProcess::argv(PHP_BINARY,$profile['root']),$profile['state_dir'],static function($args,$descriptors,$cwd,$options) use (&$pipes) { return proc_open($args,$descriptors,$pipes,$cwd,null,$options); });
if (!is_resource($process)) { throw new RuntimeException('Child process creation failed'); }
fclose($pipes[0]);
// Foreground supervision avoids unowned background PHP copies; Ctrl+C is not a game stop.
exit(proc_close($process));
} catch (Throwable $error) { fwrite(STDERR,'Portable control failed: '.$error->getMessage().PHP_EOL); exit(1); }
16 changes: 16 additions & 0 deletions scripts/windows/preflight.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
require __DIR__.'/../../src/Civ13/PortableProfile.php';
use Civ13\PortableProfile;
// Deliberately read-only. Never includes bot.php or the configured environment file.
try {
if (count($argv) !== 2) { throw new RuntimeException('Usage: php scripts/windows/preflight.php ABSOLUTE_PROFILE.json'); }
$profile = PortableProfile::load($argv[1]);
PortableProfile::assertRoot($profile, dirname(__DIR__, 2));
$errors = PortableProfile::preflight($profile, extension_loaded(...));
echo json_encode(['schema'=>'civilizationbot.preflight.v1','ok'=>$errors===[],'errors'=>$errors], JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT).PHP_EOL;
exit($errors === [] ? 0 : 1);
} catch (Throwable $error) {
fwrite(STDERR, 'Portable configuration failed validation'.PHP_EOL);
exit(1);
}
Loading
Loading