Security
lynkeus puts code inside your app that lets another process read the screen and press things. That is a capability worth understanding before you adopt it. This page describes exactly what is exposed, to whom, and what keeps it out of the builds your users get. It then covers the hosted report service: what a pushed run contains, who can read it, and when it is deleted.
It is written for the lead who decides whether to adopt lynkeus, and for the developer who has to configure it safely.
The short version
Section titled “The short version”- The agent runs in debug builds only. In a release build the native code that synthesizes touches is not compiled in, and the JavaScript that talks to the driver is never loaded.
- The app dials out to the local machine. Nothing listens inside the app, and nothing is reachable from the network.
- With a token, the app serves only the driver that knows the secret. If the app asks for a token and the value does not reach it, the agent stays off.
- The headless host refuses to start without a token, and refuses a backend that is not local.
- Nothing leaves your machine unless you push a run to the report service. What you push is deleted after 7 days on the Free plan and 90 days on the Team plan.
What the agent can do
Section titled “What the agent can do”Once a driver is attached, it can do everything the protocol describes:
- Read the screen: every element React rendered, with its
testID, text, value and position. - Press, type and swipe, as a user would.
- Navigate to any route and open deep links.
- Read the app’s recent HTTP requests: method, URL, status and duration.
- Read the trace: routes, requests, store changes and analytics events.
- Run any command the app registered with
qa.register, and read any store it exposed withqa.observe.
That is the same reach as a person holding the phone with a debugger attached. The sections below are about making sure only your own tests have it.
What the agent does not record
Section titled “What the agent does not record”The agent limits what it reads in three places.
| Data | What is kept |
|---|---|
Inputs with secureTextEntry |
The value is masked with •. A password never appears in a screen listing. |
| HTTP requests | Method, URL, status and duration only. Headers and bodies are never recorded. In the URL, the values of query parameters whose name ends with token, key, secret, code, otp, password, pin, session or auth are replaced with …. |
| Store changes in the trace | Which top-level keys changed, with short values. An object is reduced to its number of keys and an array to its length, a string longer than 80 characters is cut, and the value under a key named like a secret (token, key, secret, code, otp, password, pin, session, auth) is replaced with …. A trace is part of what a report uploads, so secrets and payloads do not land in it. |
Two things are not reduced, and you control both:
- The
storescommand returns the state of a store you passed toqa.observe, in full, secret-named keys included. Observe a store only if a test may read it. - The properties you pass to
qa.eventare recorded as given.
Debug builds only
Section titled “Debug builds only”The agent is built so that it cannot run in a release build, at two levels.
Native code. The part that synthesizes touches is compiled per build.
- On iOS it is wrapped in
#if DEBUG. The symbols do not exist in a release binary. This matters for more than safety: touch synthesis uses private UIKit API, the technique KIF and Detox rely on, and it must never reach App Store review. - On Android the module checks the application’s
FLAG_DEBUGGABLEflag and refuses every call unless the app is debuggable.
JavaScript. enabled defaults to __DEV__. When it is false, auto() returns at once and <Lynkeus> never loads the client: the code that reads the screen and talks to the driver. The package is in the release bundle and does nothing there. The client is loaded only when the agent is enabled, whichever way the app imports the package and whatever Metro is configured to inline.
Gate enabled on your environment
Section titled “Gate enabled on your environment”A debug build is not always a safe build. A debug build pointed at your production backend, with real accounts, should not run the agent.
import { auto } from 'lynkeus-agent/auto';
auto({ token: process.env.LYNKEUS_TOKEN, enabled: __DEV__ && config.ENV !== 'prod',});The connection
Section titled “The connection”The direction of the connection is the main protection. The app is the client: it dials the driver. The driver is the server.
| Where the app dials | localhost on iOS. 10.0.2.2, the emulator’s address for the host machine, on Android. |
| Where the driver listens | 127.0.0.1: the loopback interface, not the network. |
| What listens inside the app | Nothing. |
So another device on the same Wi-Fi cannot reach the agent: there is no port to connect to. What remains is other software on the same machine, which could listen on the port the app dials. That is what the token is for.
The token
Section titled “The token”The token is a secret shared by the app and its driver. When the app is mounted with one, the driver must present the same value as its first message. Until it does, the app answers every request with unauthenticated and serves nothing.
auto({ token: process.env.LYNKEUS_TOKEN });export LYNKEUS_TOKEN=<the same value>lynkeus screenWithout a token, meaning an app that passes no token at all, the app serves whatever process is listening on the port it dials. On a developer’s own machine that may be acceptable. Set a token wherever the app can reach anything that matters: a shared staging backend, a CI runner that runs other jobs.
A token that does not arrive
Section titled “A token that does not arrive”An app that passes a token expects protection. If the value arrives empty, the agent stays off and writes the reason to the app’s log. It never falls back to serving any driver.
lynkeus: the app passed a token and it is empty, so the agent stays off. …This case is common, not exotic. React Native gives the app no environment, so process.env.LYNKEUS_TOKEN is undefined inside a bundle unless something writes it there. The lynkeus-agent/babel plugin does, and lynkeus init adds it to babel.config.js. Metro must be started with LYNKEUS_TOKEN set, and once with --reset-cache after the value changes.
The plugin writes the token into a development bundle only. A release bundle is left alone: a developer’s secret has no place in a build that ships.
Handling the token
Section titled “Handling the token”Treat the token like any other secret:
- Do not commit it. Keep it in your environment, and in CI in the CI’s secret store.
- Use one per machine or per pipeline rather than one for everybody.
- It is written into the development bundle of the app that reads it, so do not reuse a secret that protects something else.
In an Expo app the app reads the token from EXPO_PUBLIC_LYNKEUS_TOKEN, because Expo only inlines variables with that prefix, and the Babel plugin is not needed. The value ends up in the bundle of your development build, which is the same exposure as any other build-time token.
The headless host
Section titled “The headless host”The headless host runs your app’s JavaScript in Node, usually against a real backend. Because nothing about it is tied to a simulator on a developer’s desk, it has stricter defaults.
- It refuses to start without a token.
lynkeus headless startstops with an error unlessLYNKEUS_TOKENis set. - It refuses hosts that are not local. With
network: 'real', the host lets the app reach only the hosts innetworkHosts, which defaults to loopback. A request to anything else is refused at once. - It checks the API address before the app renders. The entry that
lynkeus headless initwrites reads the variable named byapiEnv(defaultAPI_URL) and throws unless it points atlocalhost,127.0.0.1or10.0.2.2.
An automated test pointed at production by accident is exactly what the last two prevent. If you edit the generated entry, keep the guard.
Tests that act on their own
Section titled “Tests that act on their own”Two commands press things without a script: smoke and crawl.
crawl is read-only by default. Every press happens, but the moment one makes the app write (a POST, PUT, PATCH or DELETE), the crawl reports it, stops exploring past it and never presses that control again. --press-anything turns that off and is for a disposable environment only.
lynkeus ships no list of dangerous controls, because which controls are irreversible depends on your app. List yours under crawl.never in lynkeus.config.json. See Configuration.
What stays on your machine
Section titled “What stays on your machine”Everything lynkeus learns is written as files under .lynkeus/ in the app’s folder: the graph, recorded screens, snapshots, run artifacts. No server is involved. A run goes somewhere else only when you ask for it, with case run --push or report push.
Run directories and .lynkeus/runs/ can hold screenshots and screen listings of your app. Decide deliberately whether to commit them or upload them as CI artifacts.
The hosted report service
Section titled “The hosted report service”The report service at lynkeus.io is optional and separate from the open-source tool. It stores runs you push so that your team can open them as a report.
What a pushed run contains
Section titled “What a pushed run contains”lynkeus report push and case run --push upload the run directory that case run --out wrote:
| File | Contains |
|---|---|
run.json |
When the run started and ended, the platform, the git branch and commit, the totals. |
cases.jsonl |
For each case: its id, title, description and file path, and each step as the case words it, with its result, duration and error. |
| Frames | A screenshot after each app step, when you ran with --screenshots. |
| Films | A video of each case, when you ran with --video. |
A line of cases.jsonl can also carry:
- The analytics events each step caused, with their properties.
- For a failed case, its evidence: the screen listing at the failure, the last requests (status, method and URL) and the diagnosis.
- With
--traces, what your backend did for each step: the spans it exported, with their attributes, such as a route, a status, a table or a job class.
The service also records the project name you push under.
Step text is stored as it is written in the case file. A password written out in an app.type step is part of that text, while a reference such as {{u.password}} is stored as the reference. Use throwaway credentials in cases, and pass them by reference.
Who can read a report
Section titled “Who can read a report”| Access | Who |
|---|---|
| The report’s link | Anyone who has it. The link contains a token that is the run’s own and cannot be guessed, and opening it does not require signing in. Share it as you would share the report itself. |
| The dashboard and the list of runs | Members of your organization, signed in with Google or GitHub. |
| The API | Whoever holds one of your organization’s API keys. |
| AI clients over MCP | A client a member connected and approved, signed in as that member. It can only read. A member can disconnect it under Members. |
API keys
Section titled “API keys”A key identifies your organization to the API. LYNKEUS_REPORTS_KEY holds it.
- A key is shown once, when you create it. The service stores only a hash of it, so it cannot show it to you again.
- Create one key per pipeline, so that you can revoke one without stopping the others.
- In CI, keep it in the CI’s secret store.
How long a run is kept
Section titled “How long a run is kept”| Plan | Runs and frames | Films |
|---|---|---|
| Free | 7 days | Not kept. The upload leaves them out and says so. |
| Team | 90 days | 90 days |
A job runs every day and deletes the runs older than the plan keeps, together with their files. Deletion is by the run’s age, counted from when it was pushed.
Payment
Section titled “Payment”Subscriptions are handled by Stripe. lynkeus report plan --upgrade prints Stripe’s checkout address and --manage prints Stripe’s portal, where the card, the invoices and canceling live.
A checklist
Section titled “A checklist”Before you rely on lynkeus in a team:
enabledis gated on__DEV__and on your environment.- A token is set in CI and on any machine that reaches a shared backend.
- The token and the report key are in secret stores, not in the repository.
- Tests run against test data, with throwaway credentials.
crawl.neverlists the controls a crawl must not press.- The headless entry still has its local-backend guard.