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 e2665c3..54ce508 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,76 @@ To pin a specific *installer* build, download from a tagged release instead of `
https://github.com/WebTigers/TigerInstall/releases/download/v1.0.2/tiger-install.zip
```
+## Driving the installer from an AI client
+
+The wizard is a plain HTML form flow with no JavaScript requirement, so any browser-aware client can
+fill and submit it exactly as a person does. Two things make that reliable rather than a scraping
+exercise.
+
+### 1. Machine-readable state on every screen
+
+Every page carries a JSON block. Read it instead of the prose:
+
+```html
+
+```
+
+| 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
@@ -116,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();
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 '
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 '' . h($home) . '/<domain>/tiger-app and its own database — fully independent installs.' . h(__FILE__) . ' via File Manager/FTP immediately.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 |
/mcp/admin and mint a new one — the site itself is unaffected.' . h($base) . '/mcp/admin./mcp endpoint is off. '
+ . 'Turn it on and mint a scoped key at ' . h($base) . '/mcp/admin, then reconnect your assistant.