From a4936fb693cd3fe8b02385c0b9175eeeb984d297 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 16 Oct 2026 09:48:37 -0400 Subject: [PATCH 1/9] Add isolated Windows TEST1714 startup profile and offline process checks --- .github/workflows/portable.yml | 20 ++++++++ bot.php | 69 ++++++++++++++++++++++----- config/examples/windows-test1714.json | 31 ++++++++++++ docs/windows-test1714.md | 68 ++++++++++++++++++++++++++ scripts/windows/control.php | 22 +++++++++ scripts/windows/preflight.php | 15 ++++++ src/Civ13/Civ13.php | 1 + src/Civ13/GameServer.php | 4 ++ src/Civ13/PortableProcess.php | 37 ++++++++++++++ src/Civ13/PortableProfile.php | 66 +++++++++++++++++++++++++ tests/windows/portable.php | 35 ++++++++++++++ 11 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/portable.yml create mode 100644 config/examples/windows-test1714.json create mode 100644 docs/windows-test1714.md create mode 100644 scripts/windows/control.php create mode 100644 scripts/windows/preflight.php create mode 100644 src/Civ13/PortableProcess.php create mode 100644 src/Civ13/PortableProfile.php create mode 100644 tests/windows/portable.php diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml new file mode 100644 index 00000000..f5561bbf --- /dev/null +++ b/.github/workflows/portable.yml @@ -0,0 +1,20 @@ +name: Portable startup contracts +on: [push, pull_request] +jobs: + offline: + strategy: + matrix: + os: [windows-latest, ubuntu-latest] + php: ['8.3', '8.4'] + 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: php tests/windows/portable.php + - run: php -l bot.php + - run: php -l src/Civ13/Civ13.php + - run: php -l src/Civ13/GameServer.php diff --git a/bot.php b/bot.php index 80a87c88..9bcfcbd8 100644 --- a/bot.php +++ b/bot.php @@ -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.'); @@ -88,17 +85,40 @@ 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__); + $errors = PortableProfile::preflight($portable, extension_loaded(...)); + if ($errors !== []) { throw new \RuntimeException(implode('; ', $errors)); } + $portableLock = \Civ13\PortableProcess::lock($portable['state_dir']); + 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 @@ -138,7 +158,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'), @@ -435,7 +455,29 @@ function loadEnv(string $filePath): void ], ], ]; +if ($portable !== null) { + // Replace the entire ambient destination/control configuration, never recursively merge it. + $options = $portable['options']; + $options['gitdir'] = $portable['gitdir']; + $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) { @@ -484,7 +526,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, @@ -565,7 +607,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')) @@ -591,6 +635,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, diff --git a/config/examples/windows-test1714.json b/config/examples/windows-test1714.json new file mode 100644 index 00000000..85a2d403 --- /dev/null +++ b/config/examples/windows-test1714.json @@ -0,0 +1,31 @@ +{ + "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": "468979034571931648", + "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": "" + } + } +} diff --git a/docs/windows-test1714.md b/docs/windows-test1714.md new file mode 100644 index 00000000..aed09486 --- /dev/null +++ b/docs/windows-test1714.md @@ -0,0 +1,68 @@ +# 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. Optional event-loop accelerators are not required. The existing +composer.json PHP floor is lower than the typed class constants used by the code; +the portable preflight reports the actual 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. 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. +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. + +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. + +## 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. + +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. diff --git a/scripts/windows/control.php b/scripts/windows/control.php new file mode 100644 index 00000000..d1f8e1d0 --- /dev/null +++ b/scripts/windows/control.php @@ -0,0 +1,22 @@ +getMessage().PHP_EOL); exit(1); } diff --git a/scripts/windows/preflight.php b/scripts/windows/preflight.php new file mode 100644 index 00000000..c00dc042 --- /dev/null +++ b/scripts/windows/preflight.php @@ -0,0 +1,15 @@ +'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); +} diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 3ab41229..2140e53f 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -924,6 +924,7 @@ public function restart(?string $file = null): PromiseInterface { $this->stop(); + if (getenv('CIVILIZATIONBOT_PROFILE')) { return \React\Promise\resolve(null); } // Supervisor owns relaunch. return OSFunctions::restart($file ?? $this->constructed_file); } diff --git a/src/Civ13/GameServer.php b/src/Civ13/GameServer.php index 819a4829..dec01eed 100644 --- a/src/Civ13/GameServer.php +++ b/src/Civ13/GameServer.php @@ -768,6 +768,7 @@ public function DirectMessage(string $message, string $sender, string $recipient */ public function Host(?Message $message = null): void { + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } OSFunctions::execInBackground("python3 {$this->basedir}".Civ13::updateserverabspaths); if (file_exists($this->basedir.Civ13::serverdata)) { OSFunctions::execInBackground("rm -f {$this->basedir}".Civ13::serverdata); @@ -789,6 +790,7 @@ public function Host(?Message $message = null): void /** Shuts the server down (via `killciv13`), optionally announcing it in OOC first. */ public function Kill(?Message $message = null, bool $notify = true): void { + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } if ($notify) { $sender = ($message && $message->user_id) ? $this->civ13->verifier->getVerifiedItem($message->user_id)['ss13'] : ($this->civ13->discord->id ?? $this->civ13->discord->username); $this->OOCMessage("Server is shutting down. To get notified when we go live again, please join us on Discord at {$this->civ13->discord_formatted}", $sender); @@ -803,6 +805,7 @@ public function Kill(?Message $message = null, bool $notify = true): void /** Kills the server and re-hosts it 20s later, optionally announcing the restart in OOC. */ public function Restart(?Message $message = null, bool $notify = true): void { + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } $this->Kill(null, false); $this->civ13->loop->addTimer(20, function () use ($message, &$gameserver): void { $this->Host($message); @@ -820,6 +823,7 @@ public function Restart(?Message $message = null, bool $notify = true): void */ public function MapSwap(string $mapto, string $admin): PromiseInterface { + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { return reject(new \RuntimeException('Native map control is unavailable on Windows')); } $mapto = strtoupper($mapto); if (! file_exists($fp = $this->civ13->gitdir.Civ13::maps) || ! $file = @fopen($fp, 'r')) { $this->logger->error($err = "Unable to open `$fp` for reading."); diff --git a/src/Civ13/PortableProcess.php b/src/Civ13/PortableProcess.php new file mode 100644 index 00000000..90fee1be --- /dev/null +++ b/src/Civ13/PortableProcess.php @@ -0,0 +1,37 @@ +['pipe','r'],1=>['file',$state.'/stdout.log','a'],2=>['file',$state.'/stderr.log','a']], $state, ['bypass_shell'=>true]); + } + + /** Hold the returned handle for the lifetime of the child. */ + public static function lock(string $state): mixed + { + $handle = fopen($state.'/bot.lock', 'c+'); + if (!$handle || !flock($handle, LOCK_EX | LOCK_NB)) { + if (is_resource($handle)) { fclose($handle); } + throw new \RuntimeException('Portable instance already running'); + } + return $handle; + } + + public static function requestStop(string $state): void + { + $stream = @fopen($state.'/stop.request', 'x'); + if ($stream === false) { throw new \RuntimeException('Stop already requested or state unavailable'); } + fclose($stream); + } +} diff --git a/src/Civ13/PortableProfile.php b/src/Civ13/PortableProfile.php new file mode 100644 index 00000000..4e15ab19 --- /dev/null +++ b/src/Civ13/PortableProfile.php @@ -0,0 +1,66 @@ + 65536) { throw new \RuntimeException('Profile unavailable or oversized'); } + $profile = json_decode($bytes, true, 16, JSON_THROW_ON_ERROR); + if (!is_array($profile) || ($profile['schema'] ?? null) !== 'civilizationbot.portable.v1') { throw new \InvalidArgumentException('Unsupported portable profile'); } + foreach (['root', 'env_file', 'state_dir', 'gitdir'] as $field) { + if (!is_string($profile[$field] ?? null) || !self::absolute($profile[$field]) || str_contains($profile[$field], "\0")) { throw new \InvalidArgumentException('Absolute configured path required: '.$field); } + } + if (!is_dir($profile['root']) || !is_dir($profile['state_dir']) || !is_dir($profile['gitdir'])) { throw new \InvalidArgumentException('Configured directories must exist'); } + if (!is_array($profile['servers'] ?? null) || count($profile['servers']) !== 1) { throw new \InvalidArgumentException('Portable profile requires exactly one explicitly configured test server'); } + foreach ($profile['servers'] as $key => &$server) { + if (!is_string($key) || !preg_match('/^[a-z0-9_]{1,32}$/D', $key) || !is_array($server) || !is_string($server['basedir'] ?? null) || !self::absolute($server['basedir']) || !is_dir($server['basedir']) || ($server['ip'] ?? null) !== '127.0.0.1' || ($server['port'] ?? null) !== 1714) { throw new \InvalidArgumentException('Expected explicit loopback TEST1714 server'); } + foreach (['name', 'host'] as $field) { + if (!is_string($server[$field] ?? null) || $server[$field] === '') { throw new \InvalidArgumentException('Explicit server identity required'); } + } + if (!is_bool($server['enabled'] ?? null)) { throw new \InvalidArgumentException('Explicit enabled flag required'); } + foreach (['discussion','playercount','ooc','lobby','asay','ic','transit','adminlog','debug','garbage','runtime','attack'] as $field) { + if (!is_string($server[$field] ?? null) || ($server[$field] !== '' && !preg_match('/^[0-9]{17,20}$/D', $server[$field]))) { throw new \InvalidArgumentException('Explicit channel string required'); } + } + // Stable GameServer stores its port as a strict string. + $server['port'] = (string) $server['port']; + $server['key'] = $key; + } + unset($server); + if (!is_int($profile['http_port'] ?? null) || $profile['http_port'] < 1024 || $profile['http_port'] > 65535 || $profile['http_port'] === 1714) { throw new \InvalidArgumentException('Distinct explicit local HTTP port required'); } + if (!is_array($profile['options'] ?? null)) { throw new \InvalidArgumentException('Explicit portable options required'); } + foreach (['channel_ids', 'role_ids'] as $field) { + if (!is_array($profile['options'][$field] ?? null)) { throw new \InvalidArgumentException('Explicit destination map required: '.$field); } + } + return $profile; + } + + public static function assertRoot(array $profile, string $root): void + { + if (realpath($profile['root']) !== realpath($root)) { throw new \InvalidArgumentException('Profile belongs to another checkout'); } + } + + public static function absolute(string $path): bool + { + return str_starts_with($path, '/') || preg_match('~^[A-Za-z]:[\\\\/]~D', $path) === 1; + } + + /** No bootstrap, environment load, sockets or external effects. */ + public static function preflight(array $profile, callable $extension, string $version = PHP_VERSION): array + { + $errors = []; + if (version_compare($version, '8.3', '<')) { $errors[] = 'PHP 8.3 or newer required (typed class constants)'; } + foreach (['json', 'mbstring', 'openssl', 'filter', 'zlib', 'curl'] as $name) { + if (!$extension($name)) { $errors[] = 'Missing extension: '.$name; } + } + if (!is_file($profile['root'].'/vendor/autoload.php')) { $errors[] = 'Composer dependencies missing'; } + if (!is_file($profile['env_file'])) { $errors[] = 'Operator-provisioned separate environment file missing'; } + return $errors; + } +} diff --git a/tests/windows/portable.php b/tests/windows/portable.php new file mode 100644 index 00000000..38b1f6dd --- /dev/null +++ b/tests/windows/portable.php @@ -0,0 +1,35 @@ +25565,'schema'=>'civilizationbot.portable.v1','root'=>$dir,'state_dir'=>$dir,'env_file'=>$dir.'/separate.env','gitdir'=>$dir,'options'=>['channel_ids'=>[],'role_ids'=>[]],'servers'=>['test'=>['basedir'=>$dir,'ip'=>'127.0.0.1','port'=>1714,'name'=>'TEST1714','host'=>'fixture','enabled'=>false,'discussion'=>'','playercount'=>'','ooc'=>'','lobby'=>'','asay'=>'','ic'=>'','transit'=>'','adminlog'=>'','debug'=>'','garbage'=>'','runtime'=>'','attack'=>'']]]; + $file=$dir.'/profile.json';file_put_contents($file,json_encode($profile));$loaded=PortableProfile::load($file); + check($loaded['servers']['test']['port']==='1714'); + check(PortableProfile::preflight($loaded,fn($name)=>true,'8.3.0')===[]); + PortableProfile::assertRoot($loaded,$dir);rejects(fn()=>PortableProfile::assertRoot($loaded,dirname($dir))); + rejects(fn()=>PortableProfile::load('relative.json')); + $bad=$profile;$bad['http_port']=1714;file_put_contents($file,json_encode($bad));rejects(fn()=>PortableProfile::load($file)); + check(count(PortableProfile::preflight($loaded,fn($name)=>false,'8.2.0'))===7); + foreach (['ip'=>'civ13.com','port'=>1715,'basedir'=>'relative'] as $field=>$value) { $bad=$profile;$bad['servers']['test'][$field]=$value;file_put_contents($file,json_encode($bad));rejects(fn()=>PortableProfile::load($file)); } + $bad=$profile;unset($bad['options']['role_ids']);file_put_contents($file,json_encode($bad));rejects(fn()=>PortableProfile::load($file)); + $lock=PortableProcess::lock($dir);rejects(fn()=>PortableProcess::lock($dir));fclose($lock); + $lock=PortableProcess::lock($dir);fclose($lock); + $calls=[];$fake=PortableProcess::start(PortableProcess::argv(PHP_BINARY,$dir),$dir,function(...$args) use (&$calls) { $calls[]=$args;return 'FakeProcess'; }); + check($fake==='FakeProcess' && $calls[0][0]===[PHP_BINARY,$dir.'/bot.php'] && $calls[0][3]===['bypass_shell'=>true]); + PortableProcess::requestStop($dir);check(is_file($dir.'/stop.request'));rejects(fn()=>PortableProcess::requestStop($dir)); + require __DIR__.'/../../src/Civ13/GameServer.php'; + $server=(new ReflectionClass(\Civ13\GameServer::class))->newInstanceWithoutConstructor(); + $old=getenv('CIVILIZATIONBOT_PROFILE');putenv('CIVILIZATIONBOT_PROFILE=synthetic-profile'); + try { foreach (['Host','Kill','Restart'] as $method) { + try { $server->$method();throw new RuntimeException('Expected native control rejection'); } + catch (RuntimeException $error) { check($error->getMessage()==='Native lifecycle controls are unavailable on Windows'); } + } } finally { $old===false ? putenv('CIVILIZATIONBOT_PROFILE') : putenv('CIVILIZATIONBOT_PROFILE='.$old); } + echo "PASS portable profile isolation, PHP/extensions, wrong destination/port/path, shell-free FakeProcess and stop ownership\n"; +} finally { foreach (glob($dir.'/*') as $path) { if (is_dir($path)) { foreach (glob($path.'/*') as $child) { unlink($child); } rmdir($path); } else { unlink($path); } } rmdir($dir); } From 047922dd8f0a1dcaca7ee109689bab587efb39d4 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 16 Oct 2026 10:33:11 -0400 Subject: [PATCH 2/9] Handle absent SS14 verifier in portable bootstrap and test real construction --- .github/workflows/portable.yml | 3 ++ docs/windows-test1714.md | 8 +++++ src/Civ13/Civ13.php | 4 +-- src/Civ13/HttpServiceManager.php | 2 +- src/Civ13/MessageServiceManager.php | 6 ++-- src/Civ13/Slash.php | 3 ++ tests/windows/constructor.php | 52 +++++++++++++++++++++++++++++ 7 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 tests/windows/constructor.php diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index f5561bbf..e13f222f 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -14,6 +14,9 @@ jobs: 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/portable.php - run: php -l bot.php - run: php -l src/Civ13/Civ13.php diff --git a/docs/windows-test1714.md b/docs/windows-test1714.md index aed09486..ecd15626 100644 --- a/docs/windows-test1714.md +++ b/docs/windows-test1714.md @@ -59,6 +59,14 @@ 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. + 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 diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 2140e53f..5a22aac4 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -149,7 +149,7 @@ class Civ13 public Byond $byond; public Moderator $moderator; public Verifier $verifier; - public SS14Verifier $ss14verifier; + public ?SS14Verifier $ss14verifier = null; public string $welcome_message = ''; @@ -422,7 +422,7 @@ private function afterConstruct( $this->__loadOrInitializeVariables(); new Moderator($this); new Verifier($this, $options); - new SS14Verifier($this); + if ($this->verifier_server !== null) { new SS14Verifier($this); } foreach ($civ13_server_settings as $gameserver_options) { new GameServer($this, $gameserver_options); } diff --git a/src/Civ13/HttpServiceManager.php b/src/Civ13/HttpServiceManager.php index 42421546..0c87fc3d 100644 --- a/src/Civ13/HttpServiceManager.php +++ b/src/Civ13/HttpServiceManager.php @@ -888,7 +888,7 @@ function sendGetRequest(endpoint) { ) ->offsetSet( '/ss14verified', - fn (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse => HttpResponse::json($this->civ13->ss14verifier->toArray()), + fn (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse => ($this->civ13->ss14verifier === null ? HttpResponse::json(['error' => 'SS14 verification unavailable'])->withStatus(503) : HttpResponse::json($this->civ13->ss14verifier->toArray())), false ) ->offsetSet( diff --git a/src/Civ13/MessageServiceManager.php b/src/Civ13/MessageServiceManager.php index d0493cb2..de7758c5 100644 --- a/src/Civ13/MessageServiceManager.php +++ b/src/Civ13/MessageServiceManager.php @@ -132,9 +132,7 @@ private function __generateGlobalMessageCommands(): void ->offsetSet('cleanupgamelogs', new Commands\Civ13CleanupGameLogs($this->civ13), ['Chief Technical Officer']) ->offsetSet('playerlist', new Commands\Civ13PlayerList($this->civ13), ['Chief Technical Officer']) ->offsetSet('civ13register', new Commands\Civ13Register($this->civ13), ['Chief Technical Officer']) - ->offsetSet('civ14register', new Commands\Civ14Register($this->civ13), ['Chief Technical Officer']) ->offsetSet('civ13unverify', new Commands\Civ13UnVerify($this->civ13), ['Chief Technical Officer']) - ->offsetSet('civ14unverify', new Commands\Civ14UnVerify($this->civ13), ['Chief Technical Officer']) ->offsetSet('dumpappcommands', new Commands\DumpAppCommands($this->civ13), ['Chief Technical Officer']) ->offsetSet('ages', new Commands\Ages($this->civ13), ['Chief Technical Officer']) ->offsetSet('cv2poll', new Commands\CV2Poll($this->civ13), ['Ambassador']) @@ -227,6 +225,10 @@ function (Message $message, string $command, array $message_filtered): PromiseIn ['Admin'] ); } + if ($this->civ13->ss14verifier !== null) { + $this->messageHandler->offsetSet('civ14register', new Commands\Civ14Register($this->civ13), ['Chief Technical Officer']); + $this->messageHandler->offsetSet('civ14unverify', new Commands\Civ14UnVerify($this->civ13), ['Chief Technical Officer']); + } if (isset($this->civ13->ss14verifier, $this->civ13->role_ids['SS14 Verified'])) { $this->messageHandler->offsetSet('verifyme', new Commands\Civ14Verify($this->civ13)); } diff --git a/src/Civ13/Slash.php b/src/Civ13/Slash.php index 0840c90b..99583259 100644 --- a/src/Civ13/Slash.php +++ b/src/Civ13/Slash.php @@ -635,6 +635,7 @@ private function __declareListeners(): void : $this->respondWithMessage($interaction, Civ13::createBuilder()->setContent("<@{$interaction->data->target_id}> is not currently verified with a byond username or it does not exist in the cache yet"), true) ); + if ($this->civ13->ss14verifier !== null) { $this->discord->listenCommand( 'ss14', fn (Interaction $interaction): PromiseInterface => ($item = $this->civ13->ss14verifier->get('discord', $interaction->data->target_id)) @@ -642,6 +643,8 @@ private function __declareListeners(): void : $this->respondWithMessage($interaction, Civ13::createBuilder()->setContent("<@{$interaction->data->target_id}> is not currently verified with an SS14 account or it does not exist in the cache yet"), true) ); + } + $this->discord->listenCommand('bancheck', function (Interaction $interaction): PromiseInterface { if (! $item = $this->civ13->verifier->get('discord', $interaction->data->target_id)) { return $this->respondWithMessage($interaction, Civ13::createBuilder()->setContent("<@{$interaction->data->target_id}> is not currently verified with a byond username or it does not exist in the cache yet"), true); diff --git a/tests/windows/constructor.php b/tests/windows/constructor.php new file mode 100644 index 00000000..7c65c200 --- /dev/null +++ b/tests/windows/constructor.php @@ -0,0 +1,52 @@ +addPsr4('Civ13\\', __DIR__.'/../../src/Civ13/', true); +$loader->addPsr4('Civ14\\', __DIR__.'/../../src/Civ14/', true); +final class OfflineDiscord extends Discord\Discord { + public function commands(): array { return $this->application_commands ?? []; } + public function __construct(private React\EventLoop\LoopInterface $offlineLoop) {} + public function getLoop(): React\EventLoop\LoopInterface { return $this->offlineLoop; } +} +$dir=sys_get_temp_dir().'/portable-constructor-'.bin2hex(random_bytes(8));mkdir($dir); +$previous=getcwd();chdir($dir); +$negative=($argv[2] ?? null)==='--negative-control'; +if ($negative) { + $source=file_get_contents(__DIR__.'/../../src/Civ13/Civ13.php'); + $source=str_replace('if ($this->verifier_server !== null) { new SS14Verifier($this); }','new SS14Verifier($this);',$source,$mutations); + if ($mutations!==1) { throw new RuntimeException('Negative control not applied'); } + file_put_contents($dir.'/Civ13.php',$source); + $loader->addClassMap(['Civ13\\Civ13'=>$dir.'/Civ13.php']); +} +try { + $loop=new React\EventLoop\StreamSelectLoop(); + $discord=new OfflineDiscord($loop); + $logger=new Monolog\Logger('offline',[new Monolog\Handler\NullHandler()]); + $options=['discord'=>$discord,'loop'=>$loop,'logger'=>$logger,'stats'=>(new ReflectionClass(Discord\Stats::class))->newInstanceWithoutConstructor(),'browser'=>new React\Http\Browser(new React\Socket\Connector(['dns'=>false],$loop)),'filesystem'=>new React\Filesystem\Fallback\Adapter(),'verify_url'=>'','functions'=>[],'channel_ids'=>[],'role_ids'=>[]]; + try { $bot=new Civ13\Civ13($options,[],[],null); } + catch (RuntimeException $error) { + if ($negative && $error->getMessage()==='No SS14 verifier server set.') { echo "PASS negative control detects original unconditional service crash\n"; return; } + throw $error; + } + if ($negative) { throw new RuntimeException('Original service crash was not detected'); } + if ($bot->ss14verifier!==null || !isset($bot->httpServiceManager,$bot->messageServiceManager)) { throw new RuntimeException('Incomplete no-service construction'); } + new Civ13\Slash($bot); + if (isset($discord->commands()['ss14']) || !isset($discord->commands()['ckey'])) { throw new RuntimeException('Dependent registration unavailable-mode mismatch'); } + (new ReflectionMethod(Civ13\MessageServiceManager::class,'__generateGlobalMessageCommands'))->invoke($bot->messageServiceManager); + if ($bot->messageServiceManager->messageHandler->offsetGet('civ13register','handlers')===null) { throw new RuntimeException('Control command was not registered'); } + foreach (['civ14register','civ14unverify','verifyme'] as $command) { + if ($bot->messageServiceManager->messageHandler->offsetGet($command,'handlers')!==null) { throw new RuntimeException('Unavailable SS14 message command registered'); } + } + (new ReflectionMethod(Civ13\HttpServiceManager::class,'__generateEndpoints'))->invoke($bot->httpServiceManager); + $callback=$bot->httpServiceManager->httpHandler->offsetGet('/ss14verified','handlers'); + $response=$callback(new React\Http\Message\ServerRequest('GET','http://127.0.0.1/ss14verified'),'/ss14verified',false); + if ($response->getStatusCode()!==503 || str_contains((string)$response->getBody(),'[]')) { throw new RuntimeException('Unavailable service reported successful empty data'); } + echo "PASS actual Civ13 afterConstruct and slash registration without SS14 service; no loop run\n"; +} finally { + chdir($previous); + $remove=function($path) use (&$remove) { foreach(new DirectoryIterator($path) as $entry) { if($entry->isDot())continue;$entry->isDir()?$remove($entry->getPathname()):unlink($entry->getPathname()); }rmdir($path); }; + $remove($dir); +} + From edd93be9699cfd5b96cf80a662de7f94865af3ca Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 16 Oct 2026 11:23:23 -0400 Subject: [PATCH 3/9] Fix portable cache directory search permissions and verify writes --- src/Civ13/Civ13.php | 2 +- tests/windows/constructor.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 5a22aac4..e5bcc4e3 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -300,7 +300,7 @@ public function __construct( $this->filecache_path = $options['filecache_path']; } if (! is_dir($this->filecache_path)) { - mkdir($this->filecache_path, 0664, true); + mkdir($this->filecache_path, 0755, true); } if (isset($options['command_symbol']) && $options['command_symbol']) { diff --git a/tests/windows/constructor.php b/tests/windows/constructor.php index 7c65c200..2edd4637 100644 --- a/tests/windows/constructor.php +++ b/tests/windows/constructor.php @@ -12,6 +12,11 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline } $dir=sys_get_temp_dir().'/portable-constructor-'.bin2hex(random_bytes(8));mkdir($dir); $previous=getcwd();chdir($dir); +// Runtime warnings must fail this contract instead of passing CI with failed cache writes. +set_error_handler(static function(int $severity, string $message, string $file, int $line): bool { + if ($severity === E_WARNING) { throw new ErrorException($message, 0, $severity, $file, $line); } + return false; +}); $negative=($argv[2] ?? null)==='--negative-control'; if ($negative) { $source=file_get_contents(__DIR__.'/../../src/Civ13/Civ13.php'); @@ -32,6 +37,9 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline } if ($negative) { throw new RuntimeException('Original service crash was not detected'); } if ($bot->ss14verifier!==null || !isset($bot->httpServiceManager,$bot->messageServiceManager)) { throw new RuntimeException('Incomplete no-service construction'); } + if (PHP_OS_FAMILY !== 'Windows' && (fileperms($bot->filecache_path) & 0100) === 0) { throw new RuntimeException('Cache lacks owner search permission'); } + $cachePayload=['portable'=>true,'value'=>'cache round trip']; + if (!Civ13\OSFunctions::VarSave($bot->filecache_path,'portable-contract.json',$cachePayload,$logger) || Civ13\OSFunctions::VarLoad($bot->filecache_path,'portable-contract.json',$logger)!==$cachePayload) { throw new RuntimeException('Actual cache write/read failed'); } new Civ13\Slash($bot); if (isset($discord->commands()['ss14']) || !isset($discord->commands()['ckey'])) { throw new RuntimeException('Dependent registration unavailable-mode mismatch'); } (new ReflectionMethod(Civ13\MessageServiceManager::class,'__generateGlobalMessageCommands'))->invoke($bot->messageServiceManager); @@ -45,6 +53,7 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline if ($response->getStatusCode()!==503 || str_contains((string)$response->getBody(),'[]')) { throw new RuntimeException('Unavailable service reported successful empty data'); } echo "PASS actual Civ13 afterConstruct and slash registration without SS14 service; no loop run\n"; } finally { + restore_error_handler(); chdir($previous); $remove=function($path) use (&$remove) { foreach(new DirectoryIterator($path) as $entry) { if($entry->isDot())continue;$entry->isDir()?$remove($entry->getPathname()):unlink($entry->getPathname()); }rmdir($path); }; $remove($dir); From 88c50c3835988deba9960454371bd36197032f75 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 16 Oct 2026 11:27:46 -0400 Subject: [PATCH 4/9] Exercise original POSIX cache permission failure in CI --- .github/workflows/portable.yml | 1 + tests/windows/constructor.php | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index e13f222f..782ce007 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -17,6 +17,7 @@ jobs: - 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 -l bot.php - run: php -l src/Civ13/Civ13.php diff --git a/tests/windows/constructor.php b/tests/windows/constructor.php index 2edd4637..bb78bd4c 100644 --- a/tests/windows/constructor.php +++ b/tests/windows/constructor.php @@ -10,6 +10,8 @@ public function commands(): array { return $this->application_commands ?? []; } public function __construct(private React\EventLoop\LoopInterface $offlineLoop) {} public function getLoop(): React\EventLoop\LoopInterface { return $this->offlineLoop; } } +$cacheNegative=($argv[2] ?? null)==='--cache-negative-control'; +if ($cacheNegative && PHP_OS_FAMILY === 'Windows') { echo "SKIP POSIX permission mutation on Windows\n"; return; } $dir=sys_get_temp_dir().'/portable-constructor-'.bin2hex(random_bytes(8));mkdir($dir); $previous=getcwd();chdir($dir); // Runtime warnings must fail this contract instead of passing CI with failed cache writes. @@ -18,9 +20,9 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline return false; }); $negative=($argv[2] ?? null)==='--negative-control'; -if ($negative) { +if ($negative || $cacheNegative) { $source=file_get_contents(__DIR__.'/../../src/Civ13/Civ13.php'); - $source=str_replace('if ($this->verifier_server !== null) { new SS14Verifier($this); }','new SS14Verifier($this);',$source,$mutations); + $source=$cacheNegative ? str_replace('mkdir($this->filecache_path, 0755, true);','mkdir($this->filecache_path, 0664, true);',$source,$mutations) : str_replace('if ($this->verifier_server !== null) { new SS14Verifier($this); }','new SS14Verifier($this);',$source,$mutations); if ($mutations!==1) { throw new RuntimeException('Negative control not applied'); } file_put_contents($dir.'/Civ13.php',$source); $loader->addClassMap(['Civ13\\Civ13'=>$dir.'/Civ13.php']); @@ -31,6 +33,10 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline $logger=new Monolog\Logger('offline',[new Monolog\Handler\NullHandler()]); $options=['discord'=>$discord,'loop'=>$loop,'logger'=>$logger,'stats'=>(new ReflectionClass(Discord\Stats::class))->newInstanceWithoutConstructor(),'browser'=>new React\Http\Browser(new React\Socket\Connector(['dns'=>false],$loop)),'filesystem'=>new React\Filesystem\Fallback\Adapter(),'verify_url'=>'','functions'=>[],'channel_ids'=>[],'role_ids'=>[]]; try { $bot=new Civ13\Civ13($options,[],[],null); } + catch (ErrorException $error) { + if ($cacheNegative && str_contains($error->getMessage(),'Permission denied')) { echo "PASS POSIX cache mutation rejects failed production writes\n"; return; } + throw $error; + } catch (RuntimeException $error) { if ($negative && $error->getMessage()==='No SS14 verifier server set.') { echo "PASS negative control detects original unconditional service crash\n"; return; } throw $error; @@ -40,6 +46,7 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline if (PHP_OS_FAMILY !== 'Windows' && (fileperms($bot->filecache_path) & 0100) === 0) { throw new RuntimeException('Cache lacks owner search permission'); } $cachePayload=['portable'=>true,'value'=>'cache round trip']; if (!Civ13\OSFunctions::VarSave($bot->filecache_path,'portable-contract.json',$cachePayload,$logger) || Civ13\OSFunctions::VarLoad($bot->filecache_path,'portable-contract.json',$logger)!==$cachePayload) { throw new RuntimeException('Actual cache write/read failed'); } + if ($cacheNegative) { throw new RuntimeException('Broken POSIX directory permissions were not detected'); } new Civ13\Slash($bot); if (isset($discord->commands()['ss14']) || !isset($discord->commands()['ckey'])) { throw new RuntimeException('Dependent registration unavailable-mode mismatch'); } (new ReflectionMethod(Civ13\MessageServiceManager::class,'__generateGlobalMessageCommands'))->invoke($bot->messageServiceManager); From b7ba4a2a55739105183919dc0089b8d5c330a2da Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 19:57:19 -0400 Subject: [PATCH 5/9] Harden portable Windows configuration and exercise real child supervision --- .github/workflows/portable.yml | 3 +- bot.php | 8 +++- config/examples/windows-test1714.json | 4 +- docs/windows-test1714.md | 21 ++++++++-- scripts/windows/control.php | 1 + scripts/windows/preflight.php | 1 + src/Civ13/Civ13.php | 5 +++ src/Civ13/HttpHandler.php | 2 +- src/Civ13/PortableProfile.php | 26 +++++++++++++ tests/windows/constructor.php | 7 ++++ tests/windows/portable.php | 8 ++++ tests/windows/process.php | 56 +++++++++++++++++++++++++++ 12 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 tests/windows/process.php diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index 782ce007..c7759fec 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -5,7 +5,7 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest] - php: ['8.3', '8.4'] + php: ['8.3', '8.4', '8.5'] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -19,6 +19,7 @@ jobs: - 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 -l bot.php - run: php -l src/Civ13/Civ13.php - run: php -l src/Civ13/GameServer.php diff --git a/bot.php b/bot.php index 9bcfcbd8..ef9484fc 100644 --- a/bot.php +++ b/bot.php @@ -106,9 +106,11 @@ function loadEnv(string $filePath, bool $explicit = false): array 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') { @@ -457,8 +459,9 @@ function loadEnv(string $filePath, bool $explicit = false): array ]; if ($portable !== null) { // Replace the entire ambient destination/control configuration, never recursively merge it. - $options = $portable['options']; - $options['gitdir'] = $portable['gitdir']; + $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; @@ -484,6 +487,7 @@ function loadEnv(string $filePath, bool $explicit = false): array /** @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') diff --git a/config/examples/windows-test1714.json b/config/examples/windows-test1714.json index 85a2d403..5fe6558b 100644 --- a/config/examples/windows-test1714.json +++ b/config/examples/windows-test1714.json @@ -6,7 +6,9 @@ "gitdir": "D:/GitHub/Civ13", "http_port": 25565, "options": { - "civ13_guild_id": "468979034571931648", + "civ13_guild_id": "", + "owner_id": "", + "technician_id": "", "channel_ids": {}, "role_ids": {}, "folders": {"json": "json/"}, diff --git a/docs/windows-test1714.md b/docs/windows-test1714.md index ecd15626..466777b5 100644 --- a/docs/windows-test1714.md +++ b/docs/windows-test1714.md @@ -7,14 +7,15 @@ 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. Optional event-loop accelerators are not required. The existing -composer.json PHP floor is lower than the typed class constants used by the code; -the portable preflight reports the actual 8.3 floor before loading dependencies. +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. Create the configured state directory and game directory first. +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. 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 @@ -39,11 +40,17 @@ 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 @@ -67,6 +74,12 @@ 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. + 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 diff --git a/scripts/windows/control.php b/scripts/windows/control.php index d1f8e1d0..be824082 100644 --- a/scripts/windows/control.php +++ b/scripts/windows/control.php @@ -7,6 +7,7 @@ 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)); } diff --git a/scripts/windows/preflight.php b/scripts/windows/preflight.php index c00dc042..999a6084 100644 --- a/scripts/windows/preflight.php +++ b/scripts/windows/preflight.php @@ -6,6 +6,7 @@ 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); diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index d1924de5..c1c8da19 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -475,6 +475,8 @@ private function resolveOptions(array &$options = []): array 'loop', 'browser', 'filesystem', + 'filecache_path', + 'discover_external_ip', 'civ13_guild_id', 'civ_token', 'command_symbol', @@ -503,6 +505,7 @@ private function resolveOptions(array &$options = []): array 'webserver_url', ]) ->setDefaults([ + 'discover_external_ip' => true, 'welcome_message' => '', 'logger' => null, 'onFulfilledDefault' => null, @@ -555,6 +558,8 @@ private function resolveOptions(array &$options = []): array ->setAllowedTypes('functions', 'array') ->setAllowedTypes('loop', LoopInterface::class) ->setAllowedTypes('browser', ['null', Browser::class]) + ->setAllowedTypes('filecache_path', 'string') + ->setAllowedTypes('discover_external_ip', 'bool') ->setAllowedTypes('filesystem', ['null', AdapterInterface::class]) ->setAllowedTypes('civ13_guild_id', 'string') ->setAllowedTypes('civ_token', 'string') diff --git a/src/Civ13/HttpHandler.php b/src/Civ13/HttpHandler.php index c90efa20..03d779ad 100644 --- a/src/Civ13/HttpHandler.php +++ b/src/Civ13/HttpHandler.php @@ -95,7 +95,7 @@ public function __construct(Civ13 &$civ13, array $handlers = [], array $whitelis $this->attributes['ratelimits'] = []; //$this->attributes['endpoints'] = []; - if ($external_ip = @file_get_contents('http://ipecho.net/plain')) { + if (($civ13->options['discover_external_ip'] ?? true) && ($external_ip = @file_get_contents('http://ipecho.net/plain'))) { $this->external_ip = $external_ip; } foreach ($whitelist as $ip) { diff --git a/src/Civ13/PortableProfile.php b/src/Civ13/PortableProfile.php index 4e15ab19..647cbf14 100644 --- a/src/Civ13/PortableProfile.php +++ b/src/Civ13/PortableProfile.php @@ -38,9 +38,35 @@ public static function load(string $file): array foreach (['channel_ids', 'role_ids'] as $field) { if (!is_array($profile['options'][$field] ?? null)) { throw new \InvalidArgumentException('Explicit destination map required: '.$field); } } + self::runtimeOptions($profile); return $profile; } + /** Replace privileged production defaults; omitted local identities stay disabled. */ + public static function runtimeOptions(array $profile): array + { + $options = $profile['options']; + foreach (['owner_id', 'technician_id', 'civ13_guild_id'] as $field) { + $value = $options[$field] ?? ''; + if (!is_string($value) || ($value !== '' && preg_match('/^[0-9]{17,20}$/D', $value) !== 1)) { + throw new \InvalidArgumentException('Expected explicit Discord identity: '.$field); + } + $options[$field] = $value; + } + $options['gitdir'] = $profile['gitdir']; + $options['filecache_path'] = $profile['state_dir'].'/json/'; + $options['discover_external_ip'] = false; + $options['verify_url'] = ''; + $options['webserver_url'] = '127.0.0.1'; + return $options; + } + + /** An inherited process environment is not an explicit credential source. */ + public static function environmentValue(string $name, array $loadedNames): string + { + return in_array($name, $loadedNames, true) ? (getenv($name) ?: '') : ''; + } + public static function assertRoot(array $profile, string $root): void { if (realpath($profile['root']) !== realpath($root)) { throw new \InvalidArgumentException('Profile belongs to another checkout'); } diff --git a/tests/windows/constructor.php b/tests/windows/constructor.php index bb78bd4c..c6f44168 100644 --- a/tests/windows/constructor.php +++ b/tests/windows/constructor.php @@ -14,6 +14,9 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline if ($cacheNegative && PHP_OS_FAMILY === 'Windows') { echo "SKIP POSIX permission mutation on Windows\n"; return; } $dir=sys_get_temp_dir().'/portable-constructor-'.bin2hex(random_bytes(8));mkdir($dir); $previous=getcwd();chdir($dir); +// Disallow PHP stream HTTP too: disabling React DNS alone does not prevent it. +$blockedWrappers=[]; +foreach (['http','https'] as $scheme) { if (in_array($scheme,stream_get_wrappers(),true)) { stream_wrapper_unregister($scheme);$blockedWrappers[]=$scheme; } } // Runtime warnings must fail this contract instead of passing CI with failed cache writes. set_error_handler(static function(int $severity, string $message, string $file, int $line): bool { if ($severity === E_WARNING) { throw new ErrorException($message, 0, $severity, $file, $line); } @@ -32,6 +35,8 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline $discord=new OfflineDiscord($loop); $logger=new Monolog\Logger('offline',[new Monolog\Handler\NullHandler()]); $options=['discord'=>$discord,'loop'=>$loop,'logger'=>$logger,'stats'=>(new ReflectionClass(Discord\Stats::class))->newInstanceWithoutConstructor(),'browser'=>new React\Http\Browser(new React\Socket\Connector(['dns'=>false],$loop)),'filesystem'=>new React\Filesystem\Fallback\Adapter(),'verify_url'=>'','functions'=>[],'channel_ids'=>[],'role_ids'=>[]]; + require_once __DIR__.'/../../src/Civ13/PortableProfile.php'; + $options=array_merge($options, Civ13\PortableProfile::runtimeOptions(['options'=>[], 'gitdir'=>$dir, 'state_dir'=>$dir])); try { $bot=new Civ13\Civ13($options,[],[],null); } catch (ErrorException $error) { if ($cacheNegative && str_contains($error->getMessage(),'Permission denied')) { echo "PASS POSIX cache mutation rejects failed production writes\n"; return; } @@ -42,6 +47,7 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline throw $error; } if ($negative) { throw new RuntimeException('Original service crash was not detected'); } + if ($bot->owner_id!=='' || $bot->technician_id!=='' || $bot->civ13_guild_id!=='' || $bot->filecache_path!==$dir.'/json/') { throw new RuntimeException('Portable privileged identity or state isolation failed'); } if ($bot->ss14verifier!==null || !isset($bot->httpServiceManager,$bot->messageServiceManager)) { throw new RuntimeException('Incomplete no-service construction'); } if (PHP_OS_FAMILY !== 'Windows' && (fileperms($bot->filecache_path) & 0100) === 0) { throw new RuntimeException('Cache lacks owner search permission'); } $cachePayload=['portable'=>true,'value'=>'cache round trip']; @@ -61,6 +67,7 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline echo "PASS actual Civ13 afterConstruct and slash registration without SS14 service; no loop run\n"; } finally { restore_error_handler(); + foreach ($blockedWrappers as $scheme) { stream_wrapper_restore($scheme); } chdir($previous); $remove=function($path) use (&$remove) { foreach(new DirectoryIterator($path) as $entry) { if($entry->isDot())continue;$entry->isDir()?$remove($entry->getPathname()):unlink($entry->getPathname()); }rmdir($path); }; $remove($dir); diff --git a/tests/windows/portable.php b/tests/windows/portable.php index 38b1f6dd..16ccfa79 100644 --- a/tests/windows/portable.php +++ b/tests/windows/portable.php @@ -12,6 +12,14 @@ function rejects(callable $call): void { try { $call(); } catch (Throwable $e) { $profile=['http_port'=>25565,'schema'=>'civilizationbot.portable.v1','root'=>$dir,'state_dir'=>$dir,'env_file'=>$dir.'/separate.env','gitdir'=>$dir,'options'=>['channel_ids'=>[],'role_ids'=>[]],'servers'=>['test'=>['basedir'=>$dir,'ip'=>'127.0.0.1','port'=>1714,'name'=>'TEST1714','host'=>'fixture','enabled'=>false,'discussion'=>'','playercount'=>'','ooc'=>'','lobby'=>'','asay'=>'','ic'=>'','transit'=>'','adminlog'=>'','debug'=>'','garbage'=>'','runtime'=>'','attack'=>'']]]; $file=$dir.'/profile.json';file_put_contents($file,json_encode($profile));$loaded=PortableProfile::load($file); check($loaded['servers']['test']['port']==='1714'); + $options=PortableProfile::runtimeOptions($loaded); + foreach (['owner_id','technician_id','civ13_guild_id','verify_url'] as $field) { check($options[$field]===''); } + check($options['filecache_path']===$dir.'/json/'); + $name='CIVBOT_SYNTHETIC_TEST';$previous=getenv($name);putenv($name.'=fixture-only'); + try { check(PortableProfile::environmentValue($name,[])==='');check(PortableProfile::environmentValue($name,[$name])==='fixture-only'); } + finally { $previous===false ? putenv($name) : putenv($name.'='.$previous); } + $bad=$profile;$bad['options']['owner_id']='invalid';file_put_contents($file,json_encode($bad));rejects(fn()=>PortableProfile::load($file)); + check(PortableProfile::preflight($loaded,fn($name)=>true,'8.3.0')===[]); PortableProfile::assertRoot($loaded,$dir);rejects(fn()=>PortableProfile::assertRoot($loaded,dirname($dir))); rejects(fn()=>PortableProfile::load('relative.json')); diff --git a/tests/windows/process.php b/tests/windows/process.php new file mode 100644 index 00000000..d598325a --- /dev/null +++ b/tests/windows/process.php @@ -0,0 +1,56 @@ + $deadline) { exit(99); } + usleep(10000); +} +unlink("stop.request"); +fclose($lock); +exit(23); +'); + $literal = 'spaces & | %PATH% "quoted"'; + $argv = [...PortableProcess::argv(PHP_BINARY, $dir), $literal]; + $process = PortableProcess::start($argv, $dir, static function ($args, $descriptors, $cwd, $options) use (&$pipes) { + // A minimal explicit environment prevents accidental inherited bot credentials. + $environment = array_filter(['SystemRoot'=>getenv('SystemRoot'), 'LD_LIBRARY_PATH'=>getenv('LD_LIBRARY_PATH'), 'PHPRC'=>getenv('PHPRC'), 'PHP_INI_SCAN_DIR'=>getenv('PHP_INI_SCAN_DIR'), 'TEMP'=>sys_get_temp_dir(), 'TMP'=>sys_get_temp_dir()], static fn ($v) => $v !== false); + return proc_open($args, $descriptors, $pipes, $cwd, $environment, $options); + }); + if (!is_resource($process)) { throw new RuntimeException('Child failed to launch'); } + fclose($pipes[0]); + $deadline = microtime(true) + 10; + while (!is_file($dir.'/ready.json') && microtime(true) < $deadline) { usleep(10000); } + if (!is_file($dir.'/ready.json')) { throw new RuntimeException('Child readiness timed out'); } + [$cwd, $received] = json_decode(file_get_contents($dir.'/ready.json'), true, 8, JSON_THROW_ON_ERROR); + if (realpath($cwd) !== realpath($dir) || $received !== $literal) { throw new RuntimeException('Child path or literal argv corrupted'); } + try { $unexpected = PortableProcess::lock($dir); fclose($unexpected); throw new LogicException('Duplicate process lock accepted'); } + catch (RuntimeException $error) { if ($error->getMessage() !== 'Portable instance already running') { throw $error; } } + PortableProcess::requestStop($dir); + $exit = proc_close($process); $process = null; + if ($exit !== 23 || is_file($dir.'/stop.request')) { throw new RuntimeException('Child exit/stop marker mismatch'); } + if (!str_contains(file_get_contents($dir.'/stdout.log'), 'stdout fixture') || !str_contains(file_get_contents($dir.'/stderr.log'), 'stderr fixture')) { throw new RuntimeException('Child log redirection failed'); } + $lock = PortableProcess::lock($dir); fclose($lock); + echo "PASS real PHP child: literal argv, spaced path, log redirection, cross-process locking, stop and exit status\n"; +} finally { + // Only the exact child handle created by this test can be terminated on failure. + if (is_resource($process)) { proc_terminate($process); proc_close($process); } + foreach (glob($dir.'/*') as $path) { unlink($path); } + rmdir($dir); +} From 09ba4d5d63d7b6836c556a05d31adc22abdbc0a8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 19:57:44 -0400 Subject: [PATCH 6/9] Publish process fixture readiness atomically --- tests/windows/process.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/windows/process.php b/tests/windows/process.php index d598325a..cf16326e 100644 --- a/tests/windows/process.php +++ b/tests/windows/process.php @@ -14,7 +14,8 @@ file_put_contents($dir.'/bot.php', ' Date: Mon, 19 Oct 2026 19:59:24 -0400 Subject: [PATCH 7/9] Exercise shipped profile and preserve POSIX directory traversal --- src/Civ13/Civ13.php | 2 +- tests/windows/constructor.php | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index c1c8da19..50f70379 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -649,7 +649,7 @@ private function resolveOptions(array &$options = []): array $this->then = new PromiseMiddleware($this->onFulfilledDefault, $this->onRejectedDefault); foreach ($options['folders'] as $key => $value) { - if (! is_string($value) || (! is_dir($value) && ! @mkdir($value, 0664, true))) { + if (! is_string($value) || (! is_dir($value) && ! @mkdir($value, 0755, true))) { $this->logger->warning("`$value` is not a valid folder path!"); unset($options['folders'][$key]); } diff --git a/tests/windows/constructor.php b/tests/windows/constructor.php index c6f44168..689276ce 100644 --- a/tests/windows/constructor.php +++ b/tests/windows/constructor.php @@ -36,7 +36,9 @@ public function getLoop(): React\EventLoop\LoopInterface { return $this->offline $logger=new Monolog\Logger('offline',[new Monolog\Handler\NullHandler()]); $options=['discord'=>$discord,'loop'=>$loop,'logger'=>$logger,'stats'=>(new ReflectionClass(Discord\Stats::class))->newInstanceWithoutConstructor(),'browser'=>new React\Http\Browser(new React\Socket\Connector(['dns'=>false],$loop)),'filesystem'=>new React\Filesystem\Fallback\Adapter(),'verify_url'=>'','functions'=>[],'channel_ids'=>[],'role_ids'=>[]]; require_once __DIR__.'/../../src/Civ13/PortableProfile.php'; - $options=array_merge($options, Civ13\PortableProfile::runtimeOptions(['options'=>[], 'gitdir'=>$dir, 'state_dir'=>$dir])); + $sample=json_decode(file_get_contents(__DIR__.'/../../config/examples/windows-test1714.json'), true, 16, JSON_THROW_ON_ERROR); + $profileOptions=($negative || $cacheNegative) ? [] : $sample['options']; + $options=array_merge($options, Civ13\PortableProfile::runtimeOptions(['options'=>$profileOptions, 'gitdir'=>$dir, 'state_dir'=>$dir])); try { $bot=new Civ13\Civ13($options,[],[],null); } catch (ErrorException $error) { if ($cacheNegative && str_contains($error->getMessage(),'Permission denied')) { echo "PASS POSIX cache mutation rejects failed production writes\n"; return; } From dc99145eeedd020eeea3df9ed3655806ff4a0b7c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 20:08:07 -0400 Subject: [PATCH 8/9] Exercise portable ready bootstrap and reject legacy maintenance before effects --- .github/workflows/portable.yml | 3 + docs/windows-test1714.md | 27 ++- src/Civ13/HttpServiceManager.php | 24 ++ .../MessageCommand/Commands/Civ14Host.php | 3 + .../Commands/PullCivRepository.php | 3 + .../MessageCommand/Commands/TypeSpess.php | 3 + .../Commands/UpdateDependencies.php | 3 + src/Civ13/OSFunctions.php | 57 ++--- src/Civ13/Panels.php | 10 +- src/Civ13/Slash.php | 12 +- src/Civ13/Verifier.php | 2 +- tests/windows/ready.php | 221 ++++++++++++++++++ tests/windows/shell-guard.php | 52 +++++ 13 files changed, 380 insertions(+), 40 deletions(-) create mode 100644 tests/windows/ready.php create mode 100644 tests/windows/shell-guard.php diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index c7759fec..b0f6e9aa 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -20,6 +20,9 @@ jobs: - 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 diff --git a/docs/windows-test1714.md b/docs/windows-test1714.md index 466777b5..79873461 100644 --- a/docs/windows-test1714.md +++ b/docs/windows-test1714.md @@ -15,7 +15,11 @@ 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. Create the configured state directory and game directory first. +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 @@ -58,6 +62,17 @@ unavailable until a separate reviewed authoritative read-only contract is suppli 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 @@ -80,6 +95,16 @@ stdout/stderr logs, cross-process lock exclusion, graceful stop marker consumpti 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 diff --git a/src/Civ13/HttpServiceManager.php b/src/Civ13/HttpServiceManager.php index 8d6c94ca..9bb5e2a0 100644 --- a/src/Civ13/HttpServiceManager.php +++ b/src/Civ13/HttpServiceManager.php @@ -431,6 +431,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/reset', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } OSFunctions::execInBackground('git reset --hard origin/main'); $message = 'Forcefully moving the HEAD back to origin/main...'; if (isset($this->civ13->channel_ids['staff_bot']) && $channel = $this->discord->getChannel($this->civ13->channel_ids['staff_bot'])) { @@ -444,6 +447,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/githubupdated', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } $secret = (string) getenv('github_secret'); $body = strval($request->getBody()); // Prefer the SHA-256 signature GitHub sends; fall back to SHA-1. @@ -499,6 +505,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/cancelupdaterestart', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } if (isset($this->civ13->timers['restart_pending']) && $this->civ13->timers['restart_pending'] instanceof TimerInterface) { $this->discord->getLoop()->cancelTimer($this->civ13->timers['restart_pending']); unset($this->civ13->timers['restart_pending']); @@ -515,6 +524,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/pull', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } OSFunctions::execInBackground('git pull'); $message = 'Updating code from GitHub...'; if (isset($this->civ13->channel_ids['staff_bot']) && $channel = $this->discord->getChannel($this->civ13->channel_ids['staff_bot'])) { @@ -528,6 +540,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/update', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } OSFunctions::execInBackground('composer update'); $message = 'Updating dependencies...'; if (isset($this->civ13->channel_ids['staff_bot']) && $channel = $this->discord->getChannel($this->civ13->channel_ids['staff_bot'])) { @@ -541,6 +556,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/restart', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } $message = 'Manually Restarting...'; if (isset($this->civ13->restart_message) && $this->civ13->restart_message instanceof Message) { $this->civ13->restart_message->edit(Civ13::createBuilder()->setContent('Manually Restarting...'))->then(fn () => $this->civ13->restart()); @@ -558,6 +576,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/kill', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } $message = 'Shutting down...'; $this->discord->getLoop()->addTimer(3, fn () => $this->civ13->restart()); @@ -568,6 +589,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/clearenv', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } $message = 'Clearing .env...'; if (file_exists($env = getcwd().'/.env')) { file_put_contents($env, ''); diff --git a/src/Civ13/MessageCommand/Commands/Civ14Host.php b/src/Civ13/MessageCommand/Commands/Civ14Host.php index 3b362604..a1c4dd91 100644 --- a/src/Civ13/MessageCommand/Commands/Civ14Host.php +++ b/src/Civ13/MessageCommand/Commands/Civ14Host.php @@ -28,6 +28,9 @@ class Civ14Host extends Civ13MessageCommand */ public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $message->reply('This maintenance command is unavailable in Windows/portable mode.'); + } if (! $state = trim(substr($message_filtered['message_content_lower'], strlen($command)))) { return $this->civ13->reply($message, 'Wrong format. Please try `ss14 on` or `ss14 off`.'); } diff --git a/src/Civ13/MessageCommand/Commands/PullCivRepository.php b/src/Civ13/MessageCommand/Commands/PullCivRepository.php index 4e8113d5..e0ad9c4f 100644 --- a/src/Civ13/MessageCommand/Commands/PullCivRepository.php +++ b/src/Civ13/MessageCommand/Commands/PullCivRepository.php @@ -28,6 +28,9 @@ class PullCivRepository extends Civ13MessageCommand */ public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $message->reply('This maintenance command is unavailable in Windows/portable mode.'); + } return $message->react( (is_dir($fp = $this->civ13->gitdir) && OSFunctions::execInBackground("git -C {$fp} pull")) ? '👍' diff --git a/src/Civ13/MessageCommand/Commands/TypeSpess.php b/src/Civ13/MessageCommand/Commands/TypeSpess.php index 60d6d014..724b7562 100644 --- a/src/Civ13/MessageCommand/Commands/TypeSpess.php +++ b/src/Civ13/MessageCommand/Commands/TypeSpess.php @@ -28,6 +28,9 @@ class TypeSpess extends Civ13MessageCommand */ public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $message->reply('This maintenance command is unavailable in Windows/portable mode.'); + } if (! $state = trim(substr($message_filtered['message_content_lower'], strlen($command)))) { return $this->civ13->reply($message, 'Wrong format. Please try `ts on` or `ts off`.'); } diff --git a/src/Civ13/MessageCommand/Commands/UpdateDependencies.php b/src/Civ13/MessageCommand/Commands/UpdateDependencies.php index f7cdd43a..985cd6bb 100644 --- a/src/Civ13/MessageCommand/Commands/UpdateDependencies.php +++ b/src/Civ13/MessageCommand/Commands/UpdateDependencies.php @@ -28,6 +28,9 @@ class UpdateDependencies extends Civ13MessageCommand */ public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $message->reply('This maintenance command is unavailable in Windows/portable mode.'); + } return $message->react( OSFunctions::execInBackground('composer update') ? '👍' diff --git a/src/Civ13/OSFunctions.php b/src/Civ13/OSFunctions.php index d514ecf8..5e281601 100644 --- a/src/Civ13/OSFunctions.php +++ b/src/Civ13/OSFunctions.php @@ -27,6 +27,12 @@ */ class OSFunctions { + /** Legacy shell strings cannot establish ownership of Windows/portable children. */ + public static function legacyShellAvailable(): bool + { + return PHP_OS_FAMILY !== 'Windows' && !getenv('CIVILIZATIONBOT_PROFILE'); + } + public const array DEFAULT_PIPES = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], @@ -55,17 +61,17 @@ private static function readPipes(array &$pipes) * Spawns a child process to execute the given command. * * This function attempts to create a new process to run the specified command. - * On Windows systems, it returns null as the POSIX compatibility layer is insufficient. + * Windows and portable profiles reject legacy shell commands before any process is created. * On other systems, it uses `sudo` and `nohup` to run the command and sets up event listeners * to log the process's stdout data, end, error, and close events, as well as the exit event. * * @param string $cmd The command to be executed in the child process. - * @return PromiseInterface The created Process object, or null if running on Windows. + * @return PromiseInterface The created Process object, or a rejected promise in Windows/portable mode. */ public static function spawnChildProcess(string $cmd): PromiseInterface { - if (PHP_OS_FAMILY == 'Windows') { - return reject(new \Exception('Windows does not support POSIX compatibility layer')); + if (!self::legacyShellAvailable()) { + return reject(new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode')); } $process = new Process("sudo nohup $cmd"); $process->stdout->on('end', static fn () => error_log('ended'.PHP_EOL)); @@ -80,9 +86,8 @@ public static function spawnChildProcess(string $cmd): PromiseInterface /** * Executes a command in the background. * - * This function executes a given command in the background, either on Windows or Unix-based systems. - * On Windows, it uses `popen` with the `start` command. - * On Unix-based systems, it uses `proc_open` with `sudo nohup`. + * This function retains the legacy Unix shell path. Windows and portable profiles + * reject it; use an explicitly owned argv-based process adapter instead. * * @param string $cmd The command to execute. * @return PromiseInterface A promise that resolves with the process resource on success, or rejects with an exception on failure. @@ -90,12 +95,8 @@ public static function spawnChildProcess(string $cmd): PromiseInterface */ public static function execInBackground(string $cmd): PromiseInterface { - if (PHP_OS_FAMILY == 'Windows') { - if (($p = popen("start {$cmd}", 'r')) === false) { - return reject(new MissingSystemPermissionException('popen() failed')); - } - - return resolve($p); + if (!self::legacyShellAvailable()) { + return reject(new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode')); } if (! $proc = proc_open($cmd, self::DEFAULT_PIPES, $pipes)) { return reject(new MissingSystemPermissionException('proc_open() failed')); @@ -124,8 +125,8 @@ public static function execInBackground(string $cmd): PromiseInterface /** * Restarts the application by executing an external shell command. * - * On Windows, it runs a batch file (`run.bat`) located in the current working directory. - * On other operating systems, it runs a PHP script (`bot.php`) in the background using `nohup`. + * Windows and portable profiles reject self-spawning shell restarts. On other + * operating systems, the legacy PHP/nohup restart path is preserved. * * @return PromiseInterface A promise that resolves with the process resource on success, or rejects with a MissingSystemPermissionException on failure. * @@ -133,12 +134,8 @@ public static function execInBackground(string $cmd): PromiseInterface */ public static function restart(string $file = 'bot.php'): PromiseInterface { - if (PHP_OS_FAMILY == 'Windows') { - if (($p = popen('cmd /c "'.getcwd().'\run.bat"', 'r')) === false) { - return reject(new MissingSystemPermissionException('popen() failed')); - } - - return resolve($p); + if (!self::legacyShellAvailable()) { + return reject(new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode')); } if (! $proc = proc_open($output = "sudo nohup php \"$file\" &", self::DEFAULT_PIPES, $pipes)) { return reject(new MissingSystemPermissionException('proc_open() failed')); @@ -176,6 +173,9 @@ public static function restart(string $file = 'bot.php'): PromiseInterface */ public static function disown($proc): void { + if (!self::legacyShellAvailable()) { + throw new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode'); + } $proc_details = proc_get_status($proc); error_log("Disowning process {$proc_details['pid']}..."); if (isset($proc_details['pid']) && $proc_details['running']) { @@ -210,24 +210,15 @@ public static function portIsAvailable(int $port = 1714): PromiseInterface * @throws \RuntimeException If the command execution fails or no processes are found. * * Notes: - * - On Windows, it uses `netstat` to find processes and `taskkill` to terminate them. + * - Windows and portable profiles reject port-based process termination. * - On Unix-based systems, it uses `lsof` to find processes and `kill` to terminate them. * - Requires appropriate permissions to execute system commands. * - Logs the termination of each process using `error_log`. */ public static function killProcessOnPort(int $port): void { - if (PHP_OS_FAMILY == 'Windows') { - exec("netstat -ano | findstr :$port", $output); - foreach ($output as $line) { - if (preg_match('/\s+(\d+)$/', $line, $matches)) { - $pid = $matches[1]; - exec("taskkill /F /PID $pid"); - error_log("Terminated process with PID $pid on port $port"); - } - } - - return; + if (!self::legacyShellAvailable()) { + throw new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode'); } exec("lsof -i :$port -t", $output); foreach ($output as $pid) { diff --git a/src/Civ13/Panels.php b/src/Civ13/Panels.php index f9780b03..cef5d2b8 100644 --- a/src/Civ13/Panels.php +++ b/src/Civ13/Panels.php @@ -158,10 +158,12 @@ private function staffPanel(): MessageBuilder }, 'Log cleanup queued.')), $this->staffButton(Button::primary(), '↻ Refresh', 'sp_refresh', fn (Interaction $i) => $i->updateMessage($this->staffPanel())), ]); - $components[] = ActionRow::new()->addComponents([ - $this->staffButton(Button::secondary(), '⬇️ Git Pull', 'sp_pull', fn (Interaction $i) => $this->bg($i, fn () => OSFunctions::execInBackground('git pull'), 'Pulling latest code…')), - $this->staffButton(Button::secondary(), '📦 Composer Update', 'sp_composer', fn (Interaction $i) => $this->bg($i, fn () => OSFunctions::execInBackground('composer update'), 'Updating dependencies…')), - ]); + if (OSFunctions::legacyShellAvailable()) { + $components[] = ActionRow::new()->addComponents([ + $this->staffButton(Button::secondary(), '⬇️ Git Pull', 'sp_pull', fn (Interaction $i) => $this->bg($i, fn () => OSFunctions::execInBackground('git pull'), 'Pulling latest code…')), + $this->staffButton(Button::secondary(), '📦 Composer Update', 'sp_composer', fn (Interaction $i) => $this->bg($i, fn () => OSFunctions::execInBackground('composer update'), 'Updating dependencies…')), + ]); + } $components[] = Separator::new(); $components[] = TextDisplay::new('-# Destructive actions run immediately. Every button re-checks your permission.'); diff --git a/src/Civ13/Slash.php b/src/Civ13/Slash.php index 99583259..c8a49392 100644 --- a/src/Civ13/Slash.php +++ b/src/Civ13/Slash.php @@ -398,7 +398,11 @@ private function __updateCommands(): void /** Freshens the Civ13 guild command list and (re)declares the guild-scoped interaction commands. */ private function __updateGuildCommands(): void { - $this->discord->guilds->get('id', $this->civ13->civ13_guild_id)->commands->freshen()->then(function (GuildCommandRepository $commands): void { + if (!$guild = $this->discord->guilds->get('id', $this->civ13->civ13_guild_id)) { + $this->logger->warning('Configured guild unavailable; guild command registration skipped'); + return; + } + $guild->commands->freshen()->then(function (GuildCommandRepository $commands): void { if ($names = array_map(fn (Command $command) => $command->name, iterator_to_array($commands))) { $this->logger->debug(sprintf('[GUILD APPLICATION COMMAND LIST] `%s`', implode('`, `', $names))); } @@ -566,6 +570,9 @@ private function __updateGuildCommands(): void private function __declareListeners(): void { $this->discord->listenCommand('pull', function (Interaction $interaction): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $this->respondWithMessage($interaction, 'This maintenance command is unavailable in Windows/portable mode.', true); + } $this->logger->info('[GIT PULL]'); OSFunctions::execInBackground('git pull'); $this->civ13->loop->addTimer(5, function () { @@ -585,6 +592,9 @@ private function __declareListeners(): void }); $this->discord->listenCommand('update', function (Interaction $interaction): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $this->respondWithMessage($interaction, 'This maintenance command is unavailable in Windows/portable mode.', true); + } $this->logger->info('[COMPOSER UPDATE]'); OSFunctions::execInBackground('composer update'); diff --git a/src/Civ13/Verifier.php b/src/Civ13/Verifier.php index aac6c524..f68509f3 100644 --- a/src/Civ13/Verifier.php +++ b/src/Civ13/Verifier.php @@ -244,7 +244,7 @@ public function setup() /** Renames the `verifier-status` channel to `{name}-online` / `{name}-offline` to match the current verifier state, announcing the change. */ public function verifierStatusChannelUpdate(bool $status): ?PromiseInterface { - if (! $channel = $this->civ13->discord->getChannel($this->civ13->channel_ids['verifier-status'])) { + if (!isset($this->civ13->channel_ids['verifier-status']) || !($channel = $this->civ13->discord->getChannel($this->civ13->channel_ids['verifier-status']))) { return null; } [$verifier_name, $reported_status] = explode('-', $channel->name); diff --git a/tests/windows/ready.php b/tests/windows/ready.php new file mode 100644 index 00000000..276d44c9 --- /dev/null +++ b/tests/windows/ready.php @@ -0,0 +1,221 @@ +addPsr4('Civ13\\', __DIR__.'/../../src/Civ13/', true); + $loader->addPsr4('Civ14\\', __DIR__.'/../../src/Civ14/', true); + + final class ReadyConnector implements React\Socket\ConnectorInterface + { + public function connect($uri) { throw new RuntimeException('Offline test attempted a connection: '.$uri); } + } + final class ReadyInteraction extends Discord\Parts\Interactions\Interaction + { + public array $responses = []; + public function __construct(Discord\Discord $discord, array $attributes = [], bool $created = false) {} + public function respondWithMessage(Discord\Builders\MessageBuilder|string $builder, bool $ephemeral = false): React\Promise\PromiseInterface { + $this->responses[] = [is_string($builder) ? $builder : $builder->getContent(), $ephemeral]; + return React\Promise\resolve(null); + } + } + final class ReadyLoop implements React\EventLoop\LoopInterface + { + public array $timers = []; + public function addReadStream($stream, $listener) { throw new RuntimeException('Unexpected read stream'); } + public function addWriteStream($stream, $listener) { throw new RuntimeException('Unexpected write stream'); } + public function removeReadStream($stream) {} + public function removeWriteStream($stream) {} + public function addTimer($interval, $callback) { + $timer = new React\EventLoop\Timer\Timer($interval, $callback, false); + $this->timers[spl_object_id($timer)] = $timer; + return $timer; + } + public function addPeriodicTimer($interval, $callback) { + $timer = new React\EventLoop\Timer\Timer($interval, $callback, true); + $this->timers[spl_object_id($timer)] = $timer; + return $timer; + } + public function cancelTimer(React\EventLoop\TimerInterface $timer) { unset($this->timers[spl_object_id($timer)]); } + public function futureTick($listener) { throw new RuntimeException('Unexpected asynchronous IO'); } + public function addSignal($signal, $listener) { throw new RuntimeException('Unexpected signal'); } + public function removeSignal($signal, $listener) {} + public function run() { throw new RuntimeException('The offline loop must not run'); } + public function stop() {} + public function fireBootstrap(): void { + $matching = array_filter($this->timers, static fn ($timer) => !$timer->isPeriodic() && $timer->getInterval() === 5.0); + check(count($matching) === 1, 'Expected exactly one delayed bootstrap timer'); + $timer = reset($matching); + $this->cancelTimer($timer); + ($timer->getCallback())($timer); + } + } + + trait ReadyCommands + { + public array $saved = []; + public int $freshens = 0; + public function __construct($discord = null, array $vars = []) {} + public function freshen(array $queryparams = []): React\Promise\PromiseInterface { + ++$this->freshens; + return React\Promise\resolve($this); + } + public function save(Discord\Parts\Part $part, ?string $reason = null): React\Promise\PromiseInterface { + $this->saved[$part->name] = $part; + return React\Promise\resolve($part); + } + public function get(string $discrim, $key) { return $discrim === 'name' ? ($this->saved[$key] ?? null) : null; } + public function &getIterator(): Traversable { + foreach ($this->saved as $key => &$part) { yield $key => $part; } + } + public function delete($part, ?string $reason = null): React\Promise\PromiseInterface { + unset($this->saved[$part->name]); + return React\Promise\resolve($part); + } + } + final class ReadyGlobalCommands extends Discord\Repository\Interaction\GlobalCommandRepository { use ReadyCommands; } + final class ReadyGuildCommands extends Discord\Repository\Guild\GuildCommandRepository { use ReadyCommands; } + final class ReadyDiscord extends Discord\Discord + { + public ReadyGlobalCommands $globalCommands; + public ReadyGuildCommands $guildCommands; + private array $offline; + public int $channelLookups = 0; + public function __construct(private ReadyLoop $offlineLoop) { + $this->globalCommands = new ReadyGlobalCommands(); + $this->guildCommands = new ReadyGuildCommands(); + $guild = (object) ['id' => '100000000000000001', 'commands' => $this->guildCommands, 'members' => []]; + $this->offline = [ + 'application' => (object) ['commands' => $this->globalCommands], + 'guilds' => new Discord\Helpers\Collection([$guild], 'id'), + 'username' => 'offline-ready', 'discriminator' => '0000', 'user' => 'offline-user', + ]; + // No Discord constructor, credentials, HTTP transport or gateway connection. + $this->http = (new ReflectionClass(Discord\Http\Http::class))->newInstanceWithoutConstructor(); + $this->factory = new Discord\Factory\Factory($this); + } + public function __get(string $name) { return $this->offline[$name] ?? parent::__get($name); } + public function getLoop(): React\EventLoop\LoopInterface { return $this->offlineLoop; } + public function getChannel($channel_id): ?Discord\Parts\Channel\Channel { ++$this->channelLookups; return null; } + public function commands(): array { return $this->application_commands ?? []; } + } + function check(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } + + $unconfigured = ($argv[2] ?? null) === '--unconfigured'; + $dir = sys_get_temp_dir().'/portable-ready-'.bin2hex(random_bytes(8)); + mkdir($dir); + $previous = getcwd(); + chdir($dir); + $blockedWrappers = []; + foreach (['http', 'https'] as $scheme) { + if (in_array($scheme, stream_get_wrappers(), true)) { stream_wrapper_unregister($scheme); $blockedWrappers[] = $scheme; } + } + $oldProfile = getenv('CIVILIZATIONBOT_PROFILE'); + putenv('CIVILIZATIONBOT_PROFILE=offline-ready-test'); + $rejections = []; + React\Promise\set_rejection_handler(static function (Throwable $error) use (&$rejections): void { $rejections[] = $error; }); + set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { + if ($severity === E_WARNING) { throw new ErrorException($message, 0, $severity, $file, $line); } + return false; + }); + try { + $loop = new ReadyLoop(); + $discord = new ReadyDiscord($loop); + $logger = new Monolog\Logger('offline-ready', [new Monolog\Handler\NullHandler()]); + $initCalls = $slashCalls = 0; + $sample = json_decode(file_get_contents(__DIR__.'/../../config/examples/windows-test1714.json'), true, 16, JSON_THROW_ON_ERROR); + $options = array_merge([ + 'discord' => $discord, 'loop' => $loop, 'logger' => $logger, + 'stats' => (new ReflectionClass(Discord\Stats::class))->newInstanceWithoutConstructor(), + 'browser' => new React\Http\Browser(new ReadyConnector()), + 'filesystem' => new React\Filesystem\Fallback\Adapter(), 'verify_url' => '', + 'functions' => [ + 'init' => [static function ($bot) use (&$initCalls): void { check($bot->ready, 'Init callback ran before readiness'); ++$initCalls; }], + 'ready_slash' => [static function ($slash, $commands) use (&$slashCalls): void { ++$slashCalls; }], + ], + 'channel_ids' => ['verifier-status' => '100000000000000002'], 'role_ids' => [], + ], Civ13\PortableProfile::runtimeOptions(['options' => $sample['options'], 'gitdir' => $dir, 'state_dir' => $dir])); + $options['channel_ids'] = ['verifier-status' => '100000000000000002']; + $options['civ13_guild_id'] = '100000000000000001'; + $options['owner_id'] = '100000000000000003'; + $options['technician_id'] = '100000000000000004'; + if ($unconfigured) { + $options['channel_ids'] = []; + $options['civ13_guild_id'] = $options['owner_id'] = $options['technician_id'] = ''; + } + $bot = new Civ13\Civ13($options, [], [], null); + check(!$bot->ready && !isset($bot->slash, $bot->panels), 'Bootstrap ran before init'); + $discord->emit('init', [$discord]); + check($bot->ready && $bot->verifier->ready && $bot->moderator->ready && $initCalls === 1, 'Real init path did not finish'); + check(!isset($bot->slash, $bot->panels), 'Delayed bootstrap ran early'); + $loop->fireBootstrap(); + check($bot->slash instanceof Civ13\Slash && $bot->panels instanceof Civ13\Panels, 'Delayed Slash/Panels bootstrap did not finish'); + check($slashCalls === 1 && $discord->globalCommands->freshens === 2 && $discord->guildCommands->freshens === ($unconfigured ? 0 : 1), 'Command repositories were not refreshed through real setup'); + foreach (['ping', 'help', 'staffpanel', 'playerpanel'] as $name) { + check(isset($discord->globalCommands->saved[$name], $discord->commands()[$name]), 'Missing global command or listener: '.$name); + } + foreach (['unban', 'parole', 'permit', 'revoke'] as $name) { + check(isset($discord->guildCommands->saved[$name]) === !$unconfigured, 'Missing guild command: '.$name); + } + check($bot->ss14verifier === null && !isset($discord->commands()['ss14']), 'Unavailable SS14 service registered'); + check(str_contains($bot->embed_footer, 'offline-ready#0000'), 'Ready Discord variables were not initialized'); + check(count($discord->listeners('GUILD_CREATE')) > 0, 'Real guild listener was not installed'); + foreach (['pull', 'update'] as $name) { + $interaction = new ReadyInteraction($discord); + $timerCount = count($loop->timers); + $callback = (new ReflectionProperty(Discord\Helpers\RegisteredCommand::class, 'callback'))->getValue($discord->commands()[$name]); + $callback($interaction); + check(count($interaction->responses) === 1 && str_contains($interaction->responses[0][0], 'unavailable') && $interaction->responses[0][1], 'Maintenance command did not fail closed: '.$name); + check(count($loop->timers) === $timerCount, 'Maintenance command scheduled background work: '.$name); + } + (new ReflectionMethod(Civ13\HttpServiceManager::class, '__generateEndpoints'))->invoke($bot->httpServiceManager); + // Synthetic local sentinel only: no environment file is read or loaded. + $sentinel = "OFFLINE_TEST_SENTINEL=keep-me\n"; + file_put_contents($dir.'/.env', $sentinel); + $bot->timers['restart_pending'] = $loop->addTimer(99, static function (): void { throw new RuntimeException('Unexpected restart'); }); + foreach (['/reset', '/githubupdated', '/pull', '/update', '/restart', '/kill', '/clearenv', '/cancelupdaterestart'] as $endpoint) { + $timersBefore = $loop->timers; + $botTimersBefore = $bot->timers; + $lookupsBefore = $discord->channelLookups; + $handler = $bot->httpServiceManager->httpHandler->offsetGet($endpoint, 'handlers'); + check(is_callable($handler), 'Missing maintenance endpoint: '.$endpoint); + $response = $handler(new React\Http\Message\ServerRequest('POST', 'http://127.0.0.1'.$endpoint), $endpoint, true); + check($response->getStatusCode() === 503 && str_contains((string) $response->getBody(), 'unavailable'), 'Maintenance endpoint reported success: '.$endpoint); + check($loop->timers === $timersBefore && $bot->timers === $botTimersBefore, 'Maintenance endpoint changed timers: '.$endpoint); + check($discord->channelLookups === $lookupsBefore, 'Maintenance endpoint attempted Discord notification: '.$endpoint); + check(file_get_contents($dir.'/.env') === $sentinel, 'Maintenance endpoint changed synthetic environment file: '.$endpoint); + } + check(!$rejections, 'Bootstrap promise rejected: '.($rejections ? (string) $rejections[0] : '')); + echo ($unconfigured ? "UNCONFIGURED " : "CONFIGURED ")."PASS real init and delayed Slash/Panels bootstrap; global/guild commands and listeners; eight HTTP maintenance guards; no network or loop run\n"; + } finally { + putenv($oldProfile === false ? 'CIVILIZATIONBOT_PROFILE' : 'CIVILIZATIONBOT_PROFILE='.$oldProfile); + restore_error_handler(); + React\Promise\set_rejection_handler(null); + foreach ($blockedWrappers as $scheme) { stream_wrapper_restore($scheme); } + chdir($previous); + $remove = function (string $path) use (&$remove): void { + foreach (new DirectoryIterator($path) as $entry) { + if ($entry->isDot()) { continue; } + $entry->isDir() ? $remove($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($path); + }; + $remove($dir); + } +} diff --git a/tests/windows/shell-guard.php b/tests/windows/shell-guard.php new file mode 100644 index 00000000..bf19f4ba --- /dev/null +++ b/tests/windows/shell-guard.php @@ -0,0 +1,52 @@ +replies[] = $message; return React\Promise\resolve(null); } + public function react($emoticon): React\Promise\PromiseInterface { throw new LogicException('Maintenance reported success with a reaction'); } + } + $previous = getenv('CIVILIZATIONBOT_PROFILE'); + $expected = 'Legacy shell operations are disabled in Windows/portable mode'; + try { + // Native Windows is protected even without a profile; Unix default remains enabled. + putenv('CIVILIZATIONBOT_PROFILE'); + if (OSFunctions::legacyShellAvailable() !== (PHP_OS_FAMILY !== 'Windows')) { throw new RuntimeException('Default platform policy changed'); } + foreach (PHP_OS_FAMILY === 'Windows' ? ['', 'synthetic-profile'] : ['synthetic-profile'] as $profile) { + putenv('CIVILIZATIONBOT_PROFILE='.$profile); + foreach (['spawnChildProcess', 'execInBackground', 'restart'] as $method) { + $rejection = null; + OSFunctions::$method('never execute this')->then( + static function (): void { throw new LogicException('Legacy operation unexpectedly resolved'); }, + static function (Throwable $error) use (&$rejection): void { $rejection = $error; } + ); + if (!$rejection instanceof RuntimeException || $rejection->getMessage() !== $expected) { throw new RuntimeException('Missing legacy promise rejection: '.$method); } + } + $bot = (new ReflectionClass(Civ13\Civ13::class))->newInstanceWithoutConstructor(); + foreach (['UpdateDependencies', 'PullCivRepository', 'TypeSpess', 'Civ14Host'] as $name) { + $class = 'Civ13\\MessageCommand\\Commands\\'.$name; + $message = new OfflineMaintenanceMessage(); + (new $class($bot))($message, 'fixture', []); + if ($message->replies !== ['This maintenance command is unavailable in Windows/portable mode.']) { throw new RuntimeException('Maintenance did not report unavailable: '.$name); } + } + foreach ([['killProcessOnPort', 1714], ['disown', null]] as [$method, $argument]) { + try { OSFunctions::$method($argument); throw new LogicException('Legacy operation unexpectedly returned'); } + catch (RuntimeException $error) { if ($error->getMessage() !== $expected) { throw $error; } } + } + } + echo "PASS Windows/portable shell guards reject before spawn, restart, process inspection or port-based termination\n"; + } finally { + $previous === false ? putenv('CIVILIZATIONBOT_PROFILE') : putenv('CIVILIZATIONBOT_PROFILE='.$previous); + } +} From ce902c60949b22d8f6fb0ebd1cb0150b821f184a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 20:10:54 -0400 Subject: [PATCH 9/9] Skip verifier transport entirely when portable authority is disabled --- .github/workflows/portable.yml | 1 + src/Civ13/Verifier.php | 3 +++ tests/windows/ready.php | 7 ++----- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index b0f6e9aa..a9d6d277 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -3,6 +3,7 @@ on: [push, pull_request] jobs: offline: strategy: + fail-fast: false matrix: os: [windows-latest, ubuntu-latest] php: ['8.3', '8.4', '8.5'] diff --git a/src/Civ13/Verifier.php b/src/Civ13/Verifier.php index f68509f3..2d86ee71 100644 --- a/src/Civ13/Verifier.php +++ b/src/Civ13/Verifier.php @@ -970,6 +970,9 @@ public function fetchVerifiedData(): array ]; } + if ($this->verify_url === '') { + return ['json' => '', 'http_status' => 0]; + } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $this->verify_url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); diff --git a/tests/windows/ready.php b/tests/windows/ready.php index 276d44c9..bf872bc6 100644 --- a/tests/windows/ready.php +++ b/tests/windows/ready.php @@ -7,12 +7,9 @@ function proc_open(...$arguments): never { throw new \RuntimeException('Unexpect function exec(...$arguments): never { throw new \RuntimeException('Unexpected shell execution'); } function shell_exec(...$arguments): never { throw new \RuntimeException('Unexpected shell execution'); } - function curl_exec($handle): false + function curl_exec($handle): never { - if (\curl_getinfo($handle, CURLINFO_EFFECTIVE_URL) !== '') { - throw new \RuntimeException('Offline ready test attempted a verifier request'); - } - return false; + throw new \RuntimeException('Offline ready test attempted a verifier request'); } }