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 '' . '' @@ -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; }