Docs/Sandbox definition (cofactor.sandbox.yaml) (Beta)
#

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:

bash
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, the setup script); >- folds lines into one string (a long single-sentence description).
  • Maximum size: a definition is at most 256 KB.

02Top-level fields

FieldTypeRequiredDeliveryMeaning
version1YesBothThe schema version of the file. Always 1.
resourcesobjectYesBothThe machine every sandbox of this application gets.
setupstringGitHub: yes. Pushed: not allowedGitHub onlyRoot shell script that installs the toolchain.
portsmapYes (may be {})BothNamed ports, each with its own URL.
paramsmapYes (may be {})BothPer-sandbox values referenced from env.
envmapYes (may be {})BothEnvironment variables for services, prepare and actions.
servicesmapYes (may be {})BothLong-running processes.
personasmapNo (default {})BothNamed login identities.
lifecycleobjectYesBoth (build GitHub only)build steps and prepare commands.
authenticationobjectNo; needed for native persona loginBothPrivate script that creates a session inside the current copy.
actionsmapNo (default {})BothTyped scripts that put data into a sandbox.
emulationmapNo (default {})BothEmulated third-party services.
object_storageobjectNoBothAn emulated S3 or GCS server with buckets.
environmentsmapYes; must include defaultBothNamed flavors of the sandbox: starting data and config.

lifecycle is required even for a pushed image with nothing to prepare: write lifecycle: { prepare: [] }.

03version

yaml
version: 1

The literal number 1.

04resources

yaml
resources: { vcpus: 2, memory_mib: 4096 }
FieldTypeRequiredRulesMeaning
vcpusintegerYes1–32Virtual CPUs per sandbox.
memory_mibintegerYes1024–65536Memory 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.

yaml
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

yaml
ports:
  web: { port: 3000, access: public }
  api: { port: 3001, access: public }

A map of port name to:

FieldTypeRequiredRulesMeaning
portintegerYes1–65535The port the app listens on inside the sandbox.
accesspublic | privateYespublic gets its own https URL, unique per sandbox. private is reachable only from inside the sandbox.

Rules:

  • At most 8 ports, counting the implicit emulation port (added when emulation declares any plugin) and the implicit object-storage port (added when object_storage is declared).
  • emulation and object-storage are reserved port names, whether or not those features are declared.
  • With emulation declared, ports 80 and 443 are reserved for the emulator.
  • With object_storage declared, 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:

ShapeMeaning
a stringA 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.
yaml
params:
  web_url: { port: web }
  api_url: { port: api }
  session_secret: { secret: true }

Rules:

  • port must name a declared port, or object-storage when object_storage is declared.
  • secret must be true.
  • 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.

yaml
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's env: values are used as written.

Who sees what:

RunsSees
Services with reload_on_prepare: true, prepare commands, actionsThis 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 stepsThis 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:

FieldTypeRequiredDefaultDeliveryMeaning
runstringYesBothShell command that starts the process. Use exec so the process replaces the shell.
healthobjectYesBothWhen the service counts as healthy (below).
after_buildbooleanNofalseGitHub onlyDon't start until lifecycle.build has produced its output.
reload_on_preparebooleanNofalseBothStarted 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:

ShapeHealthy 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.

yaml
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-storage and object-storage-front are reserved service names.
  • In a definition for pushed images, after_build: true is 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:

FieldTypeRequiredMeaning
emailstring (an email address)YesThe persona's login email.
yaml
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:

yaml
personas:
  owner: { email: owner@example.test }
  member: { email: member@example.test }
authentication:
  run: sandbox/auth.sh
FieldTypeRequiredMeaning
runnon-empty stringYes, when authentication is declaredScript 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 with name, value, domain, path, expires, httpOnly, secure and sameSite (Strict, Lax or None).
  • origins: entries with origin and localStorage, 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

FieldTypeRequiredDeliveryMeaning
buildlist of build stepsGitHub: yes, at least one. Pushed: not allowedGitHub onlyOrdered steps that build the checkout.
preparelist of stringsYes (may be [])BothShell commands run every time a sandbox is prepared.

lifecycle.build

GitHub only. Each step:

FieldTypeRequiredRulesMeaning
namestringYes^[a-z][a-z0-9-]{0,31}$; unique among the stepsThe step's name, shown in timings.
runstringYesNon-emptyShell command.
inputslist of stringsYesAt least one; each non-emptyGit 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.

yaml
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.

yaml
lifecycle:
  prepare:
    - bash sandbox/personas.sh

13actions

Typed scripts: the one way data gets into a sandbox. A map of action name to:

FieldTypeRequiredDefaultRulesMeaning
descriptionstringYesNon-emptyWhat it does, for the person or agent calling it.
runstringYesNon-emptyScript path, run with bash. Relative to the definition's directory (GitHub) or the image's WORKDIR (pushed).
inputmapNo{}Names: ^[a-z][a-z0-9_]*$Input properties (below).
outputmapNo{}Names: ^[a-z][a-z0-9_]*$Output properties (below).
timeoutintegerNo601–1800Seconds before the script is killed.

Input property:

FieldTypeRequiredMeaning
typestring | number | integer | boolean | personaYesThe value's type. A persona value is a declared persona's name.
descriptionstringNoShown to callers.
requiredbooleanNoThe caller must supply it (unless it has a default).
defaultstring, number or booleanNoUsed when the caller omits it.
enumlist of strings or numbersNoThe only allowed values.
sensitivebooleanNoMasked wherever action input is logged or returned.

Output property:

FieldTypeRequiredMeaning
typestring | number | integer | booleanYesThe value's type.
descriptionstringNoShown to callers.
yaml
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 {}.

yaml
emulation:
  email: {}
  jira: { tenant: acme }

Rules:

  • Each id must be a known plugin: email, google-oauth, jira or slack.
  • validate doesn't check plugin config keys; a missing jira.tenant is not reported.
  • Declaring any plugin adds an implicit public emulation port (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.

FieldTypeRequiredRulesMeaning
providers3 | gcsYesOne provider per applicationWhich API the server speaks.
bucketslist of stringsYesAt least one; uniqueBuckets 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.

yaml
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:

FieldTypeRequiredDefaultMeaning
descriptionstringYesWhat's already there and who can do what. Write it for the agent choosing between environments.
extendsstringNoAnother environment's name. Its seeds run first, and its env: merges first.
seedslist of seed stepsNo[]Action or emulator-operation calls, run in order when the environment's ready snapshot is built.
envmapNo{}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:

FieldTypeRequiredDefaultRulesMeaning
idstringYes^[a-z][a-z0-9_]*$; unique across the whole resolved chainLater steps reference this step's output as ${id.key}.
actionstringYesA declared action's name, or <plugin>.<operation>What to call.
inputmap of string → string, number or booleanNo{}Literal values, or ${id.key} references to an earlier step's output.
yaml
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:

AreaRule
EnvironmentsA default environment exists. Every extends names a declared environment, and no chain has a cycle. Environment env: keys are valid variable names.
SeedsStep 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.
ReferencesEach ${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.
PortsAt 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.
Servicesobject-storage and object-storage-front are reserved names.
Params and envA { port } param names a declared port (or object-storage, when declared). Every ${params.name} in env names a declared param.
EmulationEvery 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).