Skip to content

feat(installer): agent install + connect handshake (TIGER-89/90) - #2

Merged
WebTigers merged 2 commits into
mainfrom
feat/agent-handshake
Sep 11, 2026
Merged

feat(installer): agent install + connect handshake (TIGER-89/90)#2
WebTigers merged 2 commits into
mainfrom
feat/agent-handshake

Conversation

@WebTigers

Copy link
Copy Markdown
Owner

Closes the two seams under TIGER-88:
TIGER-89 (install handshake) and
TIGER-90 (connect handshake).

One mechanism, not two

89 wants "success or the specific failure without human interpretation." 90 asks "how does the client
read the credential." That's the same question — how does a non-human read this screen — so it gets
one answer: a JSON state block on every screen, carrying the credential at finish.

<script type="application/json" id="tiger-install-state">{ "step": "...", "status": "..." }</script>

status alone answers "did that work?" — blocked (unmet requirement), error (retryable, with a
stable slug), ok (only on finish). A <script> block rather than <meta> because it carries
structure — scope, URLs, field lists — that doesn't fit an attribute.

89 — drivable was already true

Per the ticket's instruction to confirm before building: the wizard is plain HTML forms with no JS
requirement, so a browser-aware client can already fill and submit it. No new install path was
added
— the ticket explicitly warns against one, and none was needed. The gap was purely that
outcomes had to be read out of prose. That's what this fixes.

90 — the shape the ticket settled

  • Visible checkbox on the admin step, default off, optionally seeded by ?agent=1
  • Seeding is GET-only — on a POST the checkbox is the sole authority, so a crafted link pre-ticks
    a box the user can see and turn off. Verified: POST ?agent=1 + unticked box → off
  • On finish, after do_create_owner() succeeds: mint, enable, display once
  • Scope = curated starter set, org-scoped. tiger.api.discovery left alone
  • Finish screen names /mcp/admin — the installer self-deletes, so it's the user's only chance to
    learn the credential exists

One decision the ticket left open, made deliberately: within "after the owner exists", I mint
before enabling. The reverse leaves /mcp on with no credential if minting fails. The safe
half-state is the one that grants nothing. Minting failure never fails the install.

No callback — and the code says why. The key renders on the installer's own screen and nowhere
else; the only outbound calls remain the fixed GitHub release constants. Verified there is no
callback/webhook/redirect_uri field anywhere.

Still open for Astra

The ticket lists four convergence questions. This answers #1 (a stable JSON anchor — kinder to
every client than a DOM hook or plain text) and #3 (not ticked → degrade to "enable at
/mcp/admin, then reconnect", not a bare failure). #2 (is one token enough for open-ended
authoring — the curated set covers content and media but not themes/menus) and #4 (should a
loopback install get different defaults) are genuinely open and want Astra's input.

Tested

  • 15 assertions: checkbox renders, unticked by default, ticked when seeded, not also a hidden
    input, names /mcp/admin; truthiness; state block emits, parses, escapes <, empty → nothing
  • 7 seeding scenarios, using logic extracted from the shipped file so the test can't drift from it
  • Requirements step driven over HTTP and its state block parsed back (8 checks, correct status)

🤖 Generated with Claude Code

https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ

WebTigers and others added 2 commits September 11, 2026 01:28
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 <script type="application/json" id="tiger-install-state">
with step, status, next_step, expected fields, and a stable error slug plus the
human message. Requirements additionally reports each check with ok/required/fix.
status alone answers "did that work?": blocked = an unmet requirement, error =
retryable, ok = only ever on finish. A JSON block rather than a <meta> because it
carries structure (scope, URLs, field lists) that will not fit an attribute.

The connect handshake (TIGER-90)
A visible checkbox on the admin step, default off, optionally seeded by ?agent=1.
Seeding is GET-only: on a POST the checkbox is the sole authority, so a crafted
link pre-ticks a box the user can SEE and turn off. Verified: POST with ?agent=1
and the box unticked resolves to off.

On finish, after do_create_owner() succeeds, mint a scoped token and enable /mcp,
then display the key once. Two ordering decisions, both load-bearing:

- Everything happens after the owner exists, so there is no window where MCP is
  reachable before an admin exists to revoke it. (Required by the ticket.)
- Within that, mint BEFORE enabling. The ticket does not specify this half, and
  the reverse leaves MCP on with no credential if minting fails. The safe
  half-state is the one that grants nothing.

Minting failure never fails the install — the site is live and the owner exists;
only the convenience is lost, and the screen says so. Not ticking the box
degrades to "enable at /mcp/admin, then reconnect" rather than silence, which is
the majority path for a hand install.

Scope is the curated starter set (Tiger_Mcp_Token::DEFAULT_MODULES), org-scoped,
not read-only. tiger.api.discovery is deliberately untouched: MCP's tools/list
already gives the client its typed surface, and publishing the OpenAPI document
is a separate decision.

No callback, and the code comments say why it must stay that way. The key is
rendered on the installer's own screen and nowhere else; the only outbound calls
remain the fixed GitHub release constants. A client that drove the install drove
the browser and can read the finish page, so a callback would solve nothing while
turning a shared installer link into credential phishing.

README documents the whole contract — this is the seam a client builds against.

Tested: 15 assertions on the checkbox, truthiness and state block; 7 scenarios on
the seeding logic extracted from the shipped file so the test cannot drift from
it; and the requirements step driven over HTTP with its state block parsed back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ
@WebTigers
WebTigers merged commit 66e519d into main Sep 11, 2026
8 checks passed
@WebTigers
WebTigers deleted the feat/agent-handshake branch September 11, 2026 05:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant