Skip to content

feat(cli): discovery, named contexts, login by URL, CI mode, no-Cloud guard [DEP-238] - #934

Open
pmdroid wants to merge 18 commits into
mainfrom
dep/dep-238-cli-discovery-contexts
Open

pmdroid wants to merge 18 commits into
mainfrom
dep/dep-238-cli-discovery-contexts

Conversation

@pmdroid

@pmdroid pmdroid commented Sep 14, 2026

Copy link
Copy Markdown
Member

What & why

DEP-238. Teaches the arcade CLI to target a self-hosted Arcade installation instead of only Arcade Cloud.

  • Discovery + login by URLarcade login --url <install-url> (or ARCADE_URL) fetches the installation's discovery document at GET /.well-known/arcade, signs in against the discovered coordinator, picks a project, and saves a named context. Deployments-off installations report the discovery reason and exit non-zero; a URL with no discovery document is refused before anything is sent (the "installation predates discovery" path).
  • Named contexts — credentials now hold multiple named contexts (engine/coordinator/dashboard URLs + a cloud/self_hosted kind + auth/org/project). The legacy single-context file migrates to a default context on load. arcade context list|use|show manages and switches them.
  • No-flag targetingarcade deploy, secret, org, and project resolve their target from the active context; no flags needed. Unauthenticated deploy names the login command.
  • CI modeARCADE_URL + ARCADE_API_KEY complete a deploy with no terminal and no credentials file (bearer API key auth).
  • No-Cloud guard — with a self-hosted context active, any attempt to contact an Arcade Cloud host (api.arcade.dev, cloud.arcade.dev, *.arcade.dev) is refused. The hardcoded cloud defaults in deploy/secret/org/project were routed through the active context.

Cloud users see no prompt and no new required flag.

Tests

libs/tests/cli/test_context.py, test_login_url.py, test_contexts_cmd.py, test_deploy_context.py:
legacy→default migration + named-context round trip, discovery parsing + predates path, cloud-host detection, the no-Cloud guard, login by URL (incl. disabled-deployments and missing-discovery), the context command, unauthenticated deploy naming login, and a CI-env deploy driven without prompts. Full libs/tests/cli suite green; ruff + mypy clean on changed libs.

Proof

Backend/CLI-only change with no dashboard surface. Verified on the CLI via the unit suite above (behavior asserted with mocked discovery + OAuth). The e2e kind-lane run with the recording proxy and the Cloud-context regression against local-stack live in the monorepo BDD harness (out of this repo's scope); the spec promotion / tag moves are tracked there.

🤖 Generated with Claude Code


Note

Medium Risk
Changes authentication, credential file shape, and which hosts the CLI contacts—security-relevant for self-hosted vs cloud—but behavior is guarded by migration, discovery checks, and extensive tests.

Overview
Adds multi-installation support to the Arcade CLI: credentials are stored as named contexts (cloud vs self-hosted, engine/coordinator/dashboard URLs, auth, org/project), with legacy single-context files migrated to default on load. New arcade context list|use|show commands switch and inspect contexts.

Login gains --url / ARCADE_URL plus optional --context: the CLI loads /.well-known/arcade, refuses missing or deployments-disabled installs before OAuth, then saves a named context from discovery. arcade deploy, secret, org, and project default to the active context’s URLs instead of hardcoded cloud hosts; coordinator_api normalizes coordinator API paths.

CI mode uses ARCADE_URL + ARCADE_API_KEY (pinned at CLI import so project .env cannot arm CI later) for non-interactive deploy with API-key auth and /v1 (admin) URL shapes. A no-Cloud guard blocks contacting *.arcade.dev while a self-hosted context is active.

Deploy also accepts optional --provider, treats degraded rollout status, and fails non-running deployments; root make test isolates credentials via ARCADE_WORK_DIR. Bumps arcade-mcp to 1.16.0 and arcade-core to 4.19.0 with broad unit coverage.

Reviewed by Cursor Bugbot for commit 0c628a4. Bugbot is set up for automated code reviews on this repo. Configure here.

pmdroid and others added 5 commits September 14, 2026 18:56
Model multiple named contexts in credentials.yaml, each carrying its own
engine/coordinator/dashboard URLs, a cloud-or-self-hosted kind, auth or an
API key, and org/project selection. A legacy single-context file migrates to
a `default` context on load; the active context is projected onto the existing
top-level fields so current readers keep working. Persist account_id so a
later foreign-secret pre-flight can compare owners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rd [DEP-238]

Add the context module: fetch the installation discovery document at
/.well-known/arcade, classify a host as cloud or self-hosted, resolve the
active context from either the CI environment (ARCADE_URL + ARCADE_API_KEY) or
the credentials file, and refuse to contact an Arcade Cloud host while a
self-hosted context is active. Route engine/coordinator URL construction and
auth headers through the active context so a self-hosted target is selected
without flags and CI supplies a bearer API key. Save OAuth credentials under a
named context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g [DEP-238]

Add `arcade login --url <install-url>` (and ARCADE_URL): fetch discovery,
report the reason and exit non-zero when deployments are off, refuse before
sending anything when no discovery document exists, sign in against the
discovered coordinator, and save a named context. Add `arcade context
list|use|show` to manage contexts. Deploy, secret, org, and project now target
the active context with no flags, unauthenticated deploy names the login
command, and ARCADE_URL + ARCADE_API_KEY complete a deploy with no terminal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…238]

Unit tests for the legacy-to-default migration and named-context round trip,
discovery parsing and the predates-discovery path, cloud-host detection, the
no-Cloud guard, login by URL, the context command, unauthenticated deploy
naming the login command, and a CI-environment deploy driven without prompts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…238]

In CI mode there is no OAuth credentials file and no saved org/project, so the
org-scoped URL builder cannot run. Route API-key requests to the engine's
credential-scoped routes instead — /v1/deployments, /v1/workers/:id, and
/v1/admin/secrets — where the project API key resolves the project server-side.
The regression test drives a CI deploy through the real URL builder rather than
mocking past it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 14, 2026

Copy link
Copy Markdown

Current version of PR was reviewed by /review-bugbot on Sep 14, 19:20. It flagged 0 findings.

Bugbot on commit 9b299ab is skipped.

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.81236% with 62 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
libs/arcade-cli/arcade_cli/main.py 62.50% 18 Missing ⚠️
libs/arcade-cli/arcade_cli/utils.py 62.85% 13 Missing ⚠️
libs/arcade-cli/arcade_cli/authn.py 77.50% 9 Missing ⚠️
libs/arcade-cli/arcade_cli/contexts_cmd.py 90.76% 6 Missing ⚠️
libs/arcade-cli/arcade_cli/deploy.py 76.92% 6 Missing ⚠️
libs/arcade-cli/arcade_cli/context.py 96.63% 4 Missing ⚠️
libs/arcade-cli/arcade_cli/org.py 50.00% 1 Missing ⚠️
libs/arcade-cli/arcade_cli/project.py 50.00% 1 Missing ⚠️
libs/arcade-cli/arcade_cli/secret.py 50.00% 1 Missing ⚠️
libs/arcade-core/arcade_core/auth_tokens.py 90.00% 1 Missing ⚠️
... and 2 more
Files with missing lines Coverage Δ
libs/arcade-cli/arcade_cli/__init__.py 100.00% <100.00%> (ø)
libs/arcade-cli/arcade_cli/_startup_environment.py 100.00% <100.00%> (ø)
libs/arcade-cli/arcade_cli/org.py 20.00% <50.00%> (-0.90%) ⬇️
libs/arcade-cli/arcade_cli/project.py 20.31% <50.00%> (-0.90%) ⬇️
libs/arcade-cli/arcade_cli/secret.py 66.94% <50.00%> (+0.27%) ⬆️
libs/arcade-core/arcade_core/auth_tokens.py 48.21% <90.00%> (+9.91%) ⬆️
libs/arcade-core/arcade_core/config_model.py 81.92% <98.24%> (+7.82%) ⬆️
libs/arcade-core/arcade_core/usage/identity.py 98.37% <93.75%> (-0.72%) ⬇️
libs/arcade-cli/arcade_cli/context.py 96.63% <96.63%> (ø)
libs/arcade-cli/arcade_cli/contexts_cmd.py 90.76% <90.76%> (ø)
... and 4 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Treat a deploy that ends in `failed` or `degraded` as a non-zero outcome:
`degraded` reports that the previous release is still serving, and both raise
so the command exits non-zero instead of returning success. Skip the
"view full logs?" prompt when there is no interactive terminal so a CI deploy
completes without blocking on input. The status poll now recognises `degraded`
as a terminal state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pmdroid
pmdroid force-pushed the dep/dep-238-cli-discovery-contexts branch from 9b299ab to 5532fe1 Compare September 14, 2026 19:35
@cursor

cursor Bot commented Sep 14, 2026

Copy link
Copy Markdown

Current version of PR was reviewed by /review-bugbot on Sep 14, 19:34. It flagged 0 findings.

Bugbot on commit 5532fe1 is skipped.

Exercise the empty and unauthenticated context-list paths, `context show` for a
missing and a fully-populated context, the discovery server-error and
bare-host-normalisation branches, and the no-Cloud guard's no-active-context
no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 14, 2026

Copy link
Copy Markdown

Current version of PR was reviewed by /review-bugbot on Sep 14, 20:06. It flagged 0 findings.

Bugbot on commit a952166 is skipped.

pmdroid and others added 4 commits September 18, 2026 15:33
…gured [DEP-238]

Cloud is configured with the coordinator's bare host, while an installation's
discovery document publishes the coordinator's API base, which already ends in
the API path. Every call site appended the path unconditionally, so signing in
to an installation asked for:

  https://cloud.bosslevel.dev/api/v1/api/v1/auth/cli_config

and got a 404 that read like the coordinator was missing rather than like the
address had been built twice.

Both forms now normalise to the same endpoint before the path is appended, at
the six places that build one. The trailing segment is only stripped when it
ends the address, so a coordinator genuinely served under a longer path keeps
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es [DEP-238]

Engines from the discovery release onwards nest the service addresses under a
urls object, and older ones publish them at the top level. A CLI that understood
only one shape refused to sign in to half the installations it was pointed at,
reporting that the document included no coordinator, with nothing an operator
could do about it.

Both shapes are now accepted. A top-level address wins over a nested one so an
engine serving both is read the way it intends, and a urls field that is not an
object is ignored rather than fatal, since a document we cannot fully parse is
still worth the parts we can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An installation running more than one deployment provider needs to be told which
one a server belongs on, and there was no way to say so.

The field is left out of the request entirely when no provider is named, rather
than sent as a null. An installation that knows only one provider rejects the
field, so a null would have broken every deploy that does not need it, including
every deploy against Cloud.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… to [DEP-238]

Named contexts moved the signed-in coordinator and tokens under the active
context, and the credentials file no longer carries them at the top level at all.
Usage identity still read the top level, so after this branch it found no access
token, skipped the whoami call entirely, and fell back to the Cloud address for a
request it was never going to make.

Nothing surfaced, because the whole lookup is wrapped in a silent except: every
event simply carried an anonymous id instead of the principal, and a self-hosted
user was attributed to nobody.

The fallback order matters for files written by older CLIs that have not been
migrated yet. The named context wins, a lone context is used when none is marked
active, and anything unresolvable falls back to the top level rather than
failing, since losing attribution is better than breaking a CLI command over
telemetry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pmdroid

pmdroid commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

Pushed four commits that were sitting uncommitted on my machine, each one a fix found by actually driving this CLI against a self-hosted installation.

What landed

Reaching one coordinator endpoint whichever address was configured. Cloud is configured with the coordinator's bare host, while an installation's discovery document publishes its API base, which already ends in the API path. Every call site appended the path unconditionally, so signing in to an installation asked for https://cloud.bosslevel.dev/api/v1/api/v1/auth/cli_config and got a 404 that read like a missing coordinator. Six call sites now normalise first, and the segment is only stripped when it ends the address.

Reading the discovery addresses in either shape. Engines from the discovery release onwards nest the addresses under a urls object and older ones publish them flat. A CLI that understood one shape refused to sign in to half the installations it was pointed at. Both are accepted, top level wins over nested, and a urls field that is not an object is ignored rather than fatal.

Naming the provider a server deploys to. An installation running more than one provider needs to be told which one a server belongs on. The field is omitted from the request entirely when unset rather than sent as null, because an installation that knows one provider rejects it and a null would break every deploy that does not need it.

Reading usage credentials from the active context. Found by Bugbot and confirmed against the writer. Named contexts moved the signed-in coordinator and tokens under the active context, and the file no longer carries them at the top level at all, but usage identity still read the top level. It found no access token, skipped the whoami call, and every event carried an anonymous id instead of the principal. Nothing surfaced because the whole lookup sits inside a silent except.

Proof

$ make check
ruff / ruff-format / mypy across six libs: all passed

$ pytest libs/tests/core/usage libs/tests/core/test_coordinator_urls.py libs/tests/cli
936 passed, 1 skipped

Each fix ships the test that fails without it: test_coordinator_urls.py for the doubled path, three cases in TestDiscovery for the nested and flat shapes and their precedence, test_deploy_request.py for the omitted provider, and test_identity_credentials.py for the context lookup including files written before contexts existed.

Dismissed

Login does not survive a corrupt credentials file. Real, and not this branch. save_credentials_from_whoami catches only FileNotFoundError, while Config.load_from_file also raises ValueError for unparseable YAML or a missing cloud key. Both are unchanged on main, and neither line is touched here, so this predates named contexts rather than being caused by them. The error does carry recovery advice, but it arrives after the browser round trip has already completed, which is the part worth fixing. Filed separately rather than widened into this PR.

The echo example is renamed to EchoServer2332. Correct observation, deliberately not committed. It is local scratch from testing deploys against staging and stays out of this branch.

🐕 Written by Kyoto, an AI agent, on Pascal's behalf —

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread libs/arcade-cli/arcade_cli/main.py
@pmdroid
pmdroid marked this pull request as ready for review September 18, 2026 15:51
Logging in by url skips the already-signed-in guard, so a named context can be
added at any time. Plain login still consulted the active context, so a user
signed in to a self-hosted installation was told they were already logged in and
could not add Cloud at all. The advice it offered was to log out, which deletes
the credentials file and with it every context they had.

The guard now asks about the context the login would actually write rather than
whichever one happens to be active, so it still refuses a genuinely redundant
sign-in and no longer refuses a new one.

The context flag is passed through to the save for the same reason. It was
accepted and ignored on this path, so naming a context without a url silently
wrote to the default one, and a guard reading the named context while the save
wrote elsewhere would have disagreed with itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread libs/arcade-cli/arcade_cli/authn.py
pmdroid and others added 2 commits September 18, 2026 16:12
…DEP-238]

Making the guard read a named context instead of the active one changes what
happens when only a self-hosted context exists. It must change nothing else, and
two cases carry the whole compatibility claim.

A credentials file written before named contexts existed migrates into the
default context, so a user signed in to Cloud alone is still recognised and the
redundant sign-in is still refused.

A user signed in to both, with the self-hosted one active, is also still
refused. That is the case the change could plausibly have broken, since it is
the one where the active context and the context being written differ while both
hold a token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checking a named context rather than the active one left the message behind. A
user signed in to a self-hosted installation who ran plain login was told their
Cloud organisation and project were "Active", while every other command kept
using the self-hosted context. The one action offered was to log out, which
deletes every saved context.

The message now says which context it found and names the one that is actually
active, and only uses the word active when those are the same context.

The advice leads with switching rather than deleting, since switching is what
someone in this position almost always wants and the destructive option was
previously the only one on offer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread libs/arcade-cli/arcade_cli/main.py Outdated
pmdroid and others added 2 commits September 18, 2026 16:33
Moving the guard onto a named context left the follow-up advice in the caller,
which cannot tell whether that context is the active one. It therefore told every
user to switch to a context they were often already using, directly under a line
saying that context was active.

The advice now sits beside the message it belongs to, where activeness is already
known. An active session gets the logout wording it has always had, and a session
in another context gets the switch. Neither is offered where it makes no sense.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s [DEP-305]

Deploy resolves auth headers per request and reads the CI credentials straight
from the environment each time. Partway through the command it loads the
project's env file into the process, and a tool server project very often keeps
an api key there. Calls before that point authenticated as the signed-in user
over oauth, and calls after it authenticated with the project's key. The
organisation in the url is resolved the same way, so the target changed with the
credential and the deploy could land somewhere the user never chose.

The environment is now captured once when the command starts, and every later
resolution reads that capture. Credentials present at start still drive
unattended runs exactly as before, which is the case the environment exists to
serve. A file read later configures the server being deployed, which was never a
statement about who is deploying it.

Capturing per command rather than per process keeps a test runner that invokes
many commands in one process honest, and the cli tests reset it between cases
because a real invocation gets a fresh process and they do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pmdroid

pmdroid commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

Fixed the deploy authentication switch, which was the one finding I had held back as a release blocker.

The defect

arcade deploy resolves auth headers per request and read the CI credentials straight from the environment each time. Partway through the command it loads the project's env file into the process, and a tool server project very often keeps an API key there. Calls before that point authenticated as the signed-in user over OAuth, and calls after it authenticated with the project's key. get_org_scoped_url resolves the same way, so the organisation in the path changed with the credential and a deploy could land somewhere the user never chose.

The fix

The environment is captured once when the command starts, and every later resolution reads that capture. Credentials present at start still drive unattended runs exactly as before, which is the case the environment variables exist to serve. A file read later configures the server being deployed, which was never a statement about who is deploying it.

The capture is per command rather than per process. That matters for the test runner, which invokes many commands in one process, and the CLI tests reset it between cases because a real invocation gets a fresh process and they do not.

Four cases cover it: a project file cannot introduce credentials, cannot replace credentials that were already there, credentials present at start still drive unattended runs, and without pinning the environment is still read live.

pytest libs/tests/cli libs/tests/core
1501 passed, 1 skipped

The one remaining failure, test_two_resources_sharing_a_path_fail_the_toolkit, is in a file whose last change is an ancestor of main and does not fail in CI.

Dismissed: the coordinator falls back to Cloud in CI mode

The observation is right and the consequence is not. With CI credentials set, resolve_ci_context supplies no coordinator URL, so resolve_coordinator_base_url does fall through to the production Cloud host. It is not silent. guard_no_cloud runs on that URL and refuses outright when the active context is self-hosted:

Refusing to contact the Arcade Cloud host '...' while the self-hosted context '...' is active.

For a CI context that genuinely points at Cloud, the fallback is the correct answer. The two callers are the org and project commands rather than deploy.

There is a real nit underneath it: that refusal advises arcade context use <name>, which is useless when the context came from the environment and there is nothing to switch to. Worth better wording, not worth widening this PR, and not introduced by this change.

🐕 Written by Kyoto, an AI agent, on Pascal's behalf —

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 79a7085. Configure here.

Comment thread libs/arcade-cli/arcade_cli/main.py Outdated
…-305]

Capturing at the start of the command was too late to do anything. The cli
imports its utils module, which imports the mcp server settings module, which
builds a settings object at import time and loads the project env file into the
process as it does. All of that finishes before any command body runs, so the
capture recorded values the env file had already supplied and the guard it was
meant to provide did nothing at all.

The capture now happens when the arcade_cli package itself is imported, from a
module with no dependencies beyond the standard library, so nothing it pulls in
can have loaded a file first.

The proof runs in its own process, because the ordering only exists across a
real import. It writes an env file naming an installation and a key, imports the
cli from that directory, and asks whether unattended mode is armed. Without the
capture the answer is yes, which is the defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config directory is resolved once at import from the environment, so a test
that reaches for that constant writes to the real credentials file in the
developer's home rather than to a temporary directory. Fixtures that redirect
the directory cannot help, because the constant is already bound by the time any
of them run.

I did exactly this while writing the login guard tests and overwrote a working
session with fixture data. Nothing in the suite does it now, but nothing stopped
it either.

Pointing the work directory at a fresh temporary one for the run binds that
constant somewhere disposable, so the whole class of mistake costs nothing
instead of costing a login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@sdreyer sdreyer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed for backwards compatibility, on-disk format, and CLI/engine wire compat. Ran the CLI suite at 0c628a4b in a clean worktree: 903 passed, 1 skipped. The DEP-305 import-ordering fix is correct and well-placed — capturing in arcade_cli/__init__.py from a stdlib-only module is the only spot early enough to beat the settings import that loads the project .env.

The wire story is clean in both directions. I diffed the full URL surface against main: every path an existing flow hits is byte-identical, coordinator_api() is pure normalization, and provider is dropped from the create-deployment body when unset (CreateDeploymentRequest has no other nullable fields, nested included), so an un-updated engine receives the exact old payload. account_id was already required on main, so there's no new coordinator obligation. New CLI against today's prod engine is safe.

One genuine break and one fail-open guard, plus rollout sequencing. Details inline; items that can't be anchored to the diff are below.


Version floors weren't raised on the sibling packages. libs/arcade-mcp-server/pyproject.toml:24 and libs/arcade-tdk/pyproject.toml:19 still declare arcade-core>=4.18.0, while core goes to 4.19.0 here. Combined with the credentials format change (see the inline comment on config_model.py), a pinned or Docker-built server env resolving 4.18 alongside a freshly-updated CLI silently loses its login — server.py:538 just logs "Arcade access token not configured" and auth tools start returning login instructions. CLAUDE.md's rule about bumping minimum dependency versions on breaking inter-library changes covers this case.

CI mode can still be armed by ambient shell env. DEP-305 closed the .env vector, but ARCADE_URL + ARCADE_API_KEY exported in a shell still silently switches an authenticated cloud user from OAuth to API-key auth and reshapes /secrets from org-scoped to /v1/admin — with an existing login sitting right there, unused, and nothing printed. ARCADE_API_KEY is already set in plenty of shells; ARCADE_URL being new is the only thing making the pair rare today. One line on stderr when CI mode wins over a saved context would make this diagnosable instead of mysterious.

Rollout ordering for degraded. I traced it to monorepo#4135; it's gated on ProviderName() == ProviderKubernetes with the reconciler wiring commented "only for the kubernetes provider", and providerNameOrDefault returns ProviderPorter. So Cloud is outside the blast radius and the exposure is self-hosted k8s installs — exactly this PR's audience. Since #4135 is still open, shipping CLI 1.16.0 first is free and gives operators a degraded-aware client before the engine can produce the state.

Two nits. org.py / project.py annotate host: str while defaulting to None — harmless at runtime since typer.Option returns Any, but the annotation now says the opposite of what the code does. And the autouse forget() in libs/tests/cli/conftest.py means every in-process test exercises _startup_environment's live-env fallback rather than the captured path; the pinning behavior rests entirely on the one subprocess test. Deliberate, I assume — just worth knowing that's the single thread holding it.

None of this blocks the feature work, which reads well — the named-context migration is careful and the discovery/predates path is a nice touch.

elif self.active_context and self.active_context in self.contexts:
self.contexts[self.active_context] = self._to_named_context()

cloud = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the credentials file one-way. save_to_file now writes only active_context + contexts, dropping the flat auth / user / context keys. BaseConfig is extra="ignore", so arcade-core 4.18 parses the new file into an all-None Config. I ran the pre-PR model against a new-format file:

old-core reads new file -> authenticated: False | auth: None | user: None | ctx: None

The old->new direction is fine — I ran a legacy file through this loader and it migrates to default, stays authenticated, keeps kind: cloud and the org/project. It's the reverse that bites: a CLI downgrade, or any environment still on 4.18, sees a logged-out user.

And it isn't opt-in. auth_tokens.py:106 saves on token refresh, so the first authenticated command flips the format permanently.

Suggested fix — dual-write. Keep emitting the flat active-context keys alongside contexts/active_context. load_from_file already branches on "contexts" in cloud and overwrites the flat values via _apply_named_context, so old and new readers both work off one file and the downgrade cliff disappears. Roughly five lines here, and it's worth more than the floor bumps because a PyPI release can't be unpublished and a migrated file can't be un-migrated.

Separate nit, a few lines up: the if not self.contexts / elif active_context in self.contexts pair has no else. If contexts is populated but active_context is None or names a missing key, neither branch runs and the in-memory flat state — including a token just refreshed — is silently dropped from the write. load_from_file always leaves those consistent today, so I couldn't reach it, but it's a data-loss branch with no diagnostic.

return resolve_active_context()
except FileNotFoundError:
return None
except Exception:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The no-Cloud guard fails open. guard_no_cloud gets its context from try_resolve_active_context, which swallows every exception into None, and then returns early on ctx is None. So any failure to read the credentials file — malformed YAML, a ValidationError on one context, a permissions error — silently disables the guard rather than tripping it.

For a feature whose own error message promises "Artifacts and requests stay inside your environment," open is the wrong direction to fail. The blanket except Exception also hides the reason, so a self-hosted user gets no signal that the protection stopped applying.

Worth distinguishing the two cases: FileNotFoundError means genuinely no config and unguarded is correct; anything else means a config exists but couldn't be read, which should refuse rather than proceed. Cheap version — have guard_no_cloud catch the load failure itself and raise NoCloudGuardError with the underlying reason, leaving try_resolve_active_context alone for the callers that legitimately want a soft default.


ci_context = try_resolve_active_context()
if ci_context is not None and ci_context.is_ci and ci_context.api_key:
return {"Authorization": f"Bearer {ci_context.api_key}"}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a defect — a request, while this code is open.

get_auth_headers returns Authorization only, with no User-Agent or X-Arcade-CLI-Version. That means the engine has no way to tell which CLI is talking to it, so when degraded lands (monorepo#4135) it can't be gated per-client — it's all-or-nothing, and the only lever left is waiting on adoption.

Adoption will be slow: arcade update notifies rather than auto-installs, so the tail is pinned CI, Docker images, and uv tool install nobody re-runs. cli_version already goes to PostHog (usage/constants.py:7), so you can watch the distribution — but watching isn't gating.

A few lines adding the version header here turns step-5 of the rollout from "wait and hope" into something controllable.

for i, log_line in enumerate(all_logs, 1):
console.print(f"{i:4d} | {log_line}", style="dim")

if final_status != "running":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth calling out in the release notes: this changes the exit code. Previously a failed deployment printed "Deployment failed" and exited 0 — the if running / elif failed pair had no raise. Pipelines that were silently green on broken deploys will now correctly go red. Right fix, but it'll look like a regression to whoever owns those pipelines.

The mirror-image hazard is on older clients. The pre-1.16 poll loop is while status in ["pending","unknown","updating"] with break on ["running","failed"]. Feed it degraded and the loop falls out, neither branch matches, and it exits 0 with no message — a silent pass on a degraded rollout. That's an argument for landing this CLI before monorepo#4135, not after.

Comment thread Makefile
test: install ## Test the code with pytest
@echo "🚀 Testing libs: Running pytest"
@uv run pytest -W ignore -v libs/tests --cov=libs --cov-config=pyproject.toml --cov-report=xml
@ARCADE_WORK_DIR=$$(mktemp -d) uv run pytest -W ignore -v libs/tests --cov=libs --cov-config=pyproject.toml --cov-report=xml

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about this line — the intent is right, and I've done the same thing to my own credentials file.

1. The guard only covers make test. CLAUDE.md documents single tests as uv run pytest libs/tests/core/test_toolkit.py::TestClass::test_method, which is unprotected and still writes to the real ~/.arcade. That's the exact accident the commit message describes. The commit is right that a fixture can't help — the constant is bound at import — but os.environ.setdefault("ARCADE_WORK_DIR", tempfile.mkdtemp()) at the top of libs/tests/conftest.py runs before any test module imports arcade_core, and covers every invocation path rather than one target.

2. Setting this var puts the suite in a split-brain config. ARCADE_WORK_DIR resolves to two different files:

constants.py  -> $ARCADE_WORK_DIR/.arcade/credentials.yaml
config_model  -> $ARCADE_WORK_DIR/credentials.yaml
SAME: False

constants.py:4 appends .arcade; Config.get_config_dir_path() uses the var as the directory itself. Pre-existing — both are on main, and check_existing_login behaves the same either way — but this line makes the divergent configuration the default for every test run. The sharpest symptom for anyone who sets the var: login writes via Config.save_to_file() while logout deletes CREDENTIALS_FILE_PATH, so arcade logout prints "You're not logged in" and leaves the credentials on disk.

History, for whoever fixes it: get_config_dir_path() is the original (#31, then #50 swapped the settings object for the env var, same semantics — the var is the config dir). constants.py introduced the second reading later in #635. Seniority argues one way but call sites argue the other: the .arcade-suffix reading has five consumers (usage/identity.py, authn.py, main.py, command_tracker.py, update.py) against essentially just config_model itself. Unifying get_config_dir_path() onto ARCADE_CONFIG_PATH changes one function, leaves the five callers alone, and keeps this Makefile line working as written.

Happy to send that as a separate patch if you'd rather keep this PR focused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants