Sandbox emulation (Beta)
Emulated email, Google OAuth, Jira and Slack inside Sandboxes v2 sandboxes: how interception works and how tests inspect what was sent.
Emulation makes third-party services deterministic and inspectable. Instead of a Sandboxes v2 sandbox's outbound calls to an email provider, Google, Jira or Slack leaving the sandbox, they're intercepted by an in-sandbox emulator that behaves like the real thing well enough for tests, and that lets a test look at what was sent.
01Declaring plugins
Declare a plugin by its id under emulation:, with its config. jira needs a tenant; the other plugins work with {} and take optional keys (below).
emulation:
email: {}
jira: { tenant: acme } # intercepts acme.atlassian.net
| Plugin id | What it intercepts |
|---|---|
email | The HTTP send APIs of SendGrid, Resend, Postmark, Mailgun and Amazon SES (in us-east-1 unless you configure sesRegions), so outbound mail is captured instead of sent. |
google-oauth | Google's OAuth endpoints, for testing sign-in flows without a real Google account. |
jira | The Jira Cloud REST API of your site, <tenant>.atlassian.net. |
slack | Slack's API, for flows that post messages. |
Plugin config
| Plugin | Key | Type | Required | Meaning |
|---|---|---|---|---|
email | sesRegions | list of strings | No | AWS regions whose SES API host (email.<region>.amazonaws.com) is intercepted. Default: [us-east-1]. |
email | extraDomains | list of strings | No | Extra hosts to intercept, such as a self-hosted provider's API. |
jira | tenant | string | Yes | Your Jira site's subdomain: acme for acme.atlassian.net. |
google-oauth | clientId | string | No | Your app's OAuth client ID, used in the token's aud/azp claims. Taken from the requests when not set. |
slack | appId | string | No | The app_id the emulated oauth.v2.access reports. |
The email plugin intercepts these hosts: api.sendgrid.com, api.resend.com, api.postmarkapp.com, api.mailgun.net, api.eu.mailgun.net, email.<region>.amazonaws.com for each of sesRegions, and every host in extraDomains.
emulation:
email:
sesRegions: [eu-west-1]
extraDomains: [mail.internal.example.test]
Only the intercepted hosts are emulated. If your app sends mail through a host the plugin doesn't intercept (SES in a region you didn't list, or a provider host not above), that call leaves the sandbox and real mail may be sent. Check that
email.sent-emailscaptures a test send before relying on it.
cofactor sandbox validate doesn't check plugin config: a jira plugin without tenant passes validation but intercepts no real Jira site.
cofactor sandbox validate rejects an unknown plugin id. cofactor sandbox envs lists each declared plugin's operations.
Declaring any plugin also adds an implicit public emulation port (port 80), which you don't declare and which counts toward the 8-port limit. While emulation is on, ports 80 and 443 are reserved for the emulator, so none of your own ports can use them, and emulation is a reserved port name.
02How interception works
- Inside the sandbox, the domains a plugin owns (for example
email.us-east-1.amazonaws.comfor SES, oracme.atlassian.netfor ajiraplugin withtenant: acme) resolve to the emulator instead of the internet. Other domains are not intercepted. - The emulator serves those domains over real TLS, with a certificate authority it generates once, when the ready snapshot is built, so every copy of one version and environment shares it. The CA survives an emulator restart, so a service that reads
NODE_EXTRA_CA_CERTSonce at startup keeps working. - Services with
reload_on_prepare: true, prepare commands and actions getNODE_EXTRA_CA_CERTS=/etc/emulation/ca.crt, so Node-based code trusts that CA. - A service without
reload_on_preparenever gets it, nor any emulation setting, so its calls to an emulated domain fail TLS. Any service that talks to an emulated provider must setreload_on_prepare: true. - No passthrough. A path on an emulated domain that no plugin implements returns 501, and the attempt is recorded. A route your app calls that isn't emulated yet fails loudly instead of silently reaching a real third party.
- Every claim's
preparegives the emulator a fresh configuration and restarts it.
openssl
The emulator needs openssl to generate its certificates.
- GitHub-delivered: Cofactor installs it before your
setupruns. - Pushed image: a pushed image that declares
emulationmust include it (apt-get install -y openssl), or the emulator never turns healthy and prepare fails.
03Google OAuth and third-party login
Use authentication.run for ordinary native flows that need a signed-in persona. Use provider emulation when a flow tests the sign-in journey itself. These exercise different paths: creating a persona's app session does not prove the Google button and callback work.
emulation:
google-oauth: {}
params:
api_url: { port: api }
env:
GOOGLE_CLIENT_ID: sandbox
GOOGLE_CLIENT_SECRET: sandbox
GOOGLE_REDIRECT_URI: ${params.api_url}/auth/callback
This is a fragment to merge into your definition. Declare the public api port and set reload_on_prepare: true on the service that calls Google. Use your app's actual environment variable names and callback path.
Callback configuration and app changes
Each copy has its own API URL. The example supplies the callback through the app's existing configuration, so /auth/callback receives the response in that copy. Apps that already configure their callback URL, or derive it correctly from the request origin, can use this approach without changing auth code. A hardcoded production callback needs to become configurable.
The emulated journey does not require registering each copy's callback in Google Console. Real Google login still follows Google's registration rules. Do not disable token verification or change production auth to make an emulator work. Non-Node clients must trust the sandbox emulator's CA using their runtime's supported mechanism; Node services receive NODE_EXTRA_CA_CERTS as described above.
The browser and server handshake
The browser authorization request is intercepted and returned to the app's callback with an emulated code and the original OAuth state. The sandbox emulator then answers the app's token exchange and user-info requests, and exposes discovery and signing-key endpoints. The app's own callback creates its normal app session.
Beta rollout: Interception of an application's HTTP redirect to Google has been proved in dev and is pending deployment. Do not assume that declaring
google-oauthalone proves this browser path is available in your environment; verify the complete journey.
The default identity is Test User (testuser@example.com). Repeated Google sign-ins in a copy return this same identity. Different copies still have separate app users, sessions and data. The Google identity is not selected from the Login node's persona, and the public definition currently has no per-persona Google identity setting.
Google emulation covers direct calls to the supported Google endpoints. If your app authenticates through a hosted intermediary such as Auth0 or Clerk, enabling Google emulation does not emulate that intermediary. Inspect where authorization, token exchange and session creation happen before selecting an approach. Compatibility with each customer's auth library must be proved.
Prove sign-in
- Keep the application's Login node to select the app and environment, with
performLogin: false, so it does not preload an app session. - Begin signed out and click the app's actual Google sign-in button.
- Confirm the callback reaches the current copy, the app reports the fixture identity, and a protected page or API request succeeds with the resulting session.
- Confirm no request reaches real Google identity hosts. An intercepted redirect alone does not prove the backend handshake succeeded.
This checks the app's integration and resulting session. It does not check Google's password screen, consent UI or availability.
04Operations
A plugin can offer typed operations: the same shape as an action (a name, a description, a typed input and output). They appear alongside your own actions, named <plugin>.<op>, and run exactly like an action: from the CLI, or as a seed step.
| Operation | Input | Output |
|---|---|---|
email.sent-emails | none | emails: every email captured since the sandbox booted or the emulator was last reset |
google-oauth, jira and slack have no operations today.
For example, to check that an invite-user action really sent the invitation:
cofactor sandbox run <id> invite-user \ --input workspace_id=<acme.workspace_id> \ --input email=new@example.test --input role=member --input as=owner cofactor sandbox run <id> email.sent-emails
This is why an action that has side effects should go through your app's own API: a raw database insert would skip the send, and there'd be nothing to see.
As a seed step:
seeds:
- id: mail
action: email.sent-emails
emails is a list, so later steps can't reference it with ${mail.emails}; only string, number and boolean outputs can be referenced. See Seeds and actions.