From 58cd5a35e0dad36e389fd0ae843e9eca4d9b4f3d Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 11 Sep 2026 01:28:13 -0400 Subject: [PATCH 1/2] feat(installer): agent install + connect handshake (TIGER-89/90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TIGER-89 asked for machine-readable outcomes; TIGER-90 asked how a fresh install hands a client a credential. Both are the same question — how does a non-human read this screen — so they get ONE answer rather than two mechanisms. Machine-readable state (TIGER-89) Every screen now carries +``` + +| Field | Meaning | +|---|---| +| `installer` | installer version | +| `step` | `requirements` · `location` · `download` · `database` · `admin` · `finish` · `expired` | +| `status` | `awaiting-input` · `blocked` · `error` · `ok` | +| `next_step` | the `step` value to post next, when the screen is waiting on input | +| `fields` | the field names this screen expects | +| `error` / `detail` | a stable error slug plus the human message, when `status` is `error` | +| `checks` | requirements only: each check with `ok`, `required`, and a `fix` when failing | + +`status` alone answers "did that work?" — `blocked` means an unmet requirement the user must fix, +`error` means the step can be retried, `ok` appears only on `finish`. + +Retrying is safe and needs no re-upload; the file only deletes itself **after** the owner is created. +Every error path stops before that. + +### 2. The connect handshake — how a client gets a credential + +A fresh Tiger is deliberately unreachable by an agent: `/mcp` is off and a scoped token is normally +minted by an authenticated admin. The installer's finish step is the one moment a human is present, +authenticated, and making a deliberate choice — so that is where the credential is handed out. + +**Tick "Let the assistant that installed Tiger manage it"** on the admin step. The checkbox can be +pre-ticked with `?agent=1` on the installer URL, but it is always **visible before you submit and can +be turned off** — a seeded choice you can see and reverse, never a silent one. + +On success the finish screen shows the key once, and the state block carries it: + +```json +{ + "step": "finish", + "status": "ok", + "site": "https://example.com/", + "agent": { + "enabled": true, + "endpoint": "https://example.com/mcp", + "token": "tgr_…", + "manage": "https://example.com/mcp/admin", + "scope": { "modules": ["cms","blog","media","search","docs"], "org_scoped": true, "read_only": false } + } +} +``` + +If the box was not ticked, `agent.enabled` is `false` with `reason: "not_requested"` — degrade to +telling the user to enable it at `/mcp/admin` and reconnect, rather than failing. + +**There is no callback URL, and one must never be added.** The key is displayed on the installer's own +screen and nowhere else. A client that drove the install drove the browser — it filled in the database +and admin forms, so it can read the finish page. A callback would solve nothing while turning a shared +installer link into credential phishing: installer links travel by being shared, and `?callback=` would +let a stranger receive a token to a site someone else legitimately installed. That is why the enable +param is safe and a callback is not. + +The token is a normal scoped MCP credential: visible, revocable, and re-mintable at `/mcp/admin`, and +never more than the owner's own permissions allow. + ## Requirements Shared cPanel hosting with **PHP 8.1+** and the `pdo_mysql`, `zip`, `mbstring`, and diff --git a/tiger-install.php b/tiger-install.php index fc7ccf5..0290cc6 100644 --- a/tiger-install.php +++ b/tiger-install.php @@ -29,7 +29,7 @@ @ini_set('display_errors', '1'); @set_time_limit(0); -const INSTALLER_VERSION = '1.0.3'; +const INSTALLER_VERSION = '1.1.0'; const RELEASE_REPO = 'webtigers/tiger'; // the skeleton repo whose releases host the full-app bundle const MIN_PHP = '8.1.0'; const GH_API = 'https://api.github.com'; @@ -51,6 +51,9 @@ function h($s) { return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8'); } function post($k, $d = '') { return isset($_POST[$k]) ? trim((string) $_POST[$k]) : $d; } function req($k, $d = '') { return isset($_REQUEST[$k]) ? trim((string) $_REQUEST[$k]) : $d; } +/** Checkbox/flag truthiness — '1', 'true', 'yes', 'on' are on; everything else (incl. '0') is off. */ +function truthy($v) { return in_array(strtolower(trim((string) $v)), ['1', 'true', 'yes', 'on'], true); } + /** The per-visitor CSRF token (a same-site cookie; see the top of the file). */ function csrf_token() { return isset($GLOBALS['__csrf']) ? (string) $GLOBALS['__csrf'] : ''; @@ -299,7 +302,27 @@ function resolve_release($version = '') { * Rendering * ------------------------------------------------------------------------- */ -function page($title, $body) { +/** + * The machine-readable state block (TIGER-89/90). + * + * Every screen carries one, so a browser-aware client can tell where it is and whether the last action + * worked WITHOUT scraping prose — the acceptance bar in TIGER-89 ("determine success or the specific + * failure without human interpretation"). It is also how the client reads the agent credential minted at + * finish (TIGER-90), so there is ONE contract to learn rather than a separate mechanism per question. + * + * A can never appear inside a JSON string here, but belt-and-braces for embedded content. + $json = str_replace('<', '\u003C', (string) $json); + return ''; +} + +function page($title, $body, array $state = []) { $csrf = csrf_token(); echo '' . '' @@ -322,6 +345,7 @@ function page($title, $body) { . 'ol.steps{counter-reset:s;list-style:none;padding:0;display:flex;gap:8px;flex-wrap:wrap;margin:0 0 8px}ol.steps li{color:var(--mut);font-size:.8rem}' . 'ol.steps li.on{color:var(--brand);font-weight:700}' . '
' + . state_block($state) . '
🐾 Tiger Installer v' . INSTALLER_VERSION . '
' . $body . '

One file, nothing more. Downloads & verifies the latest Tiger release, installs it above your document root, then deletes itself.

' @@ -386,12 +410,17 @@ function db_form($bag, $errNote = '') { function admin_form($bag, $errNote = '') { return '

Create your admin account

' . ($errNote !== '' ? '
' . h($errNote) . '
' : '
Database installed and ready.
') - . '
' . hidden_bag($bag, ['org', 'email', 'username', 'password']) + . '' . hidden_bag($bag, ['org', 'email', 'username', 'password', 'agent']) . '
' . field('Organization name', 'org', 'text', $bag['org'], 'My Company') . field('Admin email', 'email', 'email', $bag['email']) . field('Username (optional)', 'username', 'text', $bag['username']) . field('Password (min 8)', 'password', 'password', $bag['password']) + . '' . '
' . nav_buttons('database', 'finish', $errNote !== '' ? 'Try again' : 'Finish install') . '
'; } @@ -572,17 +601,63 @@ function do_provision($bag) { } /** Create the founding org + admin. Returns '' or an error message. */ -function do_create_owner($bag) { +function do_create_owner($bag, &$owner = null) { try { ensure_booted($bag['app_dir']); $username = $bag['username'] !== '' ? $bag['username'] : null; - Tiger_Install::createOwner($bag['email'], $bag['password'], $bag['org'], null, 'developer', $username); + $owner = Tiger_Install::createOwner($bag['email'], $bag['password'], $bag['org'], null, 'developer', $username); } catch (Throwable $e) { return $e->getMessage(); } return ''; } +/** + * Mint the agent credential and switch `/mcp` on — TIGER-90, the connect handshake. + * + * Runs ONLY after do_create_owner() has succeeded: the token is scoped to the owner that was just + * created, so there is no ordering in which MCP is reachable before an admin exists to revoke it. + * + * Within that, mint BEFORE enabling. The ticket requires both to happen after the owner exists and + * warns that the reverse order can leave "MCP enabled on a site with no admin"; minting first also + * means a failure at the mint step leaves the site on today's defaults (MCP off) rather than on with + * no credential to show for it. The safe half-state is the one that grants nothing. + * + * Failure here NEVER fails the install. The site is already live and the owner already exists; all + * that is lost is the convenience, and the finish screen says so plainly. + * + * Scope: the curated starter set (Tiger_Mcp_Token::DEFAULT_MODULES), org-scoped, not read-only. + * `tiger.api.discovery` is deliberately left alone — publishing the OpenAPI document is a separate + * decision, and MCP's tools/list already gives the client its typed surface. + * + * @return array {ok: bool, token?: string, modules?: string[], error?: string} + */ +function do_enable_agent($bag, $owner) { + try { + ensure_booted($bag['app_dir']); + + $userId = is_array($owner) ? ($owner['user_id'] ?? null) : null; + $orgId = is_array($owner) ? ($owner['org_id'] ?? null) : null; + if ($userId === null) { return ['ok' => false, 'error' => 'No owner id was returned; agent access not enabled.']; } + + $cred = (new Tiger_Model_UserCredential())->createToken($userId); + Tiger_Mcp_Token::saveConfig($cred['credential_id'], [ + 'modules' => Tiger_Mcp_Token::DEFAULT_MODULES, + 'read_only' => false, + 'org_scoped' => true, + 'role' => 'developer', + 'org_id' => (string) $orgId, + ]); + + // Only now is there a credential to reach it with. + (new Tiger_Model_Config())->set(Tiger_Model_Config::SCOPE_GLOBAL, '', Tiger_Mcp::CONFIG_ENABLED, '1'); + + return ['ok' => true, 'token' => $cred['token'], 'modules' => Tiger_Mcp_Token::DEFAULT_MODULES]; + } catch (Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } +} + /* --------------------------------------------------------------------------- * Controller — a Back/Next wizard. Every field rides in a "bag" carried on every * request, so navigating Back never loses what you typed (passwords included). @@ -597,16 +672,30 @@ function do_create_owner($bag) { // The value bag — read every field each request; fill sensible defaults once. $bag = []; -foreach (['app_dir', 'docroot', 'db_host', 'db_name', 'db_user', 'db_pass', 'org', 'email', 'username', 'password'] as $f) { +foreach (['app_dir', 'docroot', 'db_host', 'db_name', 'db_user', 'db_pass', 'org', 'email', 'username', 'password', 'agent'] as $f) { $bag[$f] = post($f, ''); } +// `agent` may be SEEDED from the query string (?agent=1) but only on a GET. On a POST the visible +// checkbox is the only authority, so un-ticking it actually turns it off — an unchecked box submits +// nothing, which is exactly what makes the seeded choice reversible (TIGER-90). +// +// This is safe ONLY because there is no callback: the minted token is displayed on the installer's own +// screen and nowhere else, so a crafted ?agent=1 link gains its sender nothing — they do not see the +// screen, the person running the install does. Re-read TIGER-90 before adding any field that would +// send the credential somewhere. +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $bag['agent'] = truthy(req('agent', '')) ? '1' : ''; +} +$agentWanted = truthy($bag['agent']); if ($bag['docroot'] === '') { $bag['docroot'] = $docroot; } if ($bag['app_dir'] === '') { $bag['app_dir'] = $home . '/' . $domain . '/tiger-app'; } if ($bag['db_host'] === '') { $bag['db_host'] = 'localhost'; } // CSRF gate for every POST. if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_ok()) { - page('Session expired', '
This page expired. Start over.
'); + page('Session expired', '
This page expired. Start over.
', + ['installer' => INSTALLER_VERSION, 'step' => 'expired', 'status' => 'error', 'error' => 'csrf_expired', + 'detail' => 'The CSRF cookie did not match. Reload the installer and start again.']); exit; } @@ -633,7 +722,14 @@ function do_create_owner($bag) { $body .= $blocked ? '
Fix the FAIL items in cPanel, then reload this page.
' : '
' . hidden_bag($bag) . nav_buttons('', 'location', 'Continue') . '
'; - page('Requirements', $body); + page('Requirements', $body, [ + 'installer' => INSTALLER_VERSION, + 'step' => 'requirements', + 'status' => $blocked ? 'blocked' : 'awaiting-input', + 'next_step' => $blocked ? null : 'location', + 'checks' => array_map(static fn($c) => ['label' => $c['label'], 'ok' => (bool) $c['ok'], + 'required' => (bool) $c['hard'], 'fix' => $c['ok'] ? null : $c['fix']], $checks), + ]); break; /* --- Location ----------------------------------------------------------- */ @@ -653,33 +749,51 @@ function do_create_owner($bag) { . '
Running several domains on this account? Each gets its own folder like ' . '' . h($home) . '/<domain>/tiger-app and its own database — fully independent installs.
' . '
' . nav_buttons('welcome', 'database', 'Download & install') . ''; - page('Location', $body); + page('Location', $body, ['installer' => INSTALLER_VERSION, 'step' => 'location', 'status' => 'awaiting-input', + 'next_step' => 'download', 'fields' => ['app_dir', 'docroot'], 'app_dir' => $bag['app_dir'], 'docroot' => $bag['docroot']]); break; /* --- Database — download+extract on entry, then the DB form ------------- */ case 'database': $err = do_install_files($bag, $home); - if ($err !== '') { page('Download', steps_nav('download') . download_error($bag, $err)); break; } - page('Database', steps_nav('database') . db_form($bag)); + if ($err !== '') { page('Download', steps_nav('download') . download_error($bag, $err), + ['installer' => INSTALLER_VERSION, 'step' => 'download', 'status' => 'error', 'error' => 'download_failed', 'detail' => $err]); break; } + page('Database', steps_nav('database') . db_form($bag), + ['installer' => INSTALLER_VERSION, 'step' => 'database', 'status' => 'awaiting-input', 'next_step' => 'admin', + 'fields' => ['db_host', 'db_name', 'db_user', 'db_pass']]); break; /* --- Admin — provision the DB on entry, then the admin form ------------- */ case 'admin': $err = do_install_files($bag, $home); - if ($err !== '') { page('Download', steps_nav('download') . download_error($bag, $err)); break; } + if ($err !== '') { page('Download', steps_nav('download') . download_error($bag, $err), + ['installer' => INSTALLER_VERSION, 'step' => 'download', 'status' => 'error', 'error' => 'download_failed', 'detail' => $err]); break; } $err = do_provision($bag); - if ($err !== '') { page('Database', steps_nav('database') . db_form($bag, $err)); break; } - page('Admin', steps_nav('admin') . admin_form($bag)); + if ($err !== '') { page('Database', steps_nav('database') . db_form($bag, $err), + ['installer' => INSTALLER_VERSION, 'step' => 'database', 'status' => 'error', 'error' => 'database_failed', 'detail' => $err, + 'fields' => ['db_host', 'db_name', 'db_user', 'db_pass']]); break; } + page('Admin', steps_nav('admin') . admin_form($bag), + ['installer' => INSTALLER_VERSION, 'step' => 'admin', 'status' => 'awaiting-input', 'next_step' => 'finish', + 'fields' => ['org', 'email', 'username', 'password', 'agent'], 'agent_requested' => $agentWanted]); break; /* --- Finish — create the admin, self-delete ----------------------------- */ case 'finish': $err = do_install_files($bag, $home); - if ($err !== '') { page('Download', steps_nav('download') . download_error($bag, $err)); break; } + if ($err !== '') { page('Download', steps_nav('download') . download_error($bag, $err), + ['installer' => INSTALLER_VERSION, 'step' => 'download', 'status' => 'error', 'error' => 'download_failed', 'detail' => $err]); break; } $err = do_provision($bag); - if ($err !== '') { page('Database', steps_nav('database') . db_form($bag, $err)); break; } - $err = do_create_owner($bag); - if ($err !== '') { page('Admin', steps_nav('admin') . admin_form($bag, $err)); break; } + if ($err !== '') { page('Database', steps_nav('database') . db_form($bag, $err), + ['installer' => INSTALLER_VERSION, 'step' => 'database', 'status' => 'error', 'error' => 'database_failed', 'detail' => $err, + 'fields' => ['db_host', 'db_name', 'db_user', 'db_pass']]); break; } + $owner = null; + $err = do_create_owner($bag, $owner); + if ($err !== '') { page('Admin', steps_nav('admin') . admin_form($bag, $err), + ['installer' => INSTALLER_VERSION, 'step' => 'admin', 'status' => 'error', 'error' => 'owner_failed', 'detail' => $err, + 'fields' => ['org', 'email', 'username', 'password', 'agent'], 'agent_requested' => $agentWanted]); break; } + + // TIGER-90 — only now: the owner exists, so the credential has someone to belong to. + $agent = $agentWanted ? do_enable_agent($bag, $owner) : ['ok' => false, 'error' => '']; @unlink($home . '/.tiger-install-tmp/tiger.zip'); $deleted = @unlink(__FILE__); @@ -695,6 +809,55 @@ function do_create_owner($bag) { . ($deleted ? '
This installer has deleted itself. Nothing else to clean up.
' : '
Delete this file now. The installer couldn’t remove itself — delete ' . h(__FILE__) . ' via File Manager/FTP immediately.
'); - page('Done', $body); + + // --- The agent credential, shown once (TIGER-90) ------------------------------------------- + // The installer self-deletes, so this screen is the ONLY place the user learns the credential + // exists. Say where to manage it, not just what it is. + if ($agentWanted && !empty($agent['ok'])) { + $body .= '

🤖 Your assistant can manage this site

' + . '

Give this key to the assistant that installed Tiger. It is shown once. ' + . 'It reaches ' . h(implode(', ', $agent['modules'])) . ' for this organization only, and it is never more than your own permissions allow.

' + . '' + . '' + . '' + . '' + . '
Endpoint' . h($base) . '/mcp
Access key' . h($agent['token']) . '
Manage / revoke' . h($base) . '/mcp/admin
' + . '
Keep it like a password. If it ever leaks, revoke it at /mcp/admin and mint a new one — the site itself is unaffected.
' + . '
'; + } elseif ($agentWanted) { + // Asked for, but the mint failed. The install is fine; only the convenience was lost. + $body .= '
Agent access was not enabled. ' + . h($agent['error'] !== '' ? $agent['error'] : 'The access key could not be created.') + . ' Your site is installed and working. Turn it on any time at ' . h($base) . '/mcp/admin.
'; + } else { + // The majority path for a hand install — a clear next step, not silence. + $body .= '
Using an AI assistant? The /mcp endpoint is off. ' + . 'Turn it on and mint a scoped key at ' . h($base) . '/mcp/admin, then reconnect your assistant.
'; + } + + page('Done', $body, [ + 'installer' => INSTALLER_VERSION, + 'step' => 'finish', + 'status' => 'ok', + 'site' => $base . '/', + 'login' => $base . '/login', + 'admin' => $base . '/admin', + 'app_dir' => $bag['app_dir'], + 'self_deleted' => (bool) $deleted, + 'agent' => $agentWanted && !empty($agent['ok']) + ? [ + 'enabled' => true, + 'endpoint' => $base . '/mcp', + 'token' => $agent['token'], + 'manage' => $base . '/mcp/admin', + 'scope' => ['modules' => $agent['modules'], 'org_scoped' => true, 'read_only' => false], + ] + : [ + 'enabled' => false, + 'manage' => $base . '/mcp/admin', + 'reason' => $agentWanted ? 'mint_failed' : 'not_requested', + 'error' => $agentWanted ? $agent['error'] : null, + ], + ]); break; } From 9404d78bcb29a49c4572e13b7363205eb0819ae8 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 11 Sep 2026 01:40:44 -0400 Subject: [PATCH 2/2] ci: lint across PHP 8.1-8.5 and guard the installer's invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This file is uploaded by hand to someone's shared host and runs ONCE, with no shell, no Composer and no chance to patch it mid-install. It had no CI at all, and it just grew a credential-minting path. Lint matrix 8.1-8.5. A parse error on a customer's PHP version is the worst failure this repo can ship: a blank page on their own server, mid-install, with no way to debug it. Shared hosting is the only target, so the matrix spans what a cPanel host actually offers rather than what we develop on. 61 assertions, no dependencies, same command locally as in CI: - invariants.php — one file with no dependencies of its own; NO callback, webhook, notify_url, redirect_uri, postback or return_url is ever read as input; outbound calls only reach the pinned release URLs; no shell functions; a checksum is required; it still self-deletes. - wizard.php — the agent checkbox is rendered, unticked by default, reversible, and never also a hidden input; the state block emits, parses, and a '<' in the payload cannot break out of the script element. - smoke.php — serves the installer over HTTP and reads its state block back. The seeding test lifts the real controller lines out of the shipped file at run time instead of copying them, so it cannot drift from the code it covers. Also guards INSTALLER_VERSION against the release tag on a v* push — the installer reports its own version and the README's download link is evergreen, so a mismatch ships an installer that lies about what it is. Mutation-tested rather than trusted because it went green: a callback field, a shell call, removed state-block escaping, the checkbox also riding in the hidden bag, seeding applied on POST, a required sibling file, and a default-ticked checkbox each fail a named assertion. The seeding mutation is the one that matters — it is the regression that would let a crafted ?agent=1 link survive the user un-ticking the box. One of those mutations appeared to survive on the first pass. It had not been applied — shell quoting ate the replacement. Verifying that before believing the result is the difference between a test gap and a harness bug. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ --- .github/workflows/ci.yml | 77 +++++++++++++++++++++++++++++++++++++++ README.md | 21 +++++++++++ tests/bootstrap.php | 63 ++++++++++++++++++++++++++++++++ tests/invariants.php | 61 +++++++++++++++++++++++++++++++ tests/run.php | 13 +++++++ tests/smoke.php | 58 ++++++++++++++++++++++++++++++ tests/wizard.php | 78 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 371 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/bootstrap.php create mode 100644 tests/invariants.php create mode 100644 tests/run.php create mode 100644 tests/smoke.php create mode 100644 tests/wizard.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6400907 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +# tiger-install.php is uploaded BY HAND to someone's shared host and run ONCE, with no shell, no +# Composer, and no chance to patch it mid-install. Whatever ships is what runs. These checks are the +# only thing between an edit and that. +# +# Shared hosting is the ONLY target — that is why the lint matrix spans every PHP a cPanel host is +# likely to offer, and why the invariants refuse shell calls and extra dependencies. + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + # A parse error on a host's PHP version is the worst failure this repo has: the user sees a blank + # page on their own server with no way to debug it. Lint on every version they might be running. + lint: + name: Lint on PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Syntax check every PHP file + run: | + fail=0 + while IFS= read -r f; do + php -l "$f" || fail=1 + done < <(find . -name '*.php' -not -path './.git/*') + exit $fail + + # Run the suite on the floor (what the preflight demands) and the newest (what a good host offers). + test: + name: Tests on PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + # No dependencies on purpose — the same `php tests/run.php` a contributor runs locally. + - run: php tests/run.php + + # The installer reports its own version, and the README's download link is evergreen, so a tag that + # disagrees with INSTALLER_VERSION ships an installer that lies about what it is. + version-matches-tag: + name: Version matches tag + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + TAG="${GITHUB_REF_NAME#v}" + VER="$(grep -oE "INSTALLER_VERSION\s*=\s*'[^']+'" tiger-install.php | grep -oE "'[^']+'" | tr -d "'")" + echo "tag=$TAG INSTALLER_VERSION=$VER" + if [ "$TAG" != "$VER" ]; then + echo "::error file=tiger-install.php::INSTALLER_VERSION ($VER) does not match the release tag ($TAG)." + exit 1 + fi + echo "✓ matches." diff --git a/README.md b/README.md index 406f67d..54ce508 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,27 @@ screen verifies all of this and tells you what to toggle in cPanel. Full detail: > > (The `--stability=beta` flag is no longer needed — the skeleton publishes stable tags.) +## Development + +``` +php tests/run.php +``` + +No dependencies — the same command CI runs. Three files: + +| | | +|---|---| +| `tests/invariants.php` | properties that must never regress: one file with no dependencies of its own, **no callback/webhook field of any kind**, outbound calls only to the pinned release URLs, no shell functions (a shared host has no shell), a required checksum, self-deletion | +| `tests/wizard.php` | the agent checkbox — rendered, unticked by default, reversible, never also a hidden input — and the machine-readable state block, including that a `<` in the payload cannot break out of the script element | +| `tests/smoke.php` | serves the installer and reads its state block back, proving it runs and reports where it is | + +The wizard tests lift the real seeding logic out of the shipped file at run time rather than copying +it, so a test cannot quietly drift from the code it covers. + +CI lints on **PHP 8.1 through 8.5** — the range a cPanel host is likely to offer. A parse error on a +customer's PHP version is the worst failure this repo has: a blank page on their own server, mid-install, +with no way to debug it. + ## License BSD-3-Clause © WebTigers. "Tiger" and "WebTigers" are trademarks of WebTigers. See [LICENSE](LICENSE). diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..69483b1 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,63 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']]; +$srv = proc_open(PHP_BINARY . ' -S 127.0.0.1:' . $port . ' -t ' . escapeshellarg($root), $desc, $pipes); +if (!is_resource($srv)) { fwrite(STDERR, "could not start php -S\n"); exit(2); } + +// wait for the socket rather than sleeping a guess +$html = false; +for ($i = 0; $i < 50; $i++) { + usleep(100000); + $html = @file_get_contents("http://127.0.0.1:$port/tiger-install.php"); + if ($html !== false) { break; } +} + +group('The installer serves and reports its state'); +is_true('it responds at all', $html !== false); + +if ($html !== false) { + is_true('no PHP error leaked into the page', + !preg_match('/(Fatal error|Parse error|Warning:|Notice:|Deprecated:)/', $html)); + + $found = preg_match('##s', $html, $m); + is_true('the state block is present', (bool) $found); + + if ($found) { + // \u003C is a valid JSON escape — json_decode unescapes it for us. + $state = json_decode($m[1], true); + is_true ('the state parses as JSON', is_array($state)); + is_same ('it reports the requirements step', $state['step'] ?? null, 'requirements'); + is_true ('status is a known value', in_array($state['status'] ?? null, ['awaiting-input', 'blocked'], true)); + is_true ('it lists the preflight checks', !empty($state['checks'])); + is_true ('every check declares ok+required', + count(array_filter($state['checks'], static fn($c) => isset($c['ok'], $c['required']))) === count($state['checks'])); + is_true ('the installer version is reported', !empty($state['installer'])); + + // A failing REQUIRED check must be the thing that sets status=blocked — that is the signal a + // client acts on, and it must agree with the checks it ships alongside. + $hardFail = (bool) array_filter($state['checks'], static fn($c) => !$c['ok'] && $c['required']); + is_same('status agrees with the checks', $state['status'], $hardFail ? 'blocked' : 'awaiting-input'); + } + + is_true('the human page rendered too', strpos($html, 'Tiger Installer') !== false); +} + +foreach ($pipes as $p) { @fclose($p); } +proc_terminate($srv); +proc_close($srv); +done(); diff --git a/tests/wizard.php b/tests/wizard.php new file mode 100644 index 0000000..6d86cd6 --- /dev/null +++ b/tests/wizard.php @@ -0,0 +1,78 @@ + '', 'email' => '', 'username' => '', 'password' => '']; +$bagOff = $base + ['agent' => '']; +$bagOn = $base + ['agent' => '1']; +$off = admin_form($bagOff); +$on = admin_form($bagOn); + +is_true ('the checkbox is rendered', (bool) preg_match('/name="agent" value="1"/', $off)); +is_false('it is UNticked by default', (bool) preg_match('/name="agent" value="1"[^>]*checked/', $off)); +is_true ('it is ticked when seeded', (bool) preg_match('/name="agent" value="1"[^>]*checked/', $on)); +// If it also rode in the hidden bag, un-ticking the visible box could not turn it off. +is_false('it is NOT also a hidden input', (bool) preg_match('/type="hidden" name="agent"/', $on)); +is_true ('it names where to revoke', strpos($on, '/mcp/admin') !== false); +is_true ('it says what it does in plain words', stripos($on, 'assistant') !== false); + +group('Seeding is GET-only, so a crafted link cannot force it'); +// Exercises the REAL controller lines, lifted from the shipped file at run time. +$seed = installer_region( + '// `agent` may be SEEDED from the query string', + "\$agentWanted = truthy(\$bag['agent']);" +); +$seedFile = tempnam(sys_get_temp_dir(), 'seed') . '.php'; +file_put_contents($seedFile, " '1'], [])); +is_false('GET ?agent=0', $scenario('GET', ['agent' => '0'], [])); +is_true ('POST with the box ticked', $scenario('POST', [], ['agent' => '1'])); +is_false('POST with the box unticked', $scenario('POST', [], [])); +// THE one that matters: a shared ?agent=1 link must not survive the user un-ticking the box. +is_false('POST ?agent=1 but box UNTICKED', $scenario('POST', ['agent' => '1'], [])); +is_true ('POST ?agent=1 and box ticked', $scenario('POST', ['agent' => '1'], ['agent' => '1'])); +@unlink($seedFile); + +group('The machine-readable state block (TIGER-89)'); +$b = state_block(['step' => 'finish', 'status' => 'ok', 'agent' => ['token' => 'tgr_x']]); +is_true ('it is emitted', strpos($b, 'id="tiger-install-state"') !== false); +is_true ('it declares application/json', strpos($b, 'type="application/json"') !== false); +is_same ('the payload parses', json_decode(strip_tags($b), true)['status'] ?? null, 'ok'); +is_same ('nested structure survives', json_decode(strip_tags($b), true)['agent']['token'] ?? null, 'tgr_x'); +// A '<' inside the payload must never close the script element early. Isolate the payload — what +// sits between the opening tag and the final '' — and assert it carries no '<' at all. +$evil = state_block(['x' => '']); +$open = strpos($evil, '>') + 1; +$payload = substr($evil, $open, strrpos($evil, '') - $open); +is_same ('the payload contains no raw "<"', substr_count($payload, '<'), 0); +is_true ('the "<" was escaped, not dropped', strpos($payload, '\u003C') !== false); +is_same ('the escaped payload still parses', json_decode(str_replace('\u003C', '<', $payload), true)['x'] ?? null, + ''); +is_same ('an empty state emits nothing', state_block([]), ''); + +done();