Executor version: v1.6.8
Surface: self-hosted on Cloudflare Workers (apps/host-cloudflare), behind Cloudflare Access
Related: #1958 (the console-side half of this)
What happened
v1.6.8 (#1919) introduced the organization role model. On host-cloudflare, roles are derived from the Cloudflare Access JWT:
const email = typeof claims.email === "string" ? claims.email : "";
const commonName = typeof claims.common_name === "string" ? claims.common_name : "";
const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase());
orgRole: isAdmin ? "admin" : "member",
A Cloudflare Access service token authenticates with common_name and carries no email claim — this is how Access works and cannot be configured otherwise. isAdmin is therefore unreachable for any machine identity. The existing unit test states it as intended behaviour:
// a token is a member, not an admin
expect(p.roles).toEqual(["member"]);
The consequence on an Access-gated deployment is that every non-browser identity permanently loses all workspace writes. In our case that is four machines that reach the gateway with the documented service-token headers. They can still read and execute tools, but from inside execute they can no longer:
executor.mcp.addServer (register an MCP server)
executor.coreTools.oauth.clients.registerDynamic
executor.coreTools.oauth.start for an owner: "org" connection
executor.coreTools.connections.refresh / remove on a workspace connection
All of these now return OrgWriteDeniedError. Since owner: "org" is the only ownership that machine identities can see at all (a user-owned connection belongs to the browser principal and is invisible to a service token), the practical effect is that automated setup and repair of the gateway is no longer possible from any machine — every one of those operations now requires a human in a browser.
What I expected
An Access-gated single-tenant deployment should be able to designate trusted machine identities as administrators, in the same way ADMIN_EMAILS designates trusted humans.
Suggested fix
An ADMIN_COMMON_NAMES environment variable alongside ADMIN_EMAILS, checked when the JWT carries common_name instead of email:
const isAdmin =
(email.length > 0 && config.adminEmails.includes(email.toLowerCase())) ||
(commonName.length > 0 && config.adminCommonNames.includes(commonName.toLowerCase()));
This keeps the default closed — a service token is still a member unless explicitly listed — while letting an operator opt a known automation identity in. It mirrors the existing ADMIN_EMAILS shape, needs no schema change, and does not affect cloud or self-host.
Steps to reproduce
- Deploy
apps/host-cloudflare at v1.6.8 behind Cloudflare Access with a service-token policy.
- Call the gateway with
CF-Access-Client-Id / CF-Access-Client-Secret.
GET /api/account/me → "email": "", identity is the token's common_name.
- From inside
execute, call any workspace write, e.g. executor.coreTools.connections.refresh({ owner: "org", ... }) → org_write_denied.
Note
We hit this while trying to repair an expired workspace OAuth connection. Combined with #1958 — where the console hides Reconnect/Remove from admins because me never returns orgRole — there was no path to repair the connection at all: not from the machines (member), and not from the browser (buttons hidden). We had to roll the Worker back to the pre-1.6.8 version to recover.
Executor version: v1.6.8
Surface: self-hosted on Cloudflare Workers (
apps/host-cloudflare), behind Cloudflare AccessRelated: #1958 (the console-side half of this)
What happened
v1.6.8 (#1919) introduced the organization role model. On
host-cloudflare, roles are derived from the Cloudflare Access JWT:A Cloudflare Access service token authenticates with
common_nameand carries noemailclaim — this is how Access works and cannot be configured otherwise.isAdminis therefore unreachable for any machine identity. The existing unit test states it as intended behaviour:The consequence on an Access-gated deployment is that every non-browser identity permanently loses all workspace writes. In our case that is four machines that reach the gateway with the documented service-token headers. They can still read and execute tools, but from inside
executethey can no longer:executor.mcp.addServer(register an MCP server)executor.coreTools.oauth.clients.registerDynamicexecutor.coreTools.oauth.startfor anowner: "org"connectionexecutor.coreTools.connections.refresh/removeon a workspace connectionAll of these now return
OrgWriteDeniedError. Sinceowner: "org"is the only ownership that machine identities can see at all (auser-owned connection belongs to the browser principal and is invisible to a service token), the practical effect is that automated setup and repair of the gateway is no longer possible from any machine — every one of those operations now requires a human in a browser.What I expected
An Access-gated single-tenant deployment should be able to designate trusted machine identities as administrators, in the same way
ADMIN_EMAILSdesignates trusted humans.Suggested fix
An
ADMIN_COMMON_NAMESenvironment variable alongsideADMIN_EMAILS, checked when the JWT carriescommon_nameinstead ofemail:This keeps the default closed — a service token is still a member unless explicitly listed — while letting an operator opt a known automation identity in. It mirrors the existing
ADMIN_EMAILSshape, needs no schema change, and does not affect cloud or self-host.Steps to reproduce
apps/host-cloudflareat v1.6.8 behind Cloudflare Access with a service-token policy.CF-Access-Client-Id/CF-Access-Client-Secret.GET /api/account/me→"email": "", identity is the token'scommon_name.execute, call any workspace write, e.g.executor.coreTools.connections.refresh({ owner: "org", ... })→org_write_denied.Note
We hit this while trying to repair an expired workspace OAuth connection. Combined with #1958 — where the console hides Reconnect/Remove from admins because
menever returnsorgRole— there was no path to repair the connection at all: not from the machines (member), and not from the browser (buttons hidden). We had to roll the Worker back to the pre-1.6.8 version to recover.