Credential Resolver
Every cmdop connect call (and every connecttool agent operation)
needs a workspace credential. The credential resolver is the small
state machine that turns the soup of CLI flags, env vars, and stored
workspaces into one resolved key — and tells you which source it came
from when something goes wrong.
This page covers the resolver’s precedence, how to debug a wrong resolution, and the design rationale for the order.
The precedence chain
workspace.Resolver.ResolveCtx() (in
internal/connect/workspace/) walks six sources, top to bottom, and
returns the first one that yields a usable key:
--api-keyflag — explicit per-call override on the CLI or tool argument.CMDOP_API_KEYenv var — process-wide override.- Named workspace —
--workspace=<name>selects a specific stored workspace, even if it is not the active one. - Active workspace — whichever workspace is marked
activein~/.cmdop/ssh_workspaces.json. - Legacy —
cfg.Chat.GrpcAPIKeyfrom older configs. One-shot migration into the default workspace on first open; no longer read after that. - OAuth access token — from
cmdop login. 72-hour lifespan with refresh.
The first source that returns a non-empty key wins. The result is
tagged with a Source constant so error messages can tell you
where the key came from:
const (
SourceFlag Source = "flag"
SourceEnv Source = "env"
SourceWorkspace Source = "workspace"
SourceLegacy Source = "legacy"
SourceOAuth Source = "oauth"
)The resolver also returns a tag for “no source found” — that surfaces
as the auth_error class in server-to-server.
Why this order
Each step is more global than the last. The order is built so that a quick override always wins, and the most “passive” source (OAuth that you logged into long ago) is the fallback.
- Flag beats everything. A one-off CI run should not depend on the operator’s config file.
- Env beats stored. Containers and CI pass keys via env; that has to defeat whatever happens to be on disk.
- Named beats active. If you say
--workspace staging, the resolver should pick that workspace’s key even ifproductionis active. - Legacy is a single migration window. It exists to import old configs once, then never again.
- OAuth is the safety net. If you forgot to set up workspaces
but ran
cmdop login, you can still talk to your machines.
Concrete examples
Per-call override
cmdop connect --api-key ck_live_temp_xxx exec vps-audi -- uptimeThe flag (Source 1) wins. Env, workspaces, and OAuth are skipped entirely for this call.
Container with env
docker run --rm \
-e CMDOP_API_KEY=ck_live_xxx \
cmdop:latest connect exec prod-api-1 -- uptimeThe env var (Source 2) wins. The container has no
ssh_workspaces.json and no OAuth state, but the env value carries
the call.
Pinned workspace
cmdop connect --workspace staging exec vps-bmw -- uptimeResolver.ResolveCtx() finds staging in the local store (Source 3)
and returns its key. The active workspace is unchanged.
Active workspace
cmdop connect exec vps-audi -- uptimeNo flag, no CMDOP_API_KEY env, no --workspace. The resolver
falls to the active workspace (Source 4) and uses its key. This is
the everyday case.
OAuth fallback
cmdop login # browser flow
cmdop connect --list # no flag, no env, no stored workspaceIf no API key is set anywhere, but you have a fresh OAuth token, Source 6 carries the call. The relay sees a different credential shape (Bearer token, not API key) but the resolver papers over the difference.
Debugging which source fired
The resolver tags every Resolved struct with the Source it
picked. Errors include this tag:
auth_error: no API key for workspace "production"
→ tried: flag (none), env (none), workspace=production (no key),
legacy (no value), oauth (no token)For unstructured calls, the tag shows up in --json output:
$ cmdop connect --json --list
{
"credential": {
"source": "workspace",
"workspace": "production",
"key_preview": "ck_live_***...***xa3b"
},
"machines": [ ... ]
}MaskKey() (in internal/connect/workspace/types.go) is the helper
that builds the masked preview. It always shows the first few and
last few characters; the middle is fixed-width asterisks.
Per-call vs persistent config
A short cheat sheet on which knob to use when:
| You want | Use |
|---|---|
| One-off override for a single command. | --api-key flag. |
| Override for an entire shell session or CI job. | CMDOP_API_KEY env. |
| Switch defaults for the next 100 commands. | cmdop connect workspace use <name>. |
| Inject a key for a specific named workspace. | cmdop connect key set <key> (after workspace use). |
| Forget the key and rely on OAuth instead. | cmdop connect key clear and cmdop login. |
Workspace --workspace vs activate
--workspace=<name> is per-call. It resolves to that workspace’s
credentials without changing the active workspace. Two side-by-side
calls with different --workspace values do not race or interfere.
cmdop connect workspace use <name> mutates the on-disk active
pointer. The next daemon connection picks up the new workspace ID
and reconnects. If your daemon is currently connected, this triggers
a relay reconnect — see workspaces for the full
note.
Server override
A workspace can carry a per-workspace gRPC Server override. When
set, the resolver returns it alongside the key, and the dial uses
that endpoint instead of the default cloud relay. Most users never
touch this; on-prem deployments and test environments are the typical
consumers.
What the resolver does not do
- It does not fetch keys from the server.
cmdop connect workspace syncdoes that, on demand. - It does not validate keys. A wrong key will resolve cleanly,
carry to the relay, and fail there with
unauthenticated. - It does not care which machine you are targeting. Workspace credentials gate the relay; per-machine identity is enforced separately.
- It does not store anything. Reading is via
DefaultStore(); writing is the explicitcmdop connect key set/workspace usepaths.
When OAuth and an API key both exist
This combination is common: you ran cmdop login for the dashboard,
and you also stored an API key for one workspace. The resolver
prefers the API key (Source 4) over the OAuth token (Source 6).
Concretely: if your active workspace has a stored API key, that is
what flows. If you cmdop connect key clear for that workspace,
the next call falls through to OAuth.
This is the behavior you want — explicit local config beats whatever your dashboard session carries — but it surprises people who expect the dashboard’s identity to win.
A stored API key beats the OAuth fallback. To force the OAuth path,
clear the key (cmdop connect key clear) or switch to a workspace
that has no key set.
Sources, recapped
1. --api-key <flag> ← single call
2. CMDOP_API_KEY <env> ← process / container
3. --workspace=<name> ← single call, picks stored workspace
4. active workspace key ← persistent default
5. legacy cfg.Chat.GrpcAPIKey ← migration only, one-shot
6. OAuth from `cmdop login` ← safety net
↓
(none) → auth_errorRelated
Where stored credentials live and how to switch them.
The other layer — machine passwords and the session token cache.
Where --api-key, --workspace, and key set/get/clear are
documented in CLI form.
auth_error is the resolver’s “no source” return path.