Per-person Copilot credentials¶
How a hub with single sign-on keeps each person's GitHub Copilot use on their own seat (for runs on the hub's own host, the dashboard's model calls and the runs the hub carries on by itself), and why that credential is a personal secret and not a login on the hub.
The copilot provider runs the GitHub Copilot CLI, which signs in with the
first credential it finds: COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN,
the account copilot login stored under its home (~/.copilot), and then the
GitHub CLI's. A run on the hub's own host inherits the hub's environment and
HOME. Before per-person credentials, on a hub with more than one person on it,
every person's copilot run, every Split, Revise, blocker analysis and
plan-assistant call on a copilot project, and every resume or failover of one
signed in as whoever the hub's environment named: the operator's
copilot login, or a GH_TOKEN the service holds for something else entirely.
The CLI also wrote everybody's sessions and logs, conversations included, into
one directory. Per-user Claude Code logins closed
the same gap for Claude, and this page is its Copilot equivalent.
There is no new configuration key: single sign-on turns it on. A hub without single sign-on has one operator, whose sign-in is the one intended, and nothing here changes for it.
- The credential is a personal secret
- What it closes
- Granting it
- The Copilot home
- What a run inherits
- What is pinned, and how
- Runs the hub carries on by itself
- Shared credentials and callers with no identity
- Deprovisioning
- What keeps it that way
- What this does not do
The credential is a personal secret¶
There were two possible designs: a per-person COPILOT_HOME signed in through a
dashboard flow, the way the Claude login panel signs in a per-person Claude
home, or the person's own COPILOT_GITHUB_TOKEN, kept as their
personal secret and granted to the project. cloop
uses the second, for four reasons:
| At rest | A personal secret is sealed by the secret broker (envelope encryption, the key-encryption key optionally held by a KMS), and no API ever returns its value. copilot login keeps a long-lived OAuth token in a file under the hub user's home, or in a keychain the hub's service rarely has, where anything running as that user can read it. |
| Everywhere | One credential serves the hub's host and every sandbox. The same grant that signs in a run on the hub's host reaches a container, a Pod or an enrolled device, which a login on the hub's disk never could. The sandbox preflight already accepted this. |
| Lifecycle | Offboarding destroys personal secrets and revokes their grants, and a deactivation at the identity provider suspends the grants and takes them back from running workloads. A login file would need all of that built again. |
| Spent by its owner only | A personal credential counts only for runs its owner starts and is withheld from every other person's lease (Requester.Withhold). |
There is still a per-person COPILOT_HOME, for the CLI's sessions and logs,
but it holds no credential.
The token must act for a person with a Copilot seat: a fine-grained personal
access token (github_pat_…) with the Copilot Requests permission, an OAuth
token (gho_…) or a GitHub App user token (ghu_…). A classic token (ghp_…)
is refused, because the CLI does not accept one, and so are installation and
refresh tokens (ghs_…, ghr_…), which act for no person
(TestClassifyPastedGitHubTokens).
What it closes¶
| Before, on a hub with single sign-on | |
|---|---|
| Whose seat | Every person's copilot work ran on the hub operator's Copilot seat, or on a token the service held for something else, because GH_TOKEN and GITHUB_TOKEN take precedence over the stored login. |
| Somebody else's token | A wildcard repository grant puts its token in GH_TOKEN and GITHUB_TOKEN (see secrets), so a run without its own Copilot credential would sign the CLI in with it. |
| Where it is sent | GH_HOST, COPILOT_GH_HOST, COPILOT_API_URL and the bring-your-own-key variables in the hub's environment said where the CLI presents a credential. |
| Conversations | ~/.copilot held every person's sessions and logs in one directory. |
| No credential | A person who had none was served on the hub's. |
Granting it¶
A copilot project on the hub's own host has the same Copilot credential card on its Overview as a sandboxed one. On a hub with single sign-on the card is specific to the viewer: it says whether their runs would sign in, and with what. Paste a token there and it is stored as your personal secret and granted to the project (a feature's grant goes to its parent) for 30 days, unless you choose otherwise. Or pick a personal secret you already have. From a shell:
A run, a model call or a replay by someone with no credential that counts for
them is refused before anything starts, with 409 harness_credential_missing,
and the dashboard opens that dialog. The response body carries
per_person: true and a short summary. Nothing falls back to the hub's
sign-in.
A bring-your-own-key grant
(COPILOT_PROVIDER_BASE_URL with its key) counts as well. The run then talks
to that provider, with that key.
The Copilot home¶
A person's Copilot home is copilot/ inside their identity home, the private
directory their Claude Code login
lives in, under ~/.config/cloop/claude-identities/<slug>/ of the user the hub
runs as. Keeping both in one tree means that everything that removes the
identity home also removes this one (an offboarding on every hub member, the
hourly sweep of denied identities' homes, a deletion at the identity provider),
and a legal hold that keeps one keeps the other.
It is created with mode 0700 when its owner starts something on a copilot
project. A symbolic link in its place is refused, not followed. A run the hub
carries on by itself finds the home, and creates only the copilot/ directory
inside an identity home that already exists, so it never recreates the home of
someone offboarded. A dispatch acting for no one uses copilot-unattributed/
beside the identity homes, never the hub user's own ~/.copilot.
What a run inherits¶
The hub decides whom a dispatch acts for, and the provider starts the CLI, often
in another process (the cloop run the hub started). The two communicate
through the environment (pkg/copilotauth):
- Before the lease, the hub appends an empty assignment for each of the
Copilot CLI's own variables in its environment: every
COPILOT_*andGITHUB_COPILOT_*variable. Exec keeps the last assignment of a key, and the CLI reads an empty one as unset. Only the CLI reads these. The GitHub tokens and host stay for the run's other tools, and step 4 removes them from the CLI's environment. - The lease then delivers the project's grants as usual: the person's own, or one shared with the project. Another person's personal credential is withheld.
- After everything else, the hub pins
CLOOP_COPILOT_HOMEto the person's Copilot home. This comes last so that no grant, not even one of the person's own that names the variable, can point a run at somebody else's home (TestAGrantCannotRepointACopilotRunsHome). - The provider, seeing
CLOOP_COPILOT_HOME, builds the CLI's environment from scratch. EveryCOPILOT_*,GITHUB_COPILOT_*,GH_TOKEN,GITHUB_TOKEN,GH_ENTERPRISE_TOKEN,GITHUB_ENTERPRISE_TOKEN,GH_HOSTandGH_CONFIG_DIRit inherited is removed, not just blanked. The run's own credential is passed back:COPILOT_GITHUB_TOKEN, or a provider's URL with the variables that go with it, but neverCOPILOT_PROVIDER_API_KEY_COMMAND, which runs a command, nor a variable that names a host.COPILOT_HOMEis set to the person's home, andGH_CONFIG_DIRto an empty directory inside it, so neither the CLI's stored login nor the GitHub CLI's can stand in for a missing credential. A run with no credential left, or whose home is gone, is refused before the CLI starts.
With the real CLI against a stand-in model server, a pinned run signs in to the
model with the person's key, keeps its session and logs in their home and none
in the hub's, and its shell sees none of the hub's tokens, hosts or settings
(TestRealCLIPinnedToAPersonRunsAsThemOnly; it runs where the CLI is
installed, which you point it at with CLOOP_COPILOT_CLI, and is skipped in CI,
which has no CLI). With a recording stub in place of the CLI:
TestAPinnedRunSignsInAsItsPersonOnly,
TestAPinnedRunWithNoCredentialNeverStartsTheCLI,
TestAPinnedRunWhoseHomeIsGoneIsRefused and
TestAPinnedInlineCallTakesItsCredentialFromTheCall.
What is pinned, and how¶
| Work | Acts for | How |
|---|---|---|
| A run on the hub's host: Run, the Tasks tab's run bar, a new project's first run | the person who started it | the dispatch's clearance (harnessClearanceFor) settles the credential and gives the dispatch the blanks and the pin (TestCopilotHostRunActsForItsPerson) |
| The Split preview, Revise, the blocker analysis and the plan assistant | the caller | dispatched to the project's executor as helper subcommands, under the same clearance (TestCopilotModelCallsActForTheCaller) |
| The review gate's reviewer | the run's person | it runs inside the run's own cloop run, so it inherits the run's pinned environment, on any project. Every harness dispatch to the hub's host is pinned whatever its own provider, since a claudecode project's reviewer may be copilot. Only a copilot project needs a Copilot credential to be dispatched at all, and a reviewer with none fails its review instead of signing in as the hub. The card of a project with a different provider grants that provider's credential, so grant a reviewer's COPILOT_GITHUB_TOKEN from the Secrets panel or with cloop secret grant (TestEveryHostRunIsPinnedWhateverItsProvider) |
| The provider-call replay and the inline task replay | the caller | they run the provider in the hub's own process. On a hub with single sign-on, inlineHarnessEnv pins every such call to the caller's Copilot home, whatever provider it names. When one of them is copilot, in any letter case (the provider factory ignores case in names), it settles the caller's credential against the project's executor and leases it for that call alone |
| The automatic resume, its hand-off to another hub member, and a failover's replacement | the run's recorded starter | below |
A copilot project bound to an executor that isolates from the hub was already per-person: a sandbox gets only what is granted to its project, and a personal grant counts only for its owner's runs. That has not changed.
End to end, TestHubCopilotActsForThePersonEndToEnd runs a hub with single
sign-on (internal/ssohubtest) whose own environment carries a Copilot token,
GitHub tokens and a GitHub host. It dispatches the real cloop run and the real
subcommands to the test binary, with a stub copilot that records what each
call signed in with. Alice's and Bob's model calls, Alice's run, Bob's replay of
one of Alice's calls and the automatic resume of Alice's run each sign in with
that person's own token, in their own Copilot home, with nothing of the hub's.
Carol, who granted nothing, is refused every one of them, and the CLI never runs
for her.
Runs the hub carries on by itself¶
The automatic resume after a usage cap, the member a leader hands it to, and a
failover's replacement act for the person who started the run they continue,
as for Claude. Their
credential must count for them, and their Copilot home must still exist. If
not (their grant was revoked or expired, they were suspended at the identity
provider, or their identity home is gone), the dispatch is refused before
anything is leased, and the run waits, paused as starter_unavailable and
naming them: has no Copilot credential on this hub that the hub may use for
them. It is not resumed on a timer or on anyone else's credential. Whoever
presses Run starts it as themselves (TestCopilotResumeActsForItsStarter,
TestResumeClearanceFindsTheStartersCopilotHome).
Shared credentials and callers with no identity¶
A credential that an administrator grants to the project as a shared secret
counts for every person's runs, as it does in a sandbox, because the
administrator has decided that the project's work bills that seat. Each
person's run still keeps its sessions in their own Copilot home
(TestCopilotHostRunOnASharedCredential).
A caller that acts for no one (the static admin token, an API token minted on
nobody's behalf) has no personal credential, so only a shared one can sign its
runs in, and without one they are refused. Unlike a Claude run, such a run is
never served on the hub's own sign-in (TestPreflightCopilotOnTheHubsHost).
Deprovisioning¶
Offboarding (cloop hub user offboard, the dashboard, a deletion at the
identity provider) destroys the person's personal secrets, including their
Copilot credential, revokes every grant over them, and removes their identity
home on every hub member, together with the Copilot home inside it. Under a
legal hold the secret and the home are kept, but the grants are still revoked
(TestSeveringTheIdentityHomeTakesTheCopilotHomeWithIt).
A deactivation at the identity provider suspends the person's grants in the
same transaction that ends their sessions and tokens, and takes the credential
back from every running workload holding it. From then on it counts for no run:
theirs are refused, and the hub carries none on for them. The secret itself
stays sealed while they are suspended, like every personal credential, because
an identity provider can reactivate people. It is destroyed when the provider
deletes them, or when the deactivation lasts longer than
ui.oidc.scim.offboard_after_days (TestSCIMReachesACopilotCredential).
What keeps it that way¶
tests/archrequires every function that leases to take the clearance's Copilot environment between settling the clearance and leasing, and to apply the pin after the lease. It allows a dispatch under a lease clearance (which checks nothing and pins no Copilot account) only from listed callers whose workload calls no model (TestEveryLeaseTakesTheCopilotPin,TestCopilotGateFlagsWhatItShould). It sits beside the nil-resolver gate that pins Claude (TestEveryDispatchPinsAClaudeAccount).tests/securityrequires every start of the copilot binary to take its environment from the one builder that applies the per-person mode (TestEveryCopilotSpawnPinsItsEnvironment), and checks what that mode strips (TestCopilotPinningStripsWhatTheCLISignsInWith). It also extends the call-graph analysis: an HTTP handler that can run the Copilot CLI in the hub's own process, not through an executor, must be listed and must pin its caller withinlineHarnessEnv, and no background code in the hub reaches it (TestHandlersRunningTheCopilotCLIPinTheirCaller,TestCopilotInlineCheckFlagsWhatItShould).
What this does not do¶
- The hub's host is not a boundary between people. A run on the hub's own host runs as the user the hub runs as. Its agent can read that user's files, including other people's identity homes, and the environment of other runs on the host. Pinning keeps every run on its own person's credential, but it does not stop a person's agent from going looking. Where people must be isolated from each other's agents, bind the project to an executor that isolates from the hub.
- The token is in the run's environment. The Copilot CLI keeps
COPILOT_GITHUB_TOKENout of the commands its agent runs, but other processes of the run (hooks, a verification script) inherit it, as they inherit a sandbox's leased credentials. ghin the agent's shell is not signed in on a pinned run:GH_TOKENandGITHUB_TOKENare removed from the CLI's environment, including a repository grant's, and its GitHub CLI configuration is empty. The agent reaches the repositories it was granted through git, whose credential helper the grant installs.- The pin is inherited. Everything a person's run starts inherits
CLOOP_COPILOT_HOME, including acloop runor acopilotthat its agent starts in a shell, so a Copilot CLI anywhere under that run signs in as that person or not at all. A developer running cloop's own test suite inside such a run is not affected (internal/hometestclears it). One running their own copilot rig there is affected, and is refused without a credential leased to it. - No usage caps and no model relay. Claude Code's subscription caps and the model relay apply to Claude only. A copilot run's model calls go directly to GitHub, or to the project's own provider.