Error codes
Every code penv and penv.cloud can answer with, the exit code each one maps to, and the four error shapes you can meet.
Four things answer you with a code. The command line, the penv.cloud API, the endpoint your identity provider writes to, and the AWS connect callback. Each has its own shape, and each shape is below with the codes it can carry.
What penv prints
A refusal is one object on stderr, with the same three members every time.
{
"error": "no_credential",
"message": "penv has no credential for this host.",
"fix": "Run penv login, or set PENV_TOKEN."
}message says what happened. fix says the one thing to do next. A refusal that has to be replayed
carries what the replay needs beside those three, so approval_required also carries approval,
url and expiresAt.
At a terminal the same refusal reads as one line: error:, the message, then the fix. The JSON form
is what you get when stdout is not a terminal, when you pass --json, or when penv detects an
agent.
Exit codes
| Code | Name | What it means |
|---|---|---|
0 | ok | The command did what it says. |
1 | error | Something went wrong. |
2 | auth | Not signed in, or the credential was rejected. |
3 | validation | The schema or the values did not pass. |
4 | confirmation | A person has to confirm. The JSON carries the command to replay. |
5 | no_credential | No credential is available and none can be obtained. |
6 | environment_refused | This identity may not read that environment. |
penv run is the exception worth knowing: once the child starts, penv leaves with the child's exit
code rather than its own.
Every code the command line answers with
error | Exit | What happened |
|---|---|---|
agent_session | 2 | login, pull or push --prune was run under an agent session. |
ambiguous | 1 | The workspace and project name more than one thing on the server. |
ambiguous_output | 1 | --output names one file and more than one language target applies. |
approval_denied | 2 | A person refused the reveal in the console. |
approval_expired | 4 | The ten minutes ran out before the id was redeemed. Ask for a new one. |
approval_mismatch | 4 | The approval id covers another key. |
approval_redeemed | 4 | That id was already spent, and each one prints a value once. |
approval_required | 4 | A person has to approve this reveal. The id and the page are in the answer. |
bad_url | 1 | PENV_URL is not an address penv will send a credential to. |
binary_lost | 1 | An upgrade left no binary in place. Reinstall. |
checksum_mismatch | 1 | A download is not the file its checksum names. Nothing was replaced. |
child_failed | 1 | The command penv run started could not be waited for. |
cloned | 2 | This host's key was seen on another machine, so the identity is locked. |
cloud_failed | 1 | The client failed in a way with no better name. |
denied | 2 | The device-code sign-in was denied in the console. |
dynamic | 3 | That key is minted by an engine. Edit it in the console. |
empty_value | 3 | penv set was given nothing to write. |
environment_refused | 6 | This identity may not read that environment, or local mode was asked for one it does not have. |
expired | 2 | The login expired, or the device code expired before anyone approved it. |
guard_failed | 3 | A harness file or a guard folder could not be merged. penv hook answers 1. |
invalid_key | 3 | The key name is not upper snake case. |
invalid_schema | 3 | .env.schema does not parse. Run penv check for the list. |
keychain | 1 | The operating system keychain would not open. |
managed_install | 1 | npm, Homebrew, Nix, winget or Scoop owns this binary. |
missing_asset | 1 | The release carries no asset for this machine. |
no_approval | 4 | The approval id is not on this server. Ask for a new one. |
no_checksum | 1 | The checksum file lists no digest for this asset. |
no_command | 1 | penv run was given nothing to run. |
no_credential | 5 | This host can prove nothing. Run penv login, or set PENV_TOKEN. |
no_current_exe | 1 | penv could not read its own path. |
no_echo_off | 1 | The terminal will not stop echoing, and a value is never shown. |
no_keychain | 1 | An enrolled key would not survive the process on this host. |
no_org | 1 | This account is in no workspace yet. |
no_release | 1 | There is no release to read. |
no_schema | 1 | No .env.schema here or in any directory above. Run penv init. |
no_secret | 1 | penv machine enroll needs the one-time secret from the console. |
no_such_org | 1 | This account is in no workspace by that name. |
no_value | 3 | That key has no value in that environment. |
not_cloud | 1 | .env.schema names no cloud project. Run penv push to create one. |
not_found | 1 | That project or environment is not on this server. |
offline | 5 | The server could not be reached, and only a cached development environment runs offline. |
org_required | 1 | This account is in several workspaces. Name one with --org. |
output_outside_repo | 3 | The generated file would land outside the repository. |
quota_exceeded | 1 | The plan's project limit is reached. |
rate_limited | 1 | The server, or the release host, is throttling this identity. |
release_unreachable | 1 | The release address could not be read. |
schema_exists | 3 | .env.schema is already there. Use penv init --force to write it again. |
server_error | 1 | The server answered 5xx twice. |
server_refused | 1 | The server answered a status penv has no better name for. |
signature_invalid | 1 | The release checksum file is not signed by a penv release key. |
signature_missing | 1 | The release carries no signature for its checksum file. |
spawn_failed | 1 | The command penv run was given could not be started. |
target_failed | 3 | A language target folder would not load or render. |
unauthorized | 2 | The credential is not accepted by this server. |
unexpected_answer | 1 | The server said nothing had changed for a read that asked for everything. |
unknown_command | 1 | penv help was given a command penv does not have. |
unknown_harness | 1 | penv has no guard for that harness. |
unknown_key | 3 | penv check was given a key the schema does not declare. |
unknown_shell | 1 | penv writes no completion script for that shell. |
unknown_target | 1 | No language target by that name, or no release build for this machine. |
unreadable_file | 1 | A file could not be read. |
unreadable_input | 1 | A value could not be read from stdin or the terminal. |
unreadable_release | 1 | The release address answered something that is not a release. |
unreadable_selection | 1 | An answer to a prompt was not one of the choices. |
unsigned_build | 1 | This build carries no release key, so it cannot upgrade at all. |
unwritable_binary | 1 | The binary could not be replaced. Nothing changed. |
unwritable_file | 1 | A file could not be written. |
unwritable_value | 1 | A value cannot be written into a .env in any portable way. |
value_on_the_command_line | 1 | penv set takes no --value, because the shell would keep it in its history. |
bad_url covers four shapes of PENV_URL. penv accepts https anywhere, and plain http only for
127.0.0.1, localhost and [::1]. It refuses plain http to any other host, an address carrying a
user in front of its host, an address naming no host at all, and any scheme other than those two.
The refusals penv upgrade can answer with are on
upgrade penv, with what to do about each one.
What penv.cloud answers
Every route under /api/v1 answers JSON, and an error body is { "error": "<code>" }. The two
schema-shaped refusals add a detail member saying which part of the body was wrong.
| Status | Code | What it means |
|---|---|---|
| 400 | name_required | The request names no key. |
| 400 | name_invalid | The key name is not a usable name. |
| 400 | key_invalid | The key an approval was asked for is not a usable name. |
| 400 | keys_required | A push carried no keys array. |
| 400 | address_required | An approval request named no workspace, project and environment. |
| 400 | body_required | A write carried no body. |
| 400 | value_or_schema_required | A key write carried neither a value nor a schema. |
| 400 | value_must_be_a_string | The value in the body is not a string. |
| 400 | schema_invalid | The per-key schema is not the shape penv schema --json emits. |
| 400 | token_required | An exchange carried no platform token. |
| 400 | signed_request_required | The AWS exchange carried no signed request. |
| 400 | signed_challenge_required | The keypair exchange carried no signed challenge. |
| 400 | credential_id_required | A challenge was asked for without a credential id. |
| 400 | enrollment_required | An enrollment carried no secret or no public key. |
| 400 | ttl_must_be_an_integer | A lease asked for a lifetime that is not a whole number of seconds. |
| 401 | expired | The credential is past its expiry. Run penv login again. |
| 401 | unauthorized | The credential is not accepted. Revoked and unknown read the same. |
| 401 | replayed | A keypair challenge nonce was used twice. |
| 403 | forbidden | The credential is real and does not carry this permission or this environment. |
| 403 | denied | A person denied the device-code sign-in. |
| 403 | refused | A delivery exchange came from a workflow trigger the workspace does not trust. |
| 403 | lease_api_disabled | Leases are switched off for this environment. |
| 404 | not_found | No such project, environment, key or approval, for you. |
| 409 | dynamic | The key is minted on request, so it cannot be written or read as a stored value. |
| 409 | cloned | The keypair generation counter does not match. The identity is revoked. |
| 409 | quota_exceeded | The plan's project limit is reached. |
| 409 | ambiguous | The workspace or project name resolves to more than one thing. |
| 409 | engine_unavailable | The engine behind a dynamic key would not answer. |
| 410 | expired | The device code expired before anyone approved it. |
| 428 | authorization_pending | Nobody has approved the device code yet. Keep polling. |
| 429 | rate_limited | An IP or plan limit. retry-after says how long. |
| 429 | slow_down | You are polling the device code faster than the interval. |
| 429 | ceiling_reached | The abuse ceiling on dynamic leases. |
| 500 | mint_failed | The engine accepted the request and produced nothing. |
| 503 | unavailable | A dependency is unreachable or unconfigured. Retry once. |
Anything else in the 500s is retried once after a second, and then reported as server_error
naming the status.
The four approval codes
penv reveal under an agent goes through a person. The state of that request is always a 409, and
the code says which of the four states it is in.
| Code | What it means | What penv does |
|---|---|---|
approval_pending | Nobody has answered yet, or an open request for this key and session already exists. | Exit 4, with the page and the expiry. |
approval_denied | A person refused it. | Exit 2. Ask them, then ask again. |
approval_expired | The ten minutes ran out. | Exit 4, and tells you to run penv reveal for a fresh one. |
approval_redeemed | The id was already spent. | Exit 4, and tells you to run penv reveal for a fresh one. |
approval_denied, approval_expired and approval_redeemed live on both sides: the server sends
each one on the 409, and penv prints it back under the same name with the exit code above.
approval_pending is the one that changes name, because penv turns it into approval_required and
hands you the page and the expiry with it. Two codes are penv's alone. A 404 from the redeem route
becomes no_approval, and approval_mismatch is raised before penv ever asks the server to spend
the id.
An id that covers another key never gets spent: penv reads the approval before it redeems and
refuses with its own approval_mismatch. See coding agents for the
whole flow.
Your identity provider
The endpoint your directory writes to speaks RFC 7644 rather than the shape above, because that is
what Okta and Entra parse. The body is a SCIM error object, status is a string, and scimType
carries one of the RFC's detail keywords when there is one.
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "409",
"scimType": "uniqueness",
"detail": "A user with that userName already exists."
}The content type is application/scim+json;charset=UTF-8. A throw nobody recognized becomes a 500
whose detail is always the same sentence, "The request could not be completed.", because the
alternative is handing a stack trace to whoever holds a directory token.
The AWS connect callback
The callback your CloudFormation stack posts to answers a closed set of six, and one mapper decides which. A fresh create answers 200 with the lifecycle secret, a replayed create answers 200 with an empty object, and every other success is 204 with no body.
| Code | Status | What it means |
|---|---|---|
invalid_request | 400 | The body is not the shape the callback takes. |
unauthorized | 401 | Anything about the token or the External ID that did not hold. |
conflict | 409 | The stack is at odds with what this workspace already has. |
upstream | 502 | AWS answered something the callback cannot act on. |
retry | 503 | Try again. retry-after is 5 seconds. |
unexpected | 500 | Nothing else fits. |
An unknown token, an expired one, one already claimed, one out of attempts, a wrong External ID and a bad lifecycle secret all answer the same 401. The endpoint tells a caller whether they got in, and nothing else, so it cannot be used to find out which of those was true.
Next: global options.