Skip to content

Mini App loopback credential guard and readiness #253

Closed

Description

@Fectivnfy112357

Parent

#251

What to build

A Mini App's own loopback server rejects requests that do not carry the host-issued credential, and a
Mini App counts as ready only when that guard is in the path rather than merely listening.

Acceptance criteria

  • A request without the credential is answered 401 with no body; the same request with it reaches
    the plugin's own handler.
  • The exact loopback host is required, and an upgrade or connect attempt without the credential is
    destroyed rather than answered.
  • Plugin code never observes the credential header.
  • A reserved health path is answered by the guard itself, and readiness fails when an anonymous
    request is not rejected.
  • The credential is minted per process generation and never appears in a published view of a Mini
    App, its surface path, or the client's data.

Activity

  1. added
    ready-for-agentFully specified, ready for an AFK agent
    miniappWebUI Mini App hosting programme (ADR 0020)
    on Oct 26, 2026
  2. Fectivnfy112357 commented on Oct 26, 2026

    @Fectivnfy112357
    Author

    Implementation is up in #262 (branch feat/miniapp-loopback-guard, commits 7a79dab0 + 32d01c2e, stacked
    on #261). Three of the five criteria are proven by tests; two are implemented and reviewed but have no
    execution evidence yet, so this ticket stays open and the comment names exactly what is missing.

    Proven

    1. A request without the credential is answered 401 with no body; the same request with it reaches the
    plugin's own handler.
    packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts drives a real
    http.Server through the real startMiniAppModule seam (loadModule injected, everything else real):

    • answers an unauthenticated request with 401 and no body → status 401, body ''
    • answers a request carrying the wrong credential with 401
    • reaches the plugin handler with the right credential and hides the header from it → 200 with the
      plugin's own body

    2. The exact loopback host is required, and an upgrade without the credential is destroyed rather than
    answered.

    • rejects the right credential on the wrong host → the credential with Host: localhost:<port> is
      answered 401, because the guard compares the exact 127.0.0.1:<port> string
    • destroys an upgrade without the credential and forwards one that carries it → the client socket sees
      no bytes at all, and the plugin's upgrade listener is never called; the credentialed upgrade gets the
      plugin's 101

    3. Plugin code never observes the credential header. Same case asserts
    observed[0].headers has no x-mavis-miniapp-token and rawHeaders contains no such entry, while the
    request that reached the handler did carry it (both headers and rawHeaders are stripped).

    Red proof for the above: with the installMiniAppLoopbackGuard(...) call removed,
    6 of 7 cases fail. The seventh is the init-message validator case, which does not depend on the
    install.

    Full-suite result at 32d01c2e:

    $ pnpm run test:capabilities
    Test Files  236 passed (236)
         Tests  5818 passed | 18 skipped (5836)
    $ pnpm typecheck
    > tsc -p tsconfig.standalone.json        # passed
    $ pnpm check:source
    Public source check passed: 5452 files, workspace exports and native helper integrity verified.
    

    Implemented, not proven

    4. A reserved health path is answered by the guard itself, and readiness fails when an anonymous
    request is not rejected.

    The first half is proven: answers the reserved health path itself, before the plugin handler gets 204
    for the credentialed request, 401 for the anonymous one, and the plugin handler is never called.

    The second half is only implemented. node-runtime now requires anonymous → 401 followed by
    credentialed → 204 in probeLoopbackAuth, with READINESS_PROBE_REJECTED for a wrong status and
    READINESS_PROBE_TIMEOUT for a stalled probe; the loop stops retrying once a port answers wrongly
    (isProbeRejection). No test exercises this: the probe lives in node-runtime, which has no test
    harness in this repository — the shipped package's node-runtime.test.ts brings one (1999 lines) and it
    has not been adopted. Closing this criterion needs that harness, or a narrower one that stubs spawn.

    5. The credential is minted per process generation and never appears in a published view, its surface
    path, or the client's data.

    Proven by construction and type, not by test:

    • minted at node-runtime.ts:228 — prepareRuntime, i.e. once per process generation and outside the
      port-attempt loop, so a port retry reuses one credential
    • carried on the init message and kept on MiniAppNodeRuntime as loopbackToken, documented
      server-only (contracts.ts:104)
    • MiniAppPublishedView (contracts.ts:129-140) has no credential field, and no renderer contract
      carries one, so nothing published, no surface path and no client payload can hold it

    The per-generation property has no assertion behind it. A test would need to prepare two runtimes and
    compare the credentials, which needs the same node-runtime harness as criterion 4.

    Deliberately left to the proxy change

    The supervisor does not yet expose the credential to the proxy. ADR 0020 counts "the supervisor's launch
    credential so the proxy has something to inject" as part of the guard's cost, but the only consumer is
    the proxy in #255, so this branch does not add a field nothing reads. If you would rather wire it here,
    say so and I will extend this branch rather than #255.

  3. Fectivnfy112357 commented on Oct 26, 2026

    @Fectivnfy112357
    Author

    Update at daf9a396 (PR #262): criterion 4 now has execution evidence too. Only criterion 5's
    per-generation assertion is still open, so the ticket stays open on that one item.

    Now proven — readiness fails when an anonymous request is not rejected

    Readiness moved out of node-runtime.ts into process/node-runtime/loopback-readiness.ts
    (waitForLoopbackReadiness), so it can be driven against real servers instead of a faked child. Four
    cases in the same suite:

    • passes when the guard rejects the anonymous probe and answers the credentialed one — the guarded
      fixture resolves
    • fails when the credentialed probe is rejected → READINESS_PROBE_REJECTED,
      authenticated health probe expected 204, received 401
    • fails when an anonymous probe is not rejected — a plain node:http server in a child process that
      answers 200 to everything → READINESS_PROBE_REJECTED,
      unauthenticated health probe expected 401, received 200. This is exactly the failure the old TCP
      connect could not see.
    • times out when nothing is listening → READINESS_TIMEOUT

    So both halves of criterion 4 are now behavioural: the reserved path is answered by the guard itself
    (204 / 401, plugin handler never called), and readiness refuses a socket that does not reject anonymous
    requests.

    At daf9a396:

    $ pnpm run test:capabilities
    Test Files  236 passed (236)
         Tests  5822 passed | 18 skipped (5840)
    $ pnpm typecheck            # passed
    $ pnpm check:source         # 5453 files, passed
    

    Still open — criterion 5's per-generation property

    The credential is minted at node-runtime.ts:228 in prepareRuntime, before the port-attempt loop, so
    a port retry reuses one credential and a new process generation gets a new one. That placement is the
    enforcement; no test asserts it, because the assertion needs a node-runtime harness that scripts a
    child process through spawn (the shipped package has one at 1999 lines, not yet adopted here).

    The other half of criterion 5 is structural and holds: MiniAppNodeRuntime.loopbackToken is documented
    server-only, MiniAppPublishedView (contracts.ts:129-140) has no credential field, and no renderer
    contract carries one, so no published view, surface path or client payload can hold it.

    Two ways to close this ticket, your call:

    1. Close on construction evidence — the property holds and is reviewable at the two line references
      above; the missing piece is only a regression test.
    2. Keep it open until the scripted node-runtime harness lands, then add "two preparations mint
      different credentials" as the assertion. That harness is needed by One client owns a Mini App at a time #254 and Serve a Mini App page through its own proxy #255 anyway.
  4. Fectivnfy112357 commented on Oct 26, 2026

    @Fectivnfy112357
    Author

    All five criteria now have evidence. Closing at 33322479 (PR #262).

    The gap in my previous comment — per-generation minting — needed the scripted-child harness this
    repository did not have. It exists now: packages/local-runtime-v2/test/unit/miniapp/node-runtime-credential.test.ts
    drives createMiniAppNodeRuntimeAdapter with a ChildProcess stand-in that runs the real runner
    in-process
    , so the readiness probe meets the real guard while the child itself is scripted.

    Criterion 4 — readiness fails when an anonymous request is not rejected (proven)

    waitForLoopbackReadiness lives in process/node-runtime/loopback-readiness.ts and is driven against
    real servers:

    • guarded fixture → resolves
    • wrong credential → READINESS_PROBE_REJECTED, authenticated health probe expected 204, received 401
    • plain node:http server in a child process answering 200 to everything →
      READINESS_PROBE_REJECTED, unauthenticated health probe expected 401, received 200
    • closed port → READINESS_TIMEOUT

    The third case is the one the old TCP connect could not see: the socket accepts, the guard is absent.

    Criterion 5 — minted per process generation, never published (proven)

    • mints one credential per preparation and hands that exact value down — one preparation calls
      makeLoopbackToken exactly once, the init message carries that value, and the runtime handle exposes
      the same value after readiness passed for it
    • gives a new process generation a different credential — a second generation's init message carries
      a different value
    • The leak half is structural: MiniAppNodeRuntime.loopbackToken is documented server-only,
      MiniAppPublishedView (contracts.ts:129-140) has no credential field, and no renderer contract
      carries one

    Criteria 1–3 (proven, unchanged from the previous comment)

    packages/local-runtime-v2/test/unit/miniapp/loopback-guard.test.ts against a real http.Server
    through the real startMiniAppModule seam: anonymous 401 with an empty body, wrong credential 401, the
    right credential on the wrong Host 401, the plugin handler reached with no credential header visible
    in headers or rawHeaders, the reserved health path answered 204/401 without reaching the plugin,
    and an unauthenticated upgrade destroyed while a credentialed one is forwarded. Removing the guard
    install fails 6 of the 7 original cases.

    Gates at 33322479

    $ pnpm run test:capabilities
    Test Files  237 passed (237)
         Tests  5824 passed | 18 skipped (5842)
    $ pnpm typecheck            # tsc -p tsconfig.standalone.json, passed
    $ pnpm check:source         # 5454 files, passed
    

    pnpm verify in full was not run; the browser and release-package steps are left to CI on #262.

    Untested, and stated rather than implied

    The connect (HTTP CONNECT) branch goes through the same socket-event allowlist as upgrade but has no
    case of its own. Everything else in the ticket has a behavioural test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    miniappWebUI Mini App hosting programme (ADR 0020)ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions