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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/examples/windows-native-lifecycle.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"enabled": false,
"target_type": "prepared",
"supervisor_root": "D:/Civ13-local/TEST1714-supervisor",
"supervisor_sha256": "REPLACE_WITH_REVIEWED_SUPERVISOR_SHA256",
"guard_sha256": "REPLACE_WITH_REVIEWED_MASTER_GUARD_SHA256",
Expand Down
61 changes: 55 additions & 6 deletions docs/windows-native-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
This adds a bot-side adapter and a versioned supervisor spool contract. It does
**not** add a game supervisor consumer, upgrade an installed supervisor, install
dependencies, provision credentials, launch a game or remove a halt latch.
The installed Desktop supervisor does not implement this protocol. Keep the
adapter disabled until a separately reviewed compatible consumer exists.
The installed Desktop supervisor does not implement this protocol. This v2
source contract is incompatible with v1 consumers. Keep the adapter disabled
until a separately reviewed compatible consumer exists and actual local access
and process ownership have been verified.

The native bot entry points retain their default rejection. An explicitly enabled
`servers.<key>.native_lifecycle` object in a portable profile allows `Host` to
Expand Down Expand Up @@ -45,11 +47,41 @@ Activation requires all of the following:
existing resolved paths are readonly.
- Supervisor config fields `root`, `repository`, `branch: "master"`,
`bind: "127.0.0.1"`, `port: 1714`, the exact `map` and `daemon`, and
`bot_control_protocol: "civilizationbot.native-supervisor.v1"`.
`bot_control_protocol: "civilizationbot.native-supervisor.v2"`.

The default `target_type: "prepared"` keeps the existing run-directory rule.
The separately proposed `target_type: "canonical"` binds `basedir` and `gitdir`
to the same exact canonical checkout and the supervisor root to its fixed
`data/desktop-supervisor` directory. The pinned supervisor config must explicitly
enable both `canonical_bot_enabled` and `canonical_update_enabled`, with its
`canonical_game_directory` and `config_directory` matching that checkout. Both
target types remain disabled by default. A separately reviewed consumer pins
its canonical backend, and its current spool-writer guard denies activation.
For initial start, the consumer additionally requires an explicit initial-start
flag and the exact existing saved generation; it never invents a fresh save.
Requests do not select or change the target type or path.

For review only, the canonical selection inside an otherwise complete portable
server entry has this disabled shape; the normal example remains `prepared`:

```json
{
"enabled": false,
"target_type": "canonical",
"supervisor_root": "D:/Civ13-example/master/data/desktop-supervisor"
}
```

It still needs all source, config, daemon, revision, map, save and binary pins
from the full example. The server's `basedir` and portable `gitdir` must both
name `D:/Civ13-example/master`; placeholders do not authorize activation.

Changing a pinned source/config/build requires explicit profile review and bot
restart. The adapter never updates a checkout, compiles, selects an old save,
substitutes a fresh round or silently relaxes save validation.
Canonical v2 restart retains the pinned binaries; it is not a canonical update.
A separate canonical update first blocks the old capability, and changed
revision, binaries or saved generation require reconciliation and repinning.

## Supervisor consumer contract

Expand All @@ -66,7 +98,7 @@ publishes `capability.json` atomically with:

```json
{
"protocol": "civilizationbot.native-supervisor.v1",
"protocol": "civilizationbot.native-supervisor.v2",
"binding_sha256": "64 lowercase hex characters",
"heartbeat_unix": 0,
"epoch": "32 lowercase hex characters",
Expand All @@ -89,8 +121,21 @@ not a port label, filename, stale PID or intended command line.
The adapter briefly holds a nonblocking exclusive `adapter.lock`, persists `pending.json`,
then publishes `request.json` using an exclusive hard link to a flushed temporary
file. A filesystem without the required hard-link/fsync behavior fails closed.
After durable writes, the producer rechecks capability identity, freshness and
request expiry immediately before attempting the link. If this check fails,
the pending and temporary records remain for reconciliation. A blocking link or
clock change can still occur after that check; the consumer independently checks
expiry at admission and before the first native effect.
The packet contains only protocol, random operation ID, fixed action,
epoch/sequence, binding hash, complete binding, and the observed `before` process.
epoch/sequence, binding hash, complete binding, the observed `before` process,
and integer `issued_unix` and `expires_unix` seconds. The lifetime is five
minutes; this is an admission bound, not a deadline for completing a save or
restart. The consumer checks age before acceptance and again at the first
native effect, allowing at most two seconds future clock skew. Once an effect
starts, expiration must not interrupt its save/restart transaction. Both sides
require a sufficiently accurate, non-rolled-back system clock; an authorized
spool writer can forge timestamps. A delayed verification may expire a request
and leave accepted evidence requiring reconciliation.
An existing request/pending record or consumed epoch/sequence refuses submission.
The publication lock is released before waiting; completion reacquires it only
for its short verification/acknowledgement transition. A separate process can
Expand All @@ -115,7 +160,7 @@ On accepting an operation the consumer must:
reconcile durable pending/result/state rather than replay a request.

Completion results must echo protocol, operation, action, epoch, sequence,
binding hash and exact `before`; set `outcome: "complete"`,
binding hash, exact `before`, `issued_unix` and `expires_unix`; set `outcome: "complete"`,
`master_verified: true`, `ready` (false for save-stop, true otherwise), and
`after` (null only for save-stop). A running `after` uses the process schema
above. Restart must establish a different creation identity. Save-stop/restart
Expand All @@ -135,6 +180,10 @@ completion retains the result plus a consumed epoch/sequence marker before
clearing only the exact pending record. No automatic retry or recovery clearing
is provided; an operator must reconcile evidence before changing those records.
Longer game operations can finish independently after the bot timeout.
Before migration, stop submitting v1 requests and reconcile surviving v1
pending/request/inflight and receipts without replay or deletion. Update and
review both pinned producer and consumer together. V1 records are never
reinterpreted with newly invented timestamps.
If save-stop/restart advances the generation, a durable marker blocks subsequent
operations using the original binding. The operator must review the newly saved
generation, update the explicit profile and supervisor capability binding, and
Expand Down
60 changes: 53 additions & 7 deletions src/Civ13/NativeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,36 @@
/** Fixed requests to an independently owned supervisor. Never executes or kills a process. */
final class NativeLifecycle
{
public const PROTOCOL = 'civilizationbot.native-supervisor.v1';
public const PROTOCOL = 'civilizationbot.native-supervisor.v2';
public const REQUEST_LIFETIME_SECONDS = 300;
private array $binding;
private array $pins;
private string $directory;
private string $bindingHash;

public function __construct(private array $settings, array $server, string $repository, private LoopInterface $loop, private $clock = null)
public function __construct(private array $settings, array $server, string $repository, private LoopInterface $loop, private $clock = null, private $wallClock = null)
{
if (($settings['enabled'] ?? false) !== true) {
throw new \LogicException('Native lifecycle adapter is disabled');
}
$this->clock ??= static fn () => hrtime(true);
$this->wallClock ??= static fn () => time();
if (($server['enabled'] ?? null) !== true || ($server['ip'] ?? null) !== '127.0.0.1' || (string) ($server['port'] ?? '') !== '1714') {
throw new \InvalidArgumentException('Native lifecycle requires loopback TEST1714');
}
$root = self::directory($settings['supervisor_root'] ?? '');
$runtime = self::directory($server['basedir'] ?? '');
$repository = self::directory($repository);
if (!str_starts_with($runtime, $root.DIRECTORY_SEPARATOR.'runs'.DIRECTORY_SEPARATOR)) {
$targetType = $settings['target_type'] ?? 'prepared';
if ($targetType === 'prepared' && !str_starts_with($runtime, $root.DIRECTORY_SEPARATOR.'runs'.DIRECTORY_SEPARATOR)) {
throw new \InvalidArgumentException('Explicit supervisor-owned run directory required');
}
if ($targetType === 'canonical' && ($runtime !== $repository || $root !== self::directory($repository.'/data/desktop-supervisor'))) {
throw new \InvalidArgumentException('Explicit canonical target does not match its fixed root');
}
if (!in_array($targetType, ['prepared', 'canonical'], true)) {
throw new \InvalidArgumentException('Unsupported native target type');
}
$this->directory = self::directory($root.'/bot-control');
$expected = $settings['expected'] ?? [];
if (!is_array($expected) || array_keys($expected) !== ['revision', 'map', 'generation', 'dmb_sha256', 'rsc_sha256']) {
Expand Down Expand Up @@ -61,6 +70,7 @@ public function __construct(private array $settings, array $server, string $repo
'supervisor_sha256' => $settings['supervisor_sha256'],
'guard_sha256' => $settings['guard_sha256'], 'config_sha256' => $settings['config_sha256'],
];
if ($targetType === 'canonical') { $this->binding['target_type'] = 'canonical'; }
$this->bindingHash = hash('sha256', json_encode($this->binding, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
$this->verifyPins();
$config = self::read($root.'/supervisor.json');
Expand All @@ -70,6 +80,16 @@ public function __construct(private array $settings, array $server, string $repo
|| self::file($config['daemon'] ?? '') !== $daemon || ($config['bot_control_protocol'] ?? null) !== self::PROTOCOL) {
throw new \InvalidArgumentException('Supervisor does not implement the reviewed loopback native contract');
}
if ($targetType === 'canonical' && (($config['canonical_bot_enabled'] ?? false) !== true
|| ($config['canonical_update_enabled'] ?? false) !== true
|| self::directory($config['canonical_game_directory'] ?? '') !== $repository
|| self::directory($config['config_directory'] ?? '') !== self::directory($repository.'/config'))) {
throw new \InvalidArgumentException('Explicit canonical bot target is disabled or mismatched');
}
if ($targetType === 'prepared' && (($config['canonical_game_directory'] ?? null) !== null
|| ($config['canonical_bot_enabled'] ?? false) === true)) {
throw new \InvalidArgumentException('Canonical configuration cannot use the prepared backend');
}
}

/** Public protocol description for the separately reviewed supervisor; contains no credentials. */
Expand All @@ -90,9 +110,10 @@ public function inspect(): array
throw new \RuntimeException('Saved generation advanced; reconcile the explicit profile before another operation');
}
$capability = self::read($this->directory.'/capability.json');
$now = $this->wallTime();
if (($capability['protocol'] ?? null) !== self::PROTOCOL || ($capability['binding_sha256'] ?? null) !== $this->bindingHash
|| !is_int($capability['heartbeat_unix'] ?? null) || $capability['heartbeat_unix'] < time() - 10
|| $capability['heartbeat_unix'] > time() + 2
|| !is_int($capability['heartbeat_unix'] ?? null) || $capability['heartbeat_unix'] < $now - 10
|| $capability['heartbeat_unix'] > $now + 2
|| !is_int($capability['sequence'] ?? null) || $capability['sequence'] < 0
|| !is_string($capability['epoch'] ?? null) || !preg_match('/^[a-f0-9]{32}$/D', $capability['epoch'])
|| ($capability['master_verified'] ?? null) !== true || ($capability['busy'] ?? null) !== false) {
Expand Down Expand Up @@ -129,12 +150,28 @@ public function request(string $action): PromiseInterface
// Recheck after serialization, before creating any request.
$current = $this->inspect();
if ($current['epoch'] !== $capability['epoch'] || $current['sequence'] !== $capability['sequence'] || $current['process'] !== $capability['process']) { throw new \RuntimeException('Supervisor epoch or owned process changed'); }
$issued = $this->wallTime();
if ($current['heartbeat_unix'] < $issued - 10 || $current['heartbeat_unix'] > $issued + 2) {
throw new \RuntimeException('Supervisor capability expired before publication');
}
$packet = ['protocol' => self::PROTOCOL, 'operation' => $operation, 'action' => $action,
'epoch' => $capability['epoch'], 'sequence' => $capability['sequence'], 'binding_sha256' => $this->bindingHash,
'binding' => $this->binding, 'before' => $capability['process']];
'binding' => $this->binding, 'before' => $capability['process'],
'issued_unix' => $issued, 'expires_unix' => $issued + self::REQUEST_LIFETIME_SECONDS];
$bytes = json_encode($packet, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
self::create($pending, $bytes);
self::create($temporary, $bytes);
// Durable writes may block. Recheck as close to publication as
// possible; on failure keep both records for reconciliation.
$current = $this->inspect();
if ($current['epoch'] !== $capability['epoch'] || $current['sequence'] !== $capability['sequence'] || $current['process'] !== $capability['process']) {
throw new \RuntimeException('Supervisor epoch or owned process changed before publication');
}
$publicationTime = $this->wallTime();
if ($current['heartbeat_unix'] < $publicationTime - 10 || $current['heartbeat_unix'] > $publicationTime + 2
|| $issued > $publicationTime + 2 || $publicationTime > $packet['expires_unix']) {
throw new \RuntimeException('Native request expired before publication');
}
// Hard-link publication is exclusive: it cannot replace a concurrent request.
if (!link($temporary, $request)) { throw new \RuntimeException('Native request publication uncertain; inspect pending operation'); }
unlink($temporary);
Expand Down Expand Up @@ -198,7 +235,7 @@ private function lock(): mixed

private function result(array $result, array $packet): array
{
foreach (['protocol', 'operation', 'action', 'epoch', 'sequence', 'binding_sha256', 'before'] as $key) {
foreach (['protocol', 'operation', 'action', 'epoch', 'sequence', 'binding_sha256', 'before', 'issued_unix', 'expires_unix'] as $key) {
if (!array_key_exists($key, $result) || $result[$key] !== $packet[$key]) { throw new \UnexpectedValueException('Unbound native supervisor result'); }
}
if (($result['outcome'] ?? null) !== 'complete' || ($result['master_verified'] ?? null) !== true
Expand All @@ -225,6 +262,15 @@ private function result(array $result, array $packet): array
return $result;
}

private function wallTime(): int
{
$now = ($this->wallClock)();
if (!is_int($now) || $now < 0 || $now > PHP_INT_MAX - self::REQUEST_LIFETIME_SECONDS) {
throw new \RuntimeException('Invalid native request clock');
}
return $now;
}

private function process(mixed $process): void
{
if ($process === null) { return; }
Expand Down
Loading
Loading