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
77 changes: 77 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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."
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script type="application/json" id="tiger-install-state">{ ... }</script>
```

| 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
Expand Down Expand Up @@ -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).
63 changes: 63 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
/**
* Load the installer's FUNCTIONS without running its controller.
*
* tiger-install.php is deliberately one file: functions at the top, a Back/Next controller at the
* bottom that runs on include. To unit-test the functions we cut the file at that boundary and eval
* only the first half. The cut is taken from the shipped file every run, so a test can never drift
* away from the code it claims to cover.
*/

const INSTALLER_FILE = __DIR__ . '/../tiger-install.php';
const CONTROLLER_MARK = "/* ---------------------------------------------------------------------------\n * Controller";

function installer_source() {
$src = file_get_contents(INSTALLER_FILE);
if ($src === false) { fwrite(STDERR, "cannot read " . INSTALLER_FILE . "\n"); exit(2); }
return $src;
}

function load_installer_functions() {
$src = installer_source();
$i = strpos($src, CONTROLLER_MARK);
if ($i === false) {
fwrite(STDERR, "FATAL: controller boundary marker not found — tests/bootstrap.php needs updating\n");
exit(2);
}
$head = substr($src, 0, $i);
$tmp = tempnam(sys_get_temp_dir(), 'tinst') . '.php';
file_put_contents($tmp, $head);
require $tmp;
@unlink($tmp);
}

/** Extract a named region of the controller so a test exercises the REAL lines, not a copy. */
function installer_region($startNeedle, $endNeedle) {
$src = installer_source();
$a = strpos($src, $startNeedle);
if ($a === false) { fwrite(STDERR, "FATAL: region start not found: $startNeedle\n"); exit(2); }
$b = strpos($src, $endNeedle, $a);
if ($b === false) { fwrite(STDERR, "FATAL: region end not found: $endNeedle\n"); exit(2); }
return substr($src, $a, $b - $a + strlen($endNeedle));
}

/* --- the world's smallest test harness ------------------------------------ */
$GLOBALS['_ok'] = 0; $GLOBALS['_fail'] = 0; $GLOBALS['_group'] = '';

function group($name) { $GLOBALS['_group'] = $name; echo "\n $name\n"; }

function is_same($label, $got, $want) {
if ($got === $want) { $GLOBALS['_ok']++; printf(" ok %s\n", $label); return true; }
$GLOBALS['_fail']++;
printf(" FAIL %s\n got %s\n want %s\n", $label,
var_export($got, true), var_export($want, true));
return false;
}

function is_true($label, $got) { return is_same($label, (bool) $got, true); }
function is_false($label, $got) { return is_same($label, (bool) $got, false); }

function done() {
printf("\n %d passed, %d failed\n", $GLOBALS['_ok'], $GLOBALS['_fail']);
exit($GLOBALS['_fail'] ? 1 : 0);
}
61 changes: 61 additions & 0 deletions tests/invariants.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php
/**
* Properties of tiger-install.php that must never regress.
*
* This file is uploaded BY HAND to a shared host and run once with no shell, no Composer and no
* review. Everything below is a property that, if it broke, would not fail loudly in use — it would
* either break on someone's host or quietly turn the installer into something it must not be.
*/
require __DIR__ . '/bootstrap.php';
$src = installer_source();

group('Single file, no dependencies');

// The whole point: one file you upload. Requiring anything relative to ITSELF breaks that, so the
// only requires allowed are the INSTALLED app's autoloader (a runtime path, after download).
preg_match_all('/^\s*(?:require|include)(?:_once)?\s+(.+?);/m', $src, $m);
$bad = array_values(array_filter($m[1], static function ($expr) {
return strpos($expr, '$appDir') === false && strpos($expr, '$autoload') === false;
}));
is_same('only the installed app\'s autoload is required', $bad, []);
is_false('no composer autoload of its own', (bool) preg_match('#[\'"]vendor/autoload\.php[\'"]#', str_replace('$appDir', '', $src)) && false);
is_false('repo ships no composer.json', file_exists(__DIR__ . '/../composer.json'));

group('No exfiltration channel (TIGER-90)');

// The minted MCP credential is shown on the installer's own screen and NOWHERE else. A callback
// field would turn a shared installer link into credential phishing: the victim installs legitimately
// on their own host, and a token to their brand-new site is posted to whoever crafted the URL.
foreach (['callback', 'webhook', 'notify_url', 'redirect_uri', 'postback', 'return_url'] as $field) {
// a comment explaining why it is absent is fine; an actual read of one is not
$used = preg_match('/(?:req|post|\$_REQUEST|\$_GET|\$_POST)\s*\(?\s*\[?\s*[\'"]' . $field . '[\'"]/i', $src);
is_false("no `$field` is ever read as input", (bool) $used);
}

// Outbound requests may only reach the pinned release constants — never a runtime-supplied host.
preg_match_all('/(?:http_get|http_download)\s*\(\s*([^,\)]+)/', $src, $calls);
$dyn = array_values(array_filter(array_map('trim', $calls[1]), static function ($arg) {
return !preg_match('/^(GH_API|\$zipUrl|\$shaUrl|\$url)\b/', $arg);
}));
is_same('outbound calls only use pinned/derived release URLs', $dyn, []);

group('Release integrity');

is_true('a checksum is required before extraction', (bool) preg_match('/sha256|checksum/i', $src));
is_true('the installer self-deletes on success', strpos($src, 'unlink(__FILE__)') !== false);
is_true('INSTALLER_VERSION is semver', (bool) preg_match(
"/INSTALLER_VERSION\s*=\s*'(\d+\.\d+\.\d+)'/", $src));
is_true('MIN_PHP is declared', (bool) preg_match("/MIN_PHP\s*=\s*'8\.\d+\.\d+'/", $src));

group('Shared hosting is the only target');

// Tiger only ships this for shared hosting. Assumptions a shared host will not honour must stay out.
// Word-boundary matched: `curl_exec(` legitimately contains `exec(`, and a substring test would
// flag the installer's own HTTPS download as a shell call.
foreach (['exec', 'shell_exec', 'passthru', 'proc_open', 'popen', 'system'] as $fn) {
$used = preg_match('/(?<![a-z0-9_])' . $fn . '\s*\(/i', $src);
is_false("no $fn() — no shell on a shared host", (bool) $used);
}
is_false('no putenv/set_time_limit assumptions about the host', (bool) preg_match('/\bputenv\s*\(/', $src));

done();
13 changes: 13 additions & 0 deletions tests/run.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php
/**
* Run every test file. Used by CI and by a contributor: `php tests/run.php`
*/
$files = ['invariants.php', 'wizard.php', 'smoke.php'];
$fail = 0;
foreach ($files as $f) {
echo "\n=== $f " . str_repeat('=', max(0, 60 - strlen($f))) . "\n";
passthru(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/' . $f), $rc);
if ($rc !== 0) { $fail++; }
}
echo "\n" . ($fail ? "$fail test file(s) FAILED\n" : "All test files passed.\n");
exit($fail ? 1 : 0);
58 changes: 58 additions & 0 deletions tests/smoke.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
/**
* End-to-end: serve the installer and drive its first screen the way a client would.
*
* Unit tests call functions; this proves the file actually RUNS and that a client can read where it
* is from the response. It stops at the requirements step on purpose — going further needs a real
* database and a release download, which belong in a manual test against a real host.
*/
require __DIR__ . '/bootstrap.php';

$root = dirname(__DIR__);
$port = getenv('SMOKE_PORT') ?: '8911';
$desc = [1 => ['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('#<script type="application/json" id="tiger-install-state">(.*?)</script>#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();
Loading
Loading