Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0010e67
feat(account): refuse every write for an abuse-paused account
jiashuoz Sep 27, 2026
52311f7
feat(clients): account_read_only across SDKs, CLI and MCP
jiashuoz Sep 27, 2026
0ce140c
feat(web): read-only banner and disabled writes for abuse-paused acco…
jiashuoz Sep 27, 2026
f717581
test(contract): read-only account conformance scenario
jiashuoz Sep 27, 2026
e434e57
docs(account): read-only accounts design, operator note and data hand…
jiashuoz Sep 27, 2026
e7acc4c
docs(cli): say -pause-class abuse also makes the account read-only
jiashuoz Sep 27, 2026
ef5b7b5
style(testutil): gofmt contract server fields
jiashuoz Sep 27, 2026
2cac8ac
fix(agent): legacy read-only guard authenticates like its handlers
jiashuoz Sep 27, 2026
b5aea5c
fix(hitlworker): expiry sweep keeps a read-only account's holds pending
jiashuoz Sep 27, 2026
b79eaac
fix(identity): keep SES sender identities when trashing a read-only a…
jiashuoz Sep 27, 2026
255cdfa
fix(httpapi): refuse a write whose principal carries no account
jiashuoz Sep 27, 2026
3bf7f50
docs(account): read-only legacy auth rule, sweep, trash and freshness…
jiashuoz Sep 27, 2026
bc9006a
test(identity): trash of an abuse-paused account keeps sender identities
jiashuoz Sep 27, 2026
90ccfb8
feat(mcp): pin the mutating flag to /v1 methods and advertise it in _…
jiashuoz Sep 27, 2026
456ad62
test(httpapi): walk raw /v1-root routes for read-only classification
jiashuoz Sep 27, 2026
3f43afd
fix(agent): an idempotent principal re-attach succeeds for a read-onl…
jiashuoz Sep 27, 2026
c7a481f
fix(agent): consent refuses only allow while read-only, after its own…
jiashuoz Sep 27, 2026
917da3b
refactor(agent): hand the guard's session user to the legacy write ha…
jiashuoz Sep 27, 2026
11bb2dd
docs(account): consent, attach replay, raw routes, MCP meta and rate-…
jiashuoz Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@ manually on every API change even though the template won't remind you.
(`cli/src/exit.ts`) are a frozen contract** — 0 ok, 1 transient, 2 usage,
3 held-for-review, 4 auth, 5 permanent request error, 6 timeout,
7 send-outcome, 8 warn (`doctor` warnings only), 9 config (`doctor` found a
definite configuration failure). Add new codes, never renumber.
definite configuration failure), 10 read-only (`account_read_only`: the
account is frozen for an abuse review). Add new codes, never renumber.
- **MCP server** (`mcp/`): inbox tools over the REST API; hosted HTTP
transport (image `ghcr.io/tokencanopy/e2a-mcp-http`). **npm publishing is
retired** (`@e2a/mcp-server` frozen at 0.5.0) — do not configure a trusted
Expand Down
10 changes: 9 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ components:
description: When a trashed account becomes eligible for permanent purge. Absent for a live account.
format: date-time
type: string
read_only:
description: "True while the account is read-only because its sending is paused pending an abuse review: every write is refused with 403 account_read_only, while reads and moving the account to the trash keep working. False otherwise. Absent when the deployment does not report it or its state is unavailable."
type: boolean
restored_at:
description: "When the account was last restored from the trash. Absent if it never was. API keys and domain verification do not survive a trash: keys must be re-created and domains re-verified after a restore."
format: date-time
Expand Down Expand Up @@ -2008,9 +2011,14 @@ components:
additionalProperties: true
properties:
code:
description: "Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account's sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."
description: "Machine-branchable error code — the stable discriminator clients switch on. Open set: treat it as a string and tolerate unknown values, since new codes may be added over time (branch on the ones you handle, fall back to the HTTP status otherwise). Exact current vocabulary (machine-checked): unauthorized, forbidden, blocked_by_policy, sending_paused, external_sending_not_enabled, registration_refused, account_read_only, invalid_request, invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope, reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty, recipient_suppressed, not_found, attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found, gone, conflict, precondition_failed, agent_taken, domain_taken, alias_taken, address_in_trash, message_held, message_not_pending, message_not_yet_delivered, not_in_trash, purge_in_progress, send_in_progress, erase_held, webhook_disabled, webhook_cooldown, domain_not_registered, domain_has_agents, domain_not_verified, inbound_mx_missing, limit_exceeded, rate_limited, contact_limit_reached, template_limit_reached, webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse, payload_too_large, attachment_too_large, not_implemented, events_log_disabled, limits_unavailable, inbound_mx_check_failed, auth_unavailable, internal_error, method_not_allowed, unsupported_media_type, error. Grouped semantics: auth: unauthorized (401), forbidden (403), blocked_by_policy (403, outbound policy gate; experimental), sending_paused (403, outbound sending is paused for the account by the platform abuse controls; queued mail is held, new sends are refused until an operator resumes; experimental), external_sending_not_enabled (403, the account may not send to one or more of the recipients through its sending identity — see ExternalSendingNotEnabledDetails for the allowed destinations and the dashboard recovery URL; nothing was queued and retrying the same request will not succeed; experimental), registration_refused (403, the sign-in identity belongs to a recently deleted or closed account and cannot register or be restored; retrying will not succeed), account_read_only (403, the account is read-only because its sending is paused pending an abuse review: every write is refused while reads and moving the account to the trash keep working; retrying will not succeed until an operator resumes the account — contact support). Validation: invalid_request is the single canonical code for input-validation failures whether they arrive as 400 (malformed) or 422 (semantically invalid); field/resource-specific invalid_* refinements (invalid_cursor, invalid_filter, invalid_domain, invalid_slug, invalid_recipient, invalid_attachment, invalid_template, invalid_event_type, invalid_webhook_url, invalid_expires_at, invalid_scope), reserved_domain, too_many_recipients, template_render_failed, template_rendered_empty (all 400); recipient_suppressed (422). Not found: not_found (404) plus the *_not_found family (attachment_not_found, contact_not_found, engagement_not_found, import_batch_not_found, template_not_found, starter_template_not_found); gone (410, past retention). Conflict/state: conflict (409, generic), precondition_failed (412, optimistic-concurrency validator is stale), the *_taken family — the requested identifier is already claimed — (agent_taken, domain_taken, alias_taken, all 409), address_in_trash (409), message_held (409), message_not_pending (409), message_not_yet_delivered (409, retry after the source outbound message is sent), not_in_trash (409), purge_in_progress (409, permanent delete already claimed), send_in_progress (409), erase_held (409, permanent deletion of the account or an agent is held while the account's sending is paused; trash instead), webhook_disabled (409), webhook_cooldown (409), domain_not_registered (400), domain_has_agents (400), domain_not_verified (400 on create-agent, 403 on send), inbound_mx_missing (400). Capacity: limit_exceeded (402, plan quota — see LimitExceededDetails), rate_limited (429, request rate — see RateLimitedDetails), contact_limit_reached, template_limit_reached and webhook_limit_reached (400, fixed per-account caps). Idempotency: idempotency_in_flight (409, wait then retry the byte-identical request), idempotency_key_reuse (422, caller bug — do not retry as-is). Size: payload_too_large (413, request body), attachment_too_large (413, inline fetch over the cap — use download_url). Availability: not_implemented (501, feature not available on this deployment), events_log_disabled (501), limits_unavailable (503), inbound_mx_check_failed (503), auth_unavailable (503, an auth backend — e.g. a delegated-token verifier or the identity store — could not judge the credential; retry). Server/fallback: internal_error (5xx), method_not_allowed (405), unsupported_media_type (415), and the generic code error for any otherwise-unmapped status."
type: string
x-e2a-error-contracts:
account_read_only:
family: auth
retryable: false
statuses:
- 403
address_in_trash:
family: state
retryable: false
Expand Down
1 change: 1 addition & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,3 +519,4 @@ only added to.
| `7` | A persisted send failed or returned an unrecognized outcome — do not retry; inspect the returned message id |
| `8` | Diagnostics (`doctor`) completed with warnings only — nothing broken |
| `9` | Diagnostics (`doctor`) found a definite configuration failure — do not retry; fix the reported configuration |
| `10` | The account is read-only (`account_read_only`): sending is paused pending an abuse review, so every write is refused — do not retry or rotate keys; contact support |
10 changes: 10 additions & 0 deletions cli/src/__tests__/exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ describe("API error exit classification", () => {
});
});

describe("account_read_only exit classification", () => {
it("maps account_read_only to its own READ_ONLY code, not AUTH or REQUEST", () => {
// The credential is valid (not AUTH) and the invocation is fine (not
// REQUEST): the account itself is frozen for an abuse review, so no write
// succeeds until an operator resumes it.
expect(exitCodeForAPIError({ code: "account_read_only", retryable: false })).toBe(EXIT.READ_ONLY);
});
});

describe("exit code contract", () => {
it("published values are frozen — add codes, never renumber", () => {
expect(EXIT.OK).toBe(0);
Expand All @@ -35,5 +44,6 @@ describe("exit code contract", () => {
expect(EXIT.SEND_OUTCOME).toBe(7);
expect(EXIT.WARN).toBe(8);
expect(EXIT.CONFIG).toBe(9);
expect(EXIT.READ_ONLY).toBe(10);
});
});
16 changes: 16 additions & 0 deletions cli/src/__tests__/format-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,20 @@ describe("formatError", () => {
it("handles a non-E2AError throw without crashing", () => {
expect(formatError(new Error("boom"))).toBe("Error: boom\n");
});

it("renders account_read_only with read-only guidance and no retry advice", () => {
const err = new E2AError({
code: "account_read_only",
message: "sending is paused for this account pending an abuse review, and the account is read-only",
status: 403,
retryable: false,
});
const out = formatError(err);
expect(out).toContain("[account_read_only]");
expect(out).toContain("read-only while its sending is paused for an abuse review");
expect(out).toContain("reads (whoami, messages, listen) still work");
expect(out.toLowerCase()).toContain("do not retry");
expect(out).toContain("contact support");
expect(out).not.toContain("e2a sending-access request");
});
});
19 changes: 19 additions & 0 deletions cli/src/__tests__/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ describe("whoami command", () => {
expect(output).not.toContain("restored:");
});

it("says the account is read-only when readOnly is set", async () => {
mockAccountGet.mockResolvedValue(makeAccount({ readOnly: true }));
const { whoami } = await import("../commands/whoami.js");
await whoami({});

const output = mockStdout.mock.calls.map((c: unknown[]) => c[0]).join("");
expect(output).toContain("read-only: yes");
expect(output).toContain("abuse review");
});

it("says nothing about read-only for a writable account", async () => {
mockAccountGet.mockResolvedValue(makeAccount({ readOnly: false }));
const { whoami } = await import("../commands/whoami.js");
await whoami({});

const output = mockStdout.mock.calls.map((c: unknown[]) => c[0]).join("");
expect(output).not.toContain("read-only");
});

it("emits raw JSON with --json", async () => {
const account = makeAccount();
mockAccountGet.mockResolvedValue(account);
Expand Down
6 changes: 6 additions & 0 deletions cli/src/bin/e2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,12 @@ function formatError(err: unknown): string {
" request approval with: e2a sending-access request --use-case <text> --recipients <text> --volume <n>\n" +
" do not retry this request as-is — the same recipients will refuse again.\n";
}
if (err instanceof E2AError && err.code === "account_read_only") {
out +=
" this account is read-only while its sending is paused for an abuse review.\n" +
" reads (whoami, messages, listen) still work; no change will succeed until the review is complete.\n" +
" do not retry or rotate keys — contact support to appeal.\n";
}
return out;
}

Expand Down
9 changes: 9 additions & 0 deletions cli/src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export async function whoami(opts: WhoamiOptions): Promise<void> {
if (account.restoredAt) {
process.stdout.write(`restored: ${account.restoredAt.toISOString()} (from trash)\n`);
}
// Read-only accounts: sending is paused for an abuse review and every write
// is refused (account_read_only, exit 10). Say so up front so a preflight
// does not go on to attempt writes that cannot succeed.
if (account.readOnly) {
process.stdout.write(
"read-only: yes (sending is paused pending an abuse review; reads still work, " +
"every change is refused — contact support)\n",
);
}

// Beta, additive: `sending_access` is omitted entirely on a deployment that
// doesn't run this control, so say nothing rather than printing a
Expand Down
9 changes: 9 additions & 0 deletions cli/src/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,19 @@ export const EXIT = {
* REQUEST (5) because nothing about the invocation itself was wrong.
*/
CONFIG: 9,
/**
* The account is read-only (`account_read_only`): its sending is paused
* pending an abuse review, so every write is refused. Distinct from AUTH
* (the credential is fine) and REQUEST (nothing about the invocation was
* wrong): no write will succeed until an operator resumes the account, so
* wrappers must neither retry nor rotate keys — contact support.
*/
READ_ONLY: 10,
} as const;

export function exitCodeForAPIError(error: { code: string; retryable: boolean }): number {
if (error.code === "unauthorized" || error.code === "forbidden") return EXIT.AUTH;
if (error.code === "account_read_only") return EXIT.READ_ONLY;
return error.retryable ? EXIT.ERROR : EXIT.REQUEST;
}

Expand Down
6 changes: 4 additions & 2 deletions cmd/e2a-contract-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ func main() {
// external-sending-access cohort (see testutil.ContractExternalAccessCutoff).
// E2A_TEST_DISPOSABLE_{TRASH,ERASE}_API_KEY authenticate the two throwaway
// accounts the account-deletion scenarios delete (once each per server).
// E2A_TEST_READONLY_API_KEY authenticates the abuse-paused (read-only)
// account; its scenario trashes it at the end (once per server).
envContent := fmt.Sprintf(
"E2A_TEST_BASE_URL=%s\nE2A_TEST_API_KEY=%s\nE2A_TEST_CAPPED_API_KEY=%s\nE2A_TEST_OVERCAP_API_KEY=%s\nE2A_TEST_RESTRICTED_API_KEY=%s\nE2A_TEST_DISPOSABLE_TRASH_API_KEY=%s\nE2A_TEST_DISPOSABLE_ERASE_API_KEY=%s\n",
"E2A_TEST_BASE_URL=%s\nE2A_TEST_API_KEY=%s\nE2A_TEST_CAPPED_API_KEY=%s\nE2A_TEST_OVERCAP_API_KEY=%s\nE2A_TEST_RESTRICTED_API_KEY=%s\nE2A_TEST_DISPOSABLE_TRASH_API_KEY=%s\nE2A_TEST_DISPOSABLE_ERASE_API_KEY=%s\nE2A_TEST_READONLY_API_KEY=%s\n",
srv.BaseURL, srv.APIKey, srv.CappedAPIKey, srv.OverCapAPIKey, srv.RestrictedAPIKey,
srv.DisposableTrashAPIKey, srv.DisposableEraseAPIKey,
srv.DisposableTrashAPIKey, srv.DisposableEraseAPIKey, srv.ReadOnlyAPIKey,
)
if envFile != "" {
if err := os.WriteFile(envFile, []byte(envContent), 0o600); err != nil {
Expand Down
Loading
Loading