Skip to content

Commit 4b20c84

Browse files
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
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
`os environments list | show | create | bind | switch` run on the `os cloud login` session
6+
7+
Clause-②: yes (widening)
8+
9+
The documented hosted flow is `os cloud login`, then `os environments create`. The five
10+
`os environments` subcommands read only `~/.objectstack/credentials.json` (the `os login`
11+
session), so with only `~/.objectstack/cloud.json` they exited 1 with
12+
`Authentication required` before sending any request, while `os login --help` sends hosted
13+
users to `os cloud login`.
14+
15+
All five now choose their session in one shared resolver:
16+
17+
- With no `--url` / `OS_CLOUD_URL`, they use the `os login` session when there is one, which
18+
is the same behaviour as before. Otherwise they use the `os cloud login` session and the URL
19+
it recorded.
20+
- With a `--url`, they use the session whose file names that server, `credentials.json` first.
21+
When neither file names it, they use `credentials.json`'s session as before. The cloud token
22+
is never sent to a URL other than its own.
23+
- The active environment sent with each request comes from the chosen session's file.
24+
`os environments switch` and `create --activate` no longer write a cloud environment id into
25+
`credentials.json` when they ran on the cloud session.
26+
27+
With no session at all, the `Authentication required` message now names `os cloud login` as
28+
well as `os login`. `os package publish` is unchanged: it still reads only `cloud.json`.

‎packages/cli/README.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,19 @@ Typical flow (build → publish → install into an environment, seeding sample
9696

9797
```bash
9898
os compile # → dist/objectstack.json
99-
os cloud login # one-time; the session os package publish reads
100-
os environments create --org "$ORG" --name "Dev" --activate # does NOT read that session — see below
99+
os cloud login # one-time; the session os package publish and os environments read
100+
os environments create --org "$ORG" --name "Dev" --activate
101101
os package publish --env <env-id> --install --seed-sample-data
102102
```
103103

104-
`os environments create` does not use the session `os cloud login` stored: give
105-
it `--url` and `--token` (or `OS_CLOUD_URL` / `OS_TOKEN`), or an `os login`
106-
session. Without either it exits 1 with `Authentication required`.
104+
`os environments` runs on either stored session. With no `--url` it talks to
105+
the server of the `os login` session (`credentials.json`) when there is one,
106+
else to the server of the `os cloud login` session (`cloud.json`), with that
107+
session's token. A `--url` (or `OS_CLOUD_URL`) picks the session that names
108+
that server, `credentials.json`'s first — so with both stored, a `--url` naming
109+
the cloud uses the cloud session. A `--url` neither file names gets
110+
`credentials.json`'s token as before, never `cloud.json`'s. With no session and
111+
no `--token` / `OS_TOKEN`, it exits 1 with `Authentication required`.
107112

108113
`os package publish` registers a `sys_package` (keyed by a reverse-domain
109114
`--manifest-id`, derived from the artifact when omitted), snapshots the
@@ -124,7 +129,7 @@ Two stored sessions exist, and each command authenticates with one of them:
124129
| `os cloud login` | `-u, --url` (env `OS_CLOUD_URL`, default `https://cloud.objectos.ai`) | none — `-e, --email` / `-p, --password`, or the browser device flow | writes `~/.objectstack/cloud.json` |
125130
| `os cloud whoami` / `os cloud logout` | — | — | reads / deletes `~/.objectstack/cloud.json` |
126131
| `os package publish`, `os plugin publish` | `-s, --server` (env `OS_CLOUD_URL`); else the URL in `cloud.json`; else `https://cloud.objectos.ai` | `-t, --token` (env `OS_CLOUD_API_KEY`, then `OS_TOKEN`) | `~/.objectstack/cloud.json` — the `os cloud login` session |
127-
| `os environments list` / `show` / `create` / `bind` / `switch` | `-u, --url` (env `OS_CLOUD_URL`); else the URL in `credentials.json`; else `http://localhost:3000` | `-t, --token` (env `OS_TOKEN`) | `~/.objectstack/credentials.json` — the `os login` session, **not** `os cloud login`'s |
132+
| `os environments list` / `show` / `create` / `bind` / `switch` | `-u, --url` (env `OS_CLOUD_URL`); else the URL of the stored session it uses; else `http://localhost:3000` | `-t, --token` (env `OS_TOKEN`) | No `--url`: `~/.objectstack/credentials.json` (the `os login` session), else `~/.objectstack/cloud.json` (the `os cloud login` session). With `--url`: the file that names that server, `credentials.json` first; when neither does, `credentials.json` as before. `cloud.json`'s token is never sent to another URL |
128133

129134
`os package install` is not a cloud command: it installs into a running runtime
130135
(`-r, --runtime`, env `OS_RUNTIME_URL`, default `http://localhost:3000`) and signs

‎packages/cli/src/commands/environments/bind.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from 'node:path';
55
import fs from 'node:fs/promises';
66
import { spawnSync } from 'node:child_process';
77
import { printError, printStep, printKV, emitJson, isExitSignal, errorCodeFields } from '../../utils/format.js';
8-
import { createApiClient, requireAuth } from '../../utils/api-client.js';
8+
import { createControlPlaneApiClient, requireControlPlaneAuth } from '../../utils/api-client.js';
99
import { formatOutput } from '../../utils/output-formatter.js';
1010

1111
/**
@@ -99,8 +99,8 @@ export default class EnvironmentsBind extends Command {
9999
this.exit(1);
100100
}
101101

102-
const { client, token } = await createApiClient({ url: flags.url, token: flags.token });
103-
requireAuth(token);
102+
const { client, token } = await createControlPlaneApiClient({ url: flags.url, token: flags.token });
103+
requireControlPlaneAuth(token);
104104

105105
// Fetch existing metadata so we don't blow it away.
106106
const current = await client.environments.get(args.environmentId);

0 commit comments

Comments
 (0)