Sandbox definition (cofactor.sandbox.yaml) (Beta)
Every field of cofactor.sandbox.yaml, its type, whether it is required, and the rules validate checks.
Everything about a Sandboxes v2 sandbox lives in one file, cofactor.sandbox.yaml. For a GitHub-delivered application it sits at the repository root or under the definition directory you connected; for pushed images it can live anywhere, and cofactor sandbox push puts it in the image.
cofactor sandbox validate checks every rule on this page, offline:
cofactor sandbox validate [file] # a definition built from a GitHub checkout cofactor sandbox validate --upload [file] # a definition for pushed images
Each problem is reported with a dotted path to exactly where it is:
environments.default.seeds[0].input.workspace_id: references step "acme", which has not run yet (or does not exist) environments.free-plan.extends: unknown environment "defualt" ports.emulation: "emulation" is a reserved port name
01Conventions
- Unknown keys are rejected. Every object on this page is strict: a misspelled field is an error, not ignored.
- Names of ports, services, personas, actions, environments and emulation plugins: lowercase letters, digits and
-, starting with a letter, up to 32 characters (^[a-z][a-z0-9-]{0,31}$). - Param names, action input/output names and seed step ids: lowercase letters, digits and
_, starting with a letter (^[a-z][a-z0-9_]*$). No-, because they appear in${params.name}and${step.key}references. - Environment variable names: uppercase letters, digits and
_, not starting with a digit (^[A-Z_][A-Z0-9_]*$). - Multi-line text: YAML
|keeps line breaks (multi-paragraph descriptions, thesetupscript);>-folds lines into one string (a long single-sentence description). - Maximum size: a definition is at most 256 KB.
02Top-level fields
| Field | Type | Required | Delivery | Meaning |
|---|---|---|---|---|
version | 1 | Yes | Both | The schema version of the file. Always 1. |
resources | object | Yes | Both | The machine every sandbox of this application gets. |
setup | string | GitHub: yes. Pushed: not allowed | GitHub only | Root shell script that installs the toolchain. |
ports | map | Yes (may be {}) | Both | Named ports, each with its own URL. |
params | map | Yes (may be {}) | Both | Per-sandbox values referenced from env. |
env | map | Yes (may be {}) | Both | Environment variables for services, prepare and actions. |
services | map | Yes (may be {}) | Both | Long-running processes. |
personas | map | No (default {}) | Both | Named login identities. |
lifecycle | object | Yes | Both (build GitHub only) | build steps and prepare commands. |
authentication | object | No; needed for native persona login | Both | Private script that creates a session inside the current copy. |
actions | map | No (default {}) | Both | Typed scripts that put data into a sandbox. |
emulation | map | No (default {}) | Both | Emulated third-party services. |
object_storage | object | No | Both | An emulated S3 or GCS server with buckets. |
environments | map | Yes; must include default | Both | Named flavors of the sandbox: starting data and config. |
lifecycle is required even for a pushed image with nothing to prepare: write lifecycle: { prepare: [] }.
03version
version: 1
The literal number 1.
04resources
resources: { vcpus: 2, memory_mib: 4096 }
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
vcpus | integer | Yes | 1–32 | Virtual CPUs per sandbox. |
memory_mib | integer | Yes | 1024–65536 | Memory per sandbox, in MiB. |
For a GitHub-delivered application, changing resources builds a new toolchain layer.
05setup
GitHub only. A shell script, run as root, that installs the toolchain: packages, language runtimes, database clusters. It runs once per toolchain layer, which is keyed by this script's text together with resources and ports; changing anything else never reruns it. Cofactor installs git, ca-certificates, curl and openssl before it runs.
setup: | apt-get update -qq apt-get install -y -qq postgresql-16 nodejs npm jq curl >/dev/null
Required (non-empty) for a GitHub-delivered definition. A definition for pushed images must not have it ("an uploaded image is already built: remove setup (install it in the image)").
06ports
ports:
web: { port: 3000, access: public }
api: { port: 3001, access: public }
A map of port name to:
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
port | integer | Yes | 1–65535 | The port the app listens on inside the sandbox. |
access | public | private | Yes | public gets its own https URL, unique per sandbox. private is reachable only from inside the sandbox. |
Rules:
- At most 8 ports, counting the implicit
emulationport (added whenemulationdeclares any plugin) and the implicitobject-storageport (added whenobject_storageis declared). emulationandobject-storageare reserved port names, whether or not those features are declared.- With
emulationdeclared, ports 80 and 443 are reserved for the emulator. - With
object_storagedeclared, port 9000 (S3), or ports 4443 and 4444 (GCS), are reserved for the storage server.
See Ports and params.
07params
Per-sandbox values, filled in by prepare, referenced from the top-level env as ${params.name}. A map of param name to one of three shapes:
| Shape | Meaning |
|---|---|
| a string | A fixed value, the same in every sandbox. |
{ port: <name> } | This sandbox's own public URL for that port (an https:// origin). |
{ secret: true } | A random value generated by Cofactor rather than written in the file (for example a session-signing key). It is generated when a ready snapshot is prepared; every claim copied from that snapshot keeps it, and a pull request preview gets its own. |
params:
web_url: { port: web }
api_url: { port: api }
session_secret: { secret: true }
Rules:
portmust name a declared port, orobject-storagewhenobject_storageis declared.secretmust betrue.- Params belong to the definition, not to an environment: they can't vary between environments.
Every param is also given to prepare commands and actions as COFACTOR_PARAM_<NAME> (the name uppercased).
08env
Environment variables the services, prepare commands and actions run with. A map of variable name to string. ${params.name} anywhere in a value is replaced by that param's value.
env:
DATABASE_URL: postgres://app@127.0.0.1:5432/app
PUBLIC_URL: ${params.web_url}
SESSION_SECRET: ${params.session_secret}
Rules:
- A
${params.name}reference must name a declared param. - Substitution happens only here, in the top-level
env. An environment'senv:values are used as written.
Who sees what:
| Runs | Sees |
|---|---|
Services with reload_on_prepare: true, prepare commands, actions | This env resolved with the sandbox's own param values, then the claimed environment's env: (parent first) on top |
Services without reload_on_prepare, and build steps | This env resolved with layer values: a { port } param is http://127.0.0.1:<port>, a { secret } param a placeholder. No environment env:. |
With object_storage declared, everything also gets the provider's client variables, which this env can override. For a pushed image, the image's own ENV is applied first, and anything here overrides it.
09services
Long-running processes. A map of service name to:
| Field | Type | Required | Default | Delivery | Meaning |
|---|---|---|---|---|---|
run | string | Yes | Both | Shell command that starts the process. Use exec so the process replaces the shell. | |
health | object | Yes | Both | When the service counts as healthy (below). | |
after_build | boolean | No | false | GitHub only | Don't start until lifecycle.build has produced its output. |
reload_on_prepare | boolean | No | false | Both | Started by the first prepare and restarted by every later one, so it runs with this sandbox's own param values and environment env:. |
health is one of:
| Shape | Healthy when |
|---|---|
{ command: <shell> } | The command exits 0. |
{ http: <port>, path: </…> } | GET on that local port and path answers below 400. http is 1–65535; path starts with /. |
A service must turn healthy within 5 minutes of starting.
services:
db:
run: exec sandbox/start-postgres.sh
health: { command: pg_isready -h 127.0.0.1 }
api:
run: cd api && exec npm start
health: { http: 3001, path: /health }
after_build: true
reload_on_prepare: true
Rules:
object-storageandobject-storage-frontare reserved service names.- In a definition for pushed images,
after_build: trueis an error ("an uploaded image has no build to wait for").
When to set reload_on_prepare: any service that reads a { port } or { secret } param, an environment's env:, or talks to an emulated provider. Without it, a service starts before any sandbox exists and never sees per-sandbox values.
10personas
Named login identities. A map of persona name to:
| Field | Type | Required | Meaning |
|---|---|---|---|
email | string (an email address) | Yes | The persona's login email. |
personas:
owner: { email: owner@example.test }
member: { email: member@example.test }
Only the email is declared. Cofactor generates each persona's password per sandbox and hands every login to prepare commands and actions as COFACTOR_PERSONAS, a JSON map { "<persona>": { "email": "…", "password": "…" } }. Each persona also becomes a persona of the application with the same name, so a native Login node can select it. Declare authentication.run to create its session in the current copy. See Seeds and actions.
11authentication
For native Cofactor flows that sign in as a sandbox persona, declare a private authentication script:
personas:
owner: { email: owner@example.test }
member: { email: member@example.test }
authentication:
run: sandbox/auth.sh
| Field | Type | Required | Meaning |
|---|---|---|---|
run | non-empty string | Yes, when authentication is declared | Script run with Bash, relative to the definition directory (GitHub) or the image's WORKDIR (pushed). Absolute paths and . or .. path segments are rejected. |
When a Login node needs a persona, Cofactor runs this script inside that flow run's copy and installs its returned session in the browser. A later Login node can request another persona in the same copy. Sessions are created for the current copy rather than shared between tests. Existing persona-based flows can keep their nodes; the persona name must match a declared sandbox persona.
The script receives the prepared environment, COFACTOR_PERSONAS and COFACTOR_AUTH_PERSONA (the requested persona name). It must create the application's own session, verify the signed-in identity, and print only a Playwright storage-state JSON object to stdout:
cookies: entries withname,value,domain,path,expires,httpOnly,secureandsameSite(Strict,LaxorNone).origins: entries withoriginandlocalStorage, a list of{name, value}strings.
Both arrays are required, and at least one cookie or local-storage entry must be present. The script must exit successfully within 60 seconds and return at most 1 MiB. Cookie domains and storage origins must belong to the current copy's public URLs. Preserve your app's cookie attributes and use its public HTTPS endpoint for secure cookies. Keep session values out of logs and normal action outputs.
This field is optional for definitions that only use personas in prepare, seeds or actions. A native persona login without it fails with script_not_declared; it does not fall back to another test's session.
Read cofactor sandbox guide v2 authentication for an adaptable script example. Third-party login setup is covered in Google OAuth and third-party login. This contract applies to native Cofactor flows; it does not automatically inject auth into external Playwright suites.
12lifecycle
| Field | Type | Required | Delivery | Meaning |
|---|---|---|---|---|
build | list of build steps | GitHub: yes, at least one. Pushed: not allowed | GitHub only | Ordered steps that build the checkout. |
prepare | list of strings | Yes (may be []) | Both | Shell commands run every time a sandbox is prepared. |
lifecycle.build
GitHub only. Each step:
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
name | string | Yes | ^[a-z][a-z0-9-]{0,31}$; unique among the steps | The step's name, shown in timings. |
run | string | Yes | Non-empty | Shell command. |
inputs | list of strings | Yes | At least one; each non-empty | Git pathspecs over tracked files (glob patterns apply), relative to the definition's directory. |
A step reruns only when the files its inputs match changed since the layer it builds on, or when an earlier step ran. An environment's env: doesn't reach build steps.
lifecycle:
build:
- name: install
run: npm ci
inputs: [package-lock.json, "**/package.json"]
A definition for pushed images must not have lifecycle.build ("an uploaded image is already built: remove lifecycle.build (build it in the image)").
lifecycle.prepare
Shell commands, run in order every time a sandbox is prepared: each fresh claim, and each in-place move of a pull request preview to a new commit. They run after the reload_on_prepare services have (re)started with the sandbox's own values and are healthy. They get every param as COFACTOR_PARAM_<NAME> and the persona logins as COFACTOR_PERSONAS, and must make each persona's login work in the app.
lifecycle:
prepare:
- bash sandbox/personas.sh
13actions
Typed scripts: the one way data gets into a sandbox. A map of action name to:
| Field | Type | Required | Default | Rules | Meaning |
|---|---|---|---|---|---|
description | string | Yes | Non-empty | What it does, for the person or agent calling it. | |
run | string | Yes | Non-empty | Script path, run with bash. Relative to the definition's directory (GitHub) or the image's WORKDIR (pushed). | |
input | map | No | {} | Names: ^[a-z][a-z0-9_]*$ | Input properties (below). |
output | map | No | {} | Names: ^[a-z][a-z0-9_]*$ | Output properties (below). |
timeout | integer | No | 60 | 1–1800 | Seconds before the script is killed. |
Input property:
| Field | Type | Required | Meaning |
|---|---|---|---|
type | string | number | integer | boolean | persona | Yes | The value's type. A persona value is a declared persona's name. |
description | string | No | Shown to callers. |
required | boolean | No | The caller must supply it (unless it has a default). |
default | string, number or boolean | No | Used when the caller omits it. |
enum | list of strings or numbers | No | The only allowed values. |
sensitive | boolean | No | Masked wherever action input is logged or returned. |
Output property:
| Field | Type | Required | Meaning |
|---|---|---|---|
type | string | number | integer | boolean | Yes | The value's type. |
description | string | No | Shown to callers. |
actions:
add-member:
description: Adds a persona to a workspace with a role.
run: sandbox/actions/add-member.sh
input:
workspace_id: { type: string, required: true }
persona: { type: persona, required: true, description: Who is added }
role: { type: string, required: true, enum: [owner, member, viewer] }
as: { type: persona, required: true, description: Who adds them }
timeout: 60
The script contract (INPUT_<NAME>, the result line) is in Seeds and actions.
14emulation
A map of emulation plugin id to its config map. jira requires tenant (your site's subdomain, acme for acme.atlassian.net). email takes optional sesRegions (list of strings; SES regions to intercept, default [us-east-1]) and extraDomains (list of strings; extra hosts to intercept). google-oauth takes an optional clientId and slack an optional appId; both work with {}.
emulation:
email: {}
jira: { tenant: acme }
Rules:
- Each id must be a known plugin:
email,google-oauth,jiraorslack. validatedoesn't check plugin config keys; a missingjira.tenantis not reported.- Declaring any plugin adds an implicit public
emulationport (port 80), which counts toward the 8-port limit, and reserves ports 80 and 443.
See Emulation.
15object_storage
An emulated S3 or Google Cloud Storage server, with these buckets, in every sandbox.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
provider | s3 | gcs | Yes | One provider per application | Which API the server speaks. |
buckets | list of strings | Yes | At least one; unique | Buckets created before the first prepare command runs. |
Bucket names: 3–63 lowercase letters, digits, - and ., starting and ending with a letter or digit, with no .., and not shaped like an IP address. A GCS bucket can't be named storage, upload, download or batch.
object_storage: { provider: s3, buckets: [attachments, exports] }
Declaring it adds an implicit public object-storage port (counted toward the 8-port limit) and gives every service, prepare command and action the provider's standard client variables. See Object storage.
16environments
Named flavors of the same sandbox: different starting data and config. A map of environment name to:
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
description | string | Yes | What's already there and who can do what. Write it for the agent choosing between environments. | |
extends | string | No | Another environment's name. Its seeds run first, and its env: merges first. | |
seeds | list of seed steps | No | [] | Action or emulator-operation calls, run in order when the environment's ready snapshot is built. |
env | map | No | {} | Environment variables layered on the top-level env for reload_on_prepare services, prepare and actions. Merged parent first, this environment's values last. Used as written (no ${params.…}). |
Seed step:
| Field | Type | Required | Default | Rules | Meaning |
|---|---|---|---|---|---|
id | string | Yes | ^[a-z][a-z0-9_]*$; unique across the whole resolved chain | Later steps reference this step's output as ${id.key}. | |
action | string | Yes | A declared action's name, or <plugin>.<operation> | What to call. | |
input | map of string → string, number or boolean | No | {} | Literal values, or ${id.key} references to an earlier step's output. |
environments:
default:
description: |
Workspace "Acme": owner is its owner, member a member. No projects yet.
seeds:
- id: acme
action: create-workspace
input: { name: Acme, as: owner }
- id: add_member
action: add-member
input: { workspace_id: "${acme.workspace_id}", persona: member, role: member, as: owner }
beta-features:
extends: default
description: Same as default, with the timeline view turned on.
env:
FEATURE_FLAGS: timeline
An environment has no params or personas of its own; those belong to the definition. See Seeds and actions for references and how seeds run.
17Validation rules
Beyond the types above, validate checks:
| Area | Rule |
|---|---|
| Environments | A default environment exists. Every extends names a declared environment, and no chain has a cycle. Environment env: keys are valid variable names. |
| Seeds | Step ids are unique across each resolved chain (a child can't reuse a parent's id). Each action is a declared action, or an operation of a plugin declared under emulation. Every input key is one the action declares; every required input without a default is given; literal values match the declared type, enum and, for persona inputs, a declared persona. |
| References | Each ${id.key} names a step that runs earlier in the chain, and one of that step's declared output keys, and that output is a string, number or boolean. |
| Ports | At most 8, implicit ones included. emulation and object-storage are reserved names. Ports 80 and 443 are reserved when emulation is declared; 9000 (S3) or 4443 and 4444 (GCS) when object_storage is. |
| Services | object-storage and object-storage-front are reserved names. |
| Params and env | A { port } param names a declared port (or object-storage, when declared). Every ${params.name} in env names a declared param. |
| Emulation | Every plugin id is a known plugin. |
| Build steps (GitHub) | Step names are unique. |
Pushed images (--upload) | No setup, no lifecycle.build, no after_build: true. |
validate checks that a definition is well-formed. Only a real claim proves the seed chain runs: use cofactor sandbox verify-env (see Sandbox CLI).