Repository navigation
Commit 4b20c84
fix(cli): os environments runs on the os cloud login session through one shared resolver (#21400)
Fixes #21360
Clause-②: yes (widening)
## What changes
The five `os environments` subcommands (`list`, `show`, `create`,
`bind`, `switch`) built their client with `createApiClient`, which reads
only `~/.objectstack/credentials.json` (the `os login` session). With
only `~/.objectstack/cloud.json`, which is the state after `os cloud
login`, all five exited 1 with `Authentication required` before sending
a request. Meanwhile `os login --help` sends hosted users to `os cloud
login`, so the documented hosted flow looped.
This follows triage's ruling `5947754514` (shape 1). All five now choose
their session in **one** shared resolver, `createControlPlaneApiClient`
in `packages/cli/src/utils/api-client.ts`, which picks in this order:
1. `credentials.json`'s session where it targets the server the command
talks to. With no `--url` / `OS_CLOUD_URL`, it names the server itself,
so a user with an `os login` session sees no change.
2. Otherwise `cloud.json`'s session, on the same terms. Its server is
its recorded url, or `https://cloud.objectos.ai` when it records none.
3. Otherwise, when an explicit url names neither file's server,
`credentials.json`'s session as before. `cloud.json`'s token is never
sent to a url other than its own.
Explicit flags and env vars (`--url` / `OS_CLOUD_URL`, `--token` /
`OS_TOKEN`, `OS_ENVIRONMENT_ID`) still win field by field, as in
`createApiClient`. Other details:
- The active environment sent as `X-Environment-Id` comes from the
chosen session's file.
- The result carries `session: 'credentials' | 'cloud'`. `switch` and
`create --activate` read it and skip the `credentials.json` write when
they ran on the cloud session. Without that skip, an id from
cloud.json's server would land in a file that names a different server,
and every later `os data` / `os meta` call would send it there. The
`cloud.json` write still goes through the existing url gate in
`active-environment.ts`.
- With no session at all, the refusal (`requireControlPlaneAuth`) names
`os cloud login` as well as `os login`. The `Authentication required`
prefix is unchanged.
- Unchanged: `createApiClient`, `requireAuth`, and their `data` / `meta`
/ `datasource` / `whoami` callers.
### Where the resolver lives, and why `os package publish` does not use
it
- **Location: `api-client.ts`, beside `createApiClient`.** The resolver
needs both stores and the url gate `isSameControlPlane` from
`active-environment.ts`. Putting it in `cloud-config.ts` would create an
import cycle, because `active-environment.ts` imports `cloud-config.ts`.
- **`os package publish` keeps its own lane.** It reads only
`cloud.json`, by design: its source says it deliberately does not fall
back to `credentials.json`, and `content/docs/deployment/cli.mdx`
documents that. The ruling's order puts `credentials.json` first. Moving
publish onto this resolver would change which token publish sends
whenever `credentials.json` names the same server, so `publish.ts` is
untouched.
## Measured: the five subcommands, spawned from source
Setup: HOME holds only `cloud.json`, pointed at a local echo control
plane. The CLI runs through `bin/run-dev.js`, with `packages/cli/dist`
absent so the source is what runs.
| subcommand | before (`51550933db`) | after (`97c52b5ce7`) |
|---|---|---|
| `list` | exit 1, 0 requests, `Authentication required. Please run os
login ...` | exit 0, `GET /api/v1/cloud/environments` with `Bearer
cloud_tok` |
| `show env_1` | exit 1, 0 requests | exit 0, `GET
.../environments/env_1` with `Bearer cloud_tok` |
| `create --org org_1 --name Dev` | exit 1, 0 requests | exit 0, `POST
.../environments` + `POST .../env_new/activate` with `Bearer cloud_tok`;
id recorded in `cloud.json` |
| `bind env_1 --artifact ...` | exit 1, 0 requests | exit 0, `GET` +
`PATCH .../environments/env_1` with `Bearer cloud_tok` |
| `switch env_1` | exit 1, 0 requests | exit 0, `GET` + `POST
.../env_1/activate` with `Bearer cloud_tok`; id recorded in `cloud.json`
|
## Pins: `packages/cli/src/commands/environments/cloud-session.test.ts`
The file has 49 cases. They run in-process through `Command.run` against
two real `node:http` echo control planes on 127.0.0.1, with HOME
redirected to a temp directory. Every group runs over all five
subcommands:
- **Only `cloud.json`:** the cloud bearer and the cloud active
environment go to the cloud url. A `--url` that `cloud.json` does not
name gets exit 1 and zero requests.
- **Only `credentials.json` (the control):** the runtime bearer and
active environment go to the runtime url, including with an explicit
`--url`, as before.
- **Both stores:**
- `credentials.json` wins where both name the same server, with and
without `--url`.
- With no `--url`, `credentials.json` still picks the server.
- A `--url` naming cloud.json's server selects the cloud session.
- **Writes:** `switch` and `create` on the cloud session leave
`credentials.json` alone. With only `cloud.json`, they record the id in
`cloud.json`.
- **No session:** the refusal names `os cloud login`.
**Red before the fix**, measured with the pin commit `eb9dcdaeb4` on top
of `51550933db`: 19 failed, 25 passed. The failures were the cloud-only
bearer (5), the `--url` naming cloud.json's server (5), the four write
cases, and the no-session remedy (5). The 25 passing cases were the
controls, the both-stores ordering cases and the foreign-url guard.
These hold before and after the change by design.
### Ablations
All 15 ablations ran at `97c52b5ce7` through
`scripts/ablation-replace.mjs` in WRAP mode, with the fix already
committed. The subject is reached by relative import into
`packages/cli/src`, so no dist is on the path. Every leg's anchor hit
exactly once, and the mutation was proven on disk by anchor count and
blob change. Every restore was proven by blob == HEAD and an empty `git
diff HEAD`. Each leg went red exactly where predicted:
| leg | mutation | red |
|---|---|---|
| L1 | drop the cloud.json candidate | 14: cloud-only bearer x5, `--url`
cloud x5, the 4 write cases |
| L2 | no-url order swapped to cloud first | 10: both-same-server x5,
no-url picks credentials x5 |
| L3 | delete the credentials url match | 5: credentials wins on the
shared `--url` server |
| L4 | ungated cloud fallback for a foreign url | 5: cloud token never
sent to a foreign `--url` |
| L5 | drop the legacy credentials leg | 5: control with explicit
`--url` |
| L6 | cloud active environment dropped | 10: cloud-only x5, `--url`
cloud x5 |
| L7 | runtime active environment dropped | 10: control x5,
both-same-server x5 |
| L8 / L9 | `switch` / `create` runtime write ungated | 1 each: leaves
credentials.json alone |
| L10 | remedy reverted to the old sentence | 5: no-session names `os
cloud login` |
| L11-L15 | each subcommand back on `createApiClient` | that
subcommand's cases only (2, 2, 4, 2, 4) |
## Tests and gates, at `97c52b5ce7`
- `pnpm --filter @objectstack/cli typecheck`: exit 0, which covers `tsc
--noEmit` over `src` (the new pin is under `src`) plus
`check:test-typecheck` OK.
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`:
- First run: 245 of 247 files passed (3509 passed, 29 skipped).
- The two failing files, `published-subpath-console.pin` and
`published-subpath-hook-body.pin`, refused because `packages/cli/dist`
was absent, a build prerequisite.
- After the workspace build, those two files passed (29 tests).
- Integration tier: **declared to CI.** The diff touches no
integration-tier file and no spawn entry.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 65 commands, and every one was run with its exit
code recorded before any pipe.
- Four gates first exited 3 (PREREQUISITE NOT MET, no dist):
`check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and
`check:i18n-walk-parity`. After `turbo run build` they exit 0.
- `--ran`: `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`.
- Lint, a declared narrowing of `pnpm lint`. ESLint ran with
`--no-inline-config --format json` on the 10 changed `.ts` files.
- The JSON reports 10 files linted, none ignored, 0 errors and 0
warnings.
- The repo's single `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`, no typed rules; it says so itself). Its
only cross-file inputs are two baseline JSON files this diff does not
touch, so no untouched file's verdict can move.
## Acceptance notes
- `packages/cli/src/utils/active-environment.ts` header says "`os
environments` authenticating as the runtime identity is deliberate".
That sentence is now false. The file is outside this card's claimed
surface, so it is not edited here. It is a one-sentence comment change.
- `package/publish.ts` and `plugin/publish.ts` each carry their own copy
of the `cloud.json`-only resolution. That duplication predates this
card, and this card does not touch it.
- `content/docs/deployment/cli.mdx` has no `os environments` sentence
that this change makes false, so it is not edited.
- The legacy leg (an explicit url that neither file names gets
`credentials.json`'s session) is kept so the control holds byte for
byte. Narrowing it would be its own decision.
- In the README Cloud section, the table row and the paragraph and code
comment that PR #21354 added are rewritten to state the new behaviour.
- The gate derivation ran on a tree 8 commits behind `origin/main`. Its
one stale input is `scripts/sdui-manifest.record.json`, which this diff
does not touch. Upstream, only
`packages/cli/test/json-stdout-purity.e2e.test.ts` changed under
`packages/cli`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a513940 commit 4b20c84
13 files changed
Lines changed: 571 additions & 50 deletions
File tree
- .changeset
- packages/cli
- src
- commands/environments
- utils
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
99 | | - | |
100 | | - | |
| 99 | + | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
104 | | - | |
105 | | - | |
106 | | - | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
107 | 112 | | |
108 | 113 | | |
109 | 114 | | |
| |||
124 | 129 | | |
125 | 130 | | |
126 | 131 | | |
127 | | - | |
| 132 | + | |
128 | 133 | | |
129 | 134 | | |
130 | 135 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
103 | | - | |
| 102 | + | |
| 103 | + | |
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
| |||
0 commit comments