diff --git a/README.ja.md b/README.ja.md index 3901a9a0..c7a3fcd5 100644 --- a/README.ja.md +++ b/README.ja.md @@ -52,7 +52,7 @@ pnpm dev **サインアップ不要:** `arkor dev` は **Studio** と呼ばれるローカル Web UI を `http://localhost:4000` で開きます。初回起動時に使い捨ての匿名ワークスペースをプロビジョニングするので、すぐに実際のトレーニング実行を開始できます。 -後からアカウントに紐付けたい場合は `arkor login --oauth` を実行してください。 +匿名ワークスペースはこのマシーンに紐づきます。クラウド API はサーバー側で単一端末ガードを強制しているので、`~/.arkor/credentials.json` を別端末にコピーする運用は想定外です。各匿名ワークスペースは発行されたマシーンで使う前提です。今のところ CLI は匿名トークンを自動 refresh しないので、コピーがしばらく動いてしまうことはあります。発行ユーザーがもう一度 `arkor login --anonymous` を実行する(または自動 refresh が入って保存中の jti が進む)と、そのタイミングで他の全コピーは次回呼び出しから単一端末エラーで失敗するようになります。OAuth に切り替えても既存の匿名ワークスペースは引き継げません。`arkor login --oauth` は `~/.arkor/credentials.json` を新しい OAuth identity で上書きしますが、既存の匿名ジョブや org は発行元の credentials ファイルからしか辿れません。**さらに、既存のプロジェクトディレクトリでの実行は自動的に新しい identity 側へ切り替わりません**: `.arkor/state.json` が古い `(orgSlug, projectSlug)` をピンしているため、そのファイルを削除する(その後再実行するか、OAuth アカウントなら `arkor init` を走らせる)まで、このディレクトリのコマンドは前のワークスペースを参照し続けます。複数端末で作業したくなった時点で `arkor login --oauth` を実行し、アカウント付きの新しいワークスペースで再スタートしてください。 ### テンプレートを選ぶ @@ -85,7 +85,7 @@ Arkor はその基盤の上に立っています。 - [x] **ダッシュボードではなくコードでトレーニングに反応。** ライフサイクルコールバック (`onStarted`、`onLog`、`onCheckpoint`、`onCompleted`、`onFailed`) は、クラウドからストリーミングされる実行に応じて、完全に型付けされた状態で発火します。 - [x] **実行が終わる前にモデルを軽くチェック。** `onCheckpoint` の中から、トレーニング中のモデルに対して `infer({ messages })` を呼び出せます。 - [x] **ローカル Studio で実行を見守る。** `arkor dev` は、ジョブ一覧、ライブの loss チャート、ログテール、ファインチューニング済みモデルとチャットできる Playground を備えた UI を開きます。 -- [x] **アカウントなしで試す。** `arkor dev` はそのまま新しい匿名ワークスペースで起動します。アカウントに紐付けたい場合は `arkor login --oauth` で Arkor Cloud の OAuth (PKCE) フローを開始してください。 +- [x] **アカウントなしで試す。** `arkor dev` はそのまま新しい匿名ワークスペースで起動します。匿名ワークスペースは単一端末専用で、発行したマシーンに紐付き、現状 OAuth アカウントへのマイグレーションパスはありません。複数端末でアカウント付きの作業を始めたいタイミングで `arkor login --oauth` を実行してください。それ以降の作業はアカウントに紐付きます。 ## これから来るもの diff --git a/README.md b/README.md index 95e58207..6014bb18 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ pnpm dev **No signup required:** `arkor dev` opens **Studio**, a local web UI at `http://localhost:4000`. On first launch it provisions a throwaway anonymous workspace so you can fire off a real training run right away. -Run `arkor login --oauth` later if you want to claim your work under an account. +Anonymous workspaces are tied to this machine. The cloud-api enforces a single-device guard server-side, so `~/.arkor/credentials.json` shouldn't be copied to a second machine. Each anonymous workspace is meant to live where it was issued. Today the CLI doesn't auto-refresh the anonymous token, so a copy may keep working for a while and only start failing once the issuing user runs `arkor login --anonymous` again (or auto-refresh ships and rotates the stored jti); when that happens, every other copy starts failing on its next call with a single-device error. Switching to OAuth does not migrate prior anonymous work: `arkor login --oauth` overwrites `~/.arkor/credentials.json` with a fresh OAuth identity, but existing anonymous jobs and orgs stay reachable only from the credentials file that issued them. **The CLI also won't automatically route runs in an existing project directory to the new identity**: `.arkor/state.json` pins the directory to the old `(orgSlug, projectSlug)`, so commands here will keep targeting the previous workspace until you delete that file (then re-run, or run `arkor init` for the OAuth account). Run `arkor login --oauth` whenever you're ready to start an account-backed workspace that follows you across devices. ### Pick a template @@ -85,7 +85,7 @@ The phrase we keep coming back to: **ship the model the same way you ship the pr - [x] **React to training in code, not in a dashboard.** Lifecycle callbacks (`onStarted`, `onLog`, `onCheckpoint`, `onCompleted`, `onFailed`) fire as the run streams from the cloud, fully typed. - [x] **Sanity-check the model before the run finishes.** Inside `onCheckpoint`, call `infer({ messages })` against the model as it's being trained. - [x] **Watch the run in a local Studio.** `arkor dev` opens a UI with a jobs list, live loss chart, log tail, and a Playground for chatting with your fine-tuned models. -- [x] **Try it without an account.** `arkor dev` boots straight into a fresh anonymous workspace. Run `arkor login --oauth` to start the Arkor Cloud OAuth (PKCE) flow and attach the work to your account. +- [x] **Try it without an account.** `arkor dev` boots straight into a fresh anonymous workspace. Anonymous workspaces are single-device: they live on the machine that issued them, and there's no migration path into an OAuth account today. Run `arkor login --oauth` whenever you want *future* work backed by an account that follows you across devices. ## What's coming next diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx index e6c0da93..0cd96ab2 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -43,10 +43,14 @@ The loopback server is closed in a `finally` block so it does not stick around i Anonymous credentials let you try Arkor without an account: training runs, jobs, and any work you do are tied to the local machine via the anonymous token. The anonymous path always mints a brand-new token (and a new `anonymousId`) and overwrites `~/.arkor/credentials.json`, so re-running `arkor login --anonymous` does not refresh the existing identity. Switching to OAuth (`arkor login --oauth`, or selecting `OAuth (browser)` in the picker) overwrites the credentials file the same way and does not migrate prior anonymous workspaces or jobs into the account. Merging anonymous work into an OAuth account once you sign in is on the roadmap; until that lands, run `arkor login --oauth` **before** you start the runs you want associated with the account. +Anonymous accounts are intentionally **single-device**: the cloud-api stores a `latest_jti` per anonymous user and `userAuth` rejects every authenticated request whose JWT carries a different jti. The jti only changes when the token rotates, so the practical timing has two regimes. **Today** the CLI does not auto-refresh anonymous tokens (that wiring lives in `@arkor/cloud-api-client`'s `getToken()` and is on the [SDK roadmap](/roadmap)), so no rotation ever happens client-side and a copy of `~/.arkor/credentials.json` on a second machine usually keeps working — until the issuing user explicitly mints a new identity (`arkor login --anonymous` overwrites the file with a fresh jti) or an admin rotates server-side, at which point every other copy starts failing on its next call. **Once auto-refresh ships**, any refresh rotates `latest_jti` immediately, so the older-jti copy on any other device (or an older backup of the same file on this machine) starts failing on its next call without warning. Either way, the losing client receives an HTTP 401 / 409 with `code: "anonymous_token_single_device"`, which `cli/main.ts` surfaces as actionable guidance; deletion of the underlying anonymous row surfaces as `code: "anonymous_account_not_found"` the same way. The recovery hint is deployment-aware: on OAuth-supporting deployments the CLI points at `arkor login --oauth` so you can sign up for an account that supports multiple devices, while on anon-only deployments (where OAuth is not configured) it points at `arkor login --anonymous` instead — `--oauth` would fail there, and minting a fresh anonymous identity is the only recovery available. Note that **neither path migrates existing anonymous work** into the new identity; the previous workspace stays reachable only from the credentials file that issued it. + ### Anonymous issuance output Both anonymous paths surface the new `anonymousId` and an explanation that the same id is how Arkor Cloud recognises this client across sessions, though the line shape differs by entry point. `arkor login` (`--anonymous` flag or picker → **Anonymous**) prints `Anonymous id: ` as the spinner stop and then a separate info line saying that keeping the credentials file (`credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS) is what preserves the identity. `arkor dev`'s auto-bootstrap skips the spinner and emits a single info line that already embeds the id and the same explanation. The picker → **Anonymous** path additionally surfaces a one-line warn alongside the success message — ``Anonymous sessions aren't guaranteed to persist — sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.`` — so the upgrade hint is visible at issuance time. The explicit `--anonymous` shortcut suppresses that warn because it skips the `/v1/auth/cli/config` fetch and so cannot tell whether `arkor login --oauth` would succeed on this deployment; pointing at it on a rare anon-only deployment would steer users at a command that fails. +Every anonymous issuance path also surfaces the single-device limitation as a separate info line. The exact wording is gated on `oauthAvailable` (same contract as the persistence nudge). On OAuth-supporting deployments callers see the upgrade-flavoured ``Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.``, while on anon-only deployments (and on `arkor login --anonymous` where the cfg fetch is skipped intentionally) callers see only the bare ``Note: anonymous accounts work on this machine only.``, so users on those deployments aren't pointed at a command that would fail. `arkor whoami` on an anonymous identity also emits the bare variant on stderr, and only when both stdout and stderr are TTYs. A redirected stream (lost `stdout.isTTY`) or a CI runner that treats stderr-on-success as a warning marker (lost `stderr.isTTY`) sees no extra prose. The note is purely a UX hint; it doesn't gate any behaviour. The stdout shape (the user JSON object printed by `JSON.stringify(user, null, 2)`, optionally followed by a single human-readable `Orgs: , …` line when the user belongs to any orgs) is unchanged from the pre-note behaviour. Don't pipe the full output through a strict JSON parser like `jq`; the `Orgs:` line is a summary tail, not part of the JSON document. + ## `arkor logout` ```bash @@ -98,7 +102,8 @@ When signed in, the command prints the JSON `user` object pretty-printed, then a - `0`: signed in, identity printed. - `0`: not signed in; the message is informational only. - `1`: the cloud-api returned `426 Upgrade Required`. The CLI prints the upgrade hint (and the upgrade command for your detected package manager) and sets `process.exitCode = 1` so the deprecation-warning flush in `arkor`'s shutdown hook still runs before the process exits. -- Other 4xx / 5xx are reported as `Failed to fetch /v1/me (). Token may be expired.` and exit `0`. +- `1`: the cloud-api returned a structured anonymous-auth dead-end (`code: "anonymous_token_single_device"` or `code: "anonymous_account_not_found"`). `cli/main.ts` formats the deployment-aware recovery hint to stderr; see [Token expiry](#token-expiry) for the exact wording. +- `1`: any other non-2xx (transient 5xx, an unmapped 4xx, etc.). The previous "exit `0` with a `Failed to fetch /v1/me (). Token may be expired.` line on stdout" behaviour was removed in this release: `arkor whoami` now raises a `CloudApiError`, and `bin.ts` renders it concisely as `err.message` (e.g. `cloud-api 503`) on stderr before exiting non-zero. Stack traces are reserved for unknown `Error` shapes (likely SDK bugs); routine HTTP failures stay one-line so they don't drown a wrapper's logs. ## Where the credentials live @@ -110,10 +115,16 @@ For OAuth sessions, the credentials file records both the access token and the i In practice that means: -- An expired access token shows up as a `Failed to fetch /v1/me (401). Token may be expired.` from `arkor whoami`, or analogous failures from anything that talks to the cloud-api. +- An expired or revoked token shows up as a non-2xx response from the cloud-api. `arkor whoami` no longer prints a generic "Token may be expired" hint; instead it raises a `CloudApiError` carrying the upstream `code` (when present), and the top-level handler in `cli/main.ts` formats two known auth-state codes as actionable guidance. Before formatting, `main()` does a best-effort fetch of `/v1/auth/cli/config` so the recovery hint matches the deployment shape: + - `code: "anonymous_token_single_device"` → + - on OAuth-supporting deployments: ``Anonymous credentials were rejected as single-device. Anonymous accounts only work on one machine. Sign up for an account that supports multiple devices: arkor login --oauth`` (exit `1`). + - on anon-only deployments: ``Anonymous credentials were rejected as single-device. Anonymous accounts only work on one machine. This deployment does not advertise OAuth, so the only recovery is to mint a new anonymous identity (your previous workspace data cannot be recovered): arkor login --anonymous`` (exit `1`). + - `code: "anonymous_account_not_found"` → analogous OAuth-vs-anon-only split, ending in `arkor login --oauth` or `arkor login --anonymous`. + + Errors without a known `code` (and any non-`CloudApiError` exceptions) are rethrown out of `main()`. `bin.ts` wraps the top-level `await main(...)` in a try/catch that distinguishes by error shape: a `CloudApiError` is rendered as just `err.message` (e.g. the upstream `error` body, or `cloud-api ` when the body was empty), keeping routine HTTP failures one-line and easy to diagnose; any other `Error` is logged with `err.stack ?? err.message` so genuine SDK bugs still surface a frame. Either way `process.exitCode = 1` is set rather than calling `process.exit(1)` so the deprecation-warning flush + telemetry shutdown in `main()`'s `finally` block still run before the process exits. (The explicit catch is also there to avoid Node's default unhandled-rejection handler, which would dump the bundled minified frame for top-level rejections, and to keep the stderr flush deterministic across the supported Node range.) - The fix for an OAuth session is to re-run `arkor login --oauth`, which goes through the full PKCE flow again and overwrites `~/.arkor/credentials.json` with fresh tokens. -Anonymous tokens have no client-side expiry tracking; the cloud-api decides when they stop working. If an anonymous session starts failing, run `arkor login --anonymous` to mint a new one (this issues a new `anonymousId`, so it is effectively a different workspace). +Anonymous tokens have a server-side 90-day TTL, but the CLI does not yet auto-refresh them; that wiring lives in `@arkor/cloud-api-client`'s `getToken()` and is on the SDK roadmap. If an anonymous session starts failing today, run `arkor login --anonymous` to mint a new one (this issues a new `anonymousId`, so it is effectively a different workspace). ## Common errors @@ -125,5 +136,7 @@ Anonymous tokens have no client-side expiry tracking; the cloud-api decides when | `Auth0 did not return a refresh token. Make sure the Application has 'offline_access' scope enabled.` | `arkor login --oauth` | The OAuth token exchange succeeded but the response had no `refresh_token`, so the CLI cannot keep the session alive past the access token's lifetime. Usually a deployment-side misconfiguration. | Enable the `offline_access` scope on the OAuth (Auth0) application, then re-run `arkor login --oauth`. | | `No credentials on file.` | `arkor logout` | `~/.arkor/credentials.json` does not exist. Nothing to delete. | Run `arkor login` first if you wanted to sign in. | | `Not signed in. Run \`arkor login\` or \`arkor login --anonymous\`.` | `arkor whoami` | Same condition as above, surfaced from a different command. | Same fix. | -| `Failed to fetch /v1/me (). Token may be expired.` | `arkor whoami` | The cloud-api rejected the request with a non-200, non-426 status. Most often expired access tokens. | Re-run `arkor login` matching the current `mode`: `arkor login --oauth` for `mode: "auth0"`, `arkor login --anonymous` for `mode: "anon"` (the latter mints a new `anonymousId`, so it lands in a new workspace). The exit code stays `0` so wrapper scripts can inspect the message. | +| ``Anonymous credentials were rejected as single-device. …`` (followed by either `arkor login --oauth` or `arkor login --anonymous` depending on deployment) | `arkor whoami` and any other authenticated command | The cloud-api rejected the request with `code: "anonymous_token_single_device"` (HTTP 401 from the userAuth jti check or HTTP 409 from the rotate-jti CAS). Either the credentials file was copied to a second device, or another local process refreshed past this token within the recovery window. | Follow the command in the message: `arkor login --oauth` to sign up for an OAuth account on supporting deployments, or `arkor login --anonymous` to mint a new anonymous identity on anon-only deployments. Either path is a *new* identity; existing anonymous work cannot be migrated. The CLI exits `1`. | +| ``Your anonymous credentials are no longer valid. …`` (followed by either `arkor login --oauth` or `arkor login --anonymous`) | `arkor whoami` and any other authenticated command | The cloud-api rejected the request with `code: "anonymous_account_not_found"` (HTTP 401). The underlying `anonymous_users` row was deleted (admin / cascade / explicit revocation). | Same as above — follow the deployment-aware command in the message. The previous anonymous workspace cannot be recovered. The CLI exits `1`. | +| `cloud-api ` (one line, no stack) | `arkor whoami` and any other authenticated command | A non-200 / non-426 cloud-api response without a structured auth-state `code` (transient 5xx, an unmapped 4xx, etc.). `cli/main.ts` rethrows the `CloudApiError`; `bin.ts` catches it at the top of the stack and renders just `err.message` (the upstream `error` body, or `cloud-api ` when the body was empty) before setting `process.exitCode = 1`. Stacks are reserved for unknown `Error` shapes (likely SDK bugs). | Inspect the message verbatim; if it looks like a transport/server failure, retry. For an expired OAuth access token, re-run `arkor login --oauth`. For an expired anonymous token, re-run `arkor login --anonymous` (mints a new `anonymousId`). | | `426 Upgrade Required` (with upgrade hint) | `arkor whoami` (and other cloud-api calls) | The deployment requires a newer SDK version. The CLI prints the upgrade command for your detected package manager and sets `process.exitCode = 1`. | Upgrade the `arkor` package and re-run. | diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index 655bab6a..ceb30879 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -58,8 +58,9 @@ This means `arkor dev` is safe on a shared dev machine: another tab cannot read | `TypeError: fetch failed` (or an equivalent transport error that exits `arkor dev` immediately) | `/v1/auth/cli/config` itself was unreachable, so the deployment mode could not be determined and the CLI fails fast. | Restore connectivity and re-run `arkor dev`. | | ``No credentials on file — bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` | First `arkor dev` on this machine when the deployment advertises OAuth. The CLI bootstraps anonymous so Studio can start immediately; the message is informational, not an error. | Nothing required. To upgrade to a real account, run `arkor login --oauth` separately (it overwrites `~/.arkor/credentials.json`) and refresh Studio. | | `No credentials on file — requesting an anonymous token.` | Same as above on anon-only deployments (no Auth0 advertised in `/v1/auth/cli/config`). The CLI omits the `arkor login --oauth` hint because that command would fail there. | Nothing required. | -| ``Anonymous id: — Arkor Cloud uses this id to recognise this client across sessions. Keep `/.arkor/credentials.json` to stay signed in as the same anonymous identity.`` | Informational follow-up after the anonymous bootstrap completes — surfaces the cloud-side identifier and where it lives (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). | Nothing required. Back up the credentials file if you want to keep using the same anonymous identity from another machine. | +| ``Anonymous id: — Arkor Cloud uses this id to recognise this client across sessions. Keep `/.arkor/credentials.json` to stay signed in as the same anonymous identity.`` | Informational follow-up after the anonymous bootstrap completes — surfaces the cloud-side identifier and where it lives (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). | Nothing required. The "stay signed in as the same anonymous identity" guidance just means: don't delete the file. The cloud-api enforces single-device on the server side: every authenticated request goes through a `latest_jti` check in `userAuth`, so as soon as the server's stored jti diverges from the one in any given copy of the credentials, that copy starts getting `code: "anonymous_token_single_device"` (HTTP 401) on its next call. Today the SDK doesn't rotate the jti on its own (auto-refresh is on the roadmap), so a copy on a second machine often appears to work for a while; that grace window disappears the moment a rotation lands, including for an older backup of the file on this same machine. Treat the file as live state, not as something to back up and restore. | | ``Anonymous sessions aren't guaranteed to persist — sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.`` | Persistence nudge fired alongside the success message when the deployment is known to support OAuth. Anonymous work has no SLA on the cloud-api side, so the CLI surfaces the upgrade path before you invest real work. Suppressed on anon-only deployments. | Optional: run `arkor login --oauth` to tie future work to your account. Existing anonymous work stays under its current id; there is no migration path today. | +| ``Note: anonymous accounts work on this machine only.`` (with `Run `arkor login --oauth` to sign up for multi-device access.` appended on OAuth-supporting deployments) | Informational note fired alongside the bootstrap success line. Anonymous accounts are bound to the issuing machine via the cloud-api's single-device guard, so the CLI surfaces the limitation up front rather than waiting for the first 401 on a second machine. The OAuth-flavoured upgrade hint is gated on the same `oauthAvailable` flag as the persistence nudge — anon-only deployments see only the bare fact, since `arkor login --oauth` would fail there. | Optional. On OAuth-supporting deployments, run `arkor login --oauth` whenever you want future work backed by an account that follows you across devices. Existing anonymous work cannot be migrated; the OAuth account starts a fresh workspace. | | ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in — run `arkor login --oauth` and try again.`` | `/v1/auth/anonymous` rejected the request with a 4xx, so anonymous bootstrap cannot proceed. | Run `arkor login --oauth` to complete the browser flow, then re-run `arkor dev`. | | `Could not write ~/.arkor/studio-token (...). The Studio at http://localhost: is unaffected, but the Vite SPA dev workflow will see 403s on /api/*.` | `$HOME` is read-only or umask blocks `0600`. The bundled Studio still works; only the standalone Vite dev workflow is affected. | Run from a writable home, or only use the bundled Studio served by `arkor dev`. | | HTTP 403 with `{ "error": "Studio API is loopback-only" }` (in browser devtools) | The `Host` header is something other than `127.0.0.1` / `localhost`. | Reach Studio via `http://localhost:` or `http://127.0.0.1:`. Reverse proxies or `0.0.0.0`-bound shells will be rejected by design. | diff --git a/docs/ja/cli/auth.mdx b/docs/ja/cli/auth.mdx index 40eaf2ec..df814d4e 100644 --- a/docs/ja/cli/auth.mdx +++ b/docs/ja/cli/auth.mdx @@ -43,10 +43,14 @@ arkor login [options] 匿名認証は、アカウントなしで Arkor を試すためのものです。学習、ジョブ、その他の作業は匿名トークンを介してローカルマシーンに紐づきます。あとで OAuth に切り替える(`arkor login --oauth`、またはピッカーから `OAuth (browser)` を選ぶ)と認証情報ファイルは差し替えられますが、作業は移行されません。匿名で学習したものを残したいなら、学習を始める前に `arkor login --oauth` を走らせてください。 +匿名アカウントは設計上 **単一端末専用** です。クラウド API は匿名ユーザーごとに `latest_jti` を保持しており、`userAuth` は受信した JWT の jti が一致しないリクエストをすべて拒否します。jti はトークンのローテーションでしか変わらないので、実際の発火タイミングは大きく 2 つに分かれます。**今のところ** CLI は匿名トークンの自動 refresh を実装していません(`@arkor/cloud-api-client` の `getToken()` 側で配線予定で、[SDK ロードマップ](/ja/roadmap) に乗っています)。そのためクライアント側からのローテーションは起こらず、別マシーンにコピーした `~/.arkor/credentials.json` も大抵しばらく動いてしまいます。失効するのは、発行ユーザーが明示的に新 identity を発行したとき(`arkor login --anonymous` が新しい jti でファイルを上書き)か、管理者がサーバー側でローテーションしたとき。そのタイミングで他のコピーは次の呼び出しから失敗します。**自動 refresh が入ると**、refresh のたびに `latest_jti` が即座に進むので、別端末(または同じマシーン上の古いバックアップ)に残った旧 jti のコピーは予告なく次の呼び出しで失敗するようになります。いずれにせよ、負けた側のクライアントは HTTP 401 / 409 を `code: "anonymous_token_single_device"` で受け取り、`cli/main.ts` がそれを実行可能なガイダンスとして整形します。匿名行そのものが削除された場合は `code: "anonymous_account_not_found"` も同じ仕組みで surface されます。リカバリー提案は **デプロイ形態に応じて分岐** します。OAuth がサポートされているデプロイでは `arkor login --oauth` を案内して複数端末対応のアカウントへサインアップする経路を示し、匿名専用デプロイ(OAuth 未設定)では代わりに `arkor login --anonymous` を案内します。後者で `--oauth` を出すとそのコマンドはそのまま失敗するので、新規匿名 identity の発行が唯一のリカバリ手段になるためです。**いずれのパスも、過去の匿名作業は移行されません**。前のワークスペースには発行元の credentials ファイルからしか到達できません。 + ### 匿名発行時の出力 どちらの匿名パスでも、新しい `anonymousId` と「同じ id がセッションをまたいで Arkor Cloud がこのクライアントを認識するための識別子である」旨の説明を必ず surface します。ただし出力の形は入口によって異なります。`arkor login`(`--anonymous` フラグまたはピッカー → **Anonymous**)はスピナーの停止行として `Anonymous id: ` を出し、続けて「認証情報ファイル(`credentialsPath()`、Linux と macOS では通常 `~/.arkor/credentials.json`)を保持していれば同じ identity を維持できる」旨の info 行を別行で出します。`arkor dev` の自動ブートストラップはスピナーを使わず、id と同じ説明を 1 本の info 行にまとめて出します。ピッカー → **Anonymous** のパスでは、さらに成功メッセージと並んで 1 行の warn(``Anonymous sessions aren't guaranteed to persist — sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``、和訳: 匿名セッションは永続性が保証されないので、今後の作業を Arkor Cloud アカウントに紐付けたいなら `arkor login --oauth` でサインインしてください)が出るので、発行時点でアップグレードのヒントが見えます。明示的な `--anonymous` ショートカットでは `/v1/auth/cli/config` の取得をスキップしているため `arkor login --oauth` がそのデプロイで成功するかわからず、稀に存在する匿名専用デプロイで失敗するコマンドへユーザーを誘導しないよう、warn は意図的に抑制されます。 +匿名発行のあらゆる入口で、単一端末の制約も別行の info として surface します。実際の文言は永続性ナッジと同じ `oauthAvailable` のゲーティング契約に従って分岐します。OAuth サポートのデプロイでは ``Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.``(和訳: 注意: 匿名アカウントはこのマシーンでのみ動作します。複数端末で使うには `arkor login --oauth` でサインアップしてください)が出ますが、匿名専用デプロイ、および `/v1/auth/cli/config` の取得を意図的にスキップする `arkor login --anonymous` の経路では、bare な ``Note: anonymous accounts work on this machine only.`` だけが出ます。失敗確実なコマンドへユーザーを誘導しないためです。`arkor whoami` も匿名 identity に対しては bare 版のみを stderr に出します。さらに stdout / stderr の両方が TTY のときに限るので、出力をリダイレクトしたとき(stdout が TTY でなくなる)や、stderr 出力をすべて警告扱いする CI ランナー(stderr が TTY でなくなる)では追加のテキストは出ません。あくまで UX のヒントであり、どの挙動もゲートしません。stdout 側の形(`JSON.stringify(user, null, 2)` で出される user オブジェクトの JSON と、org に所属していれば続けて 1 行の人間向け `Orgs: , …` 行)は note 追加前と変わりません。ただし `Orgs:` 行は JSON 本体の一部ではなく後ろに付くサマリ行なので、出力全体をそのまま `jq` のような厳密な JSON パーサーに通すのは避けてください。 + ## `arkor logout` ```bash @@ -98,7 +102,8 @@ arkor whoami - `0`: サインイン中、identity を表示。 - `0`: 未サインイン、メッセージは情報用のみ。 - `1`: クラウド API が `426 Upgrade Required` を返した。CLI はアップグレードのヒント(と検出したパッケージマネージャ用のアップグレードコマンド)を表示し、`process.exitCode = 1` を立てて、`arkor` のシャットダウンフックの非推奨警告フラッシュが終了前に走るようにします。 -- それ以外の 4xx / 5xx は `Failed to fetch /v1/me (). Token may be expired.`(`/v1/me` の取得に失敗しました(``)。トークンが期限切れの可能性があります)として報告し、`0` で終了します。 +- `1`: クラウド API が認証状態系の構造化 `code`(`anonymous_token_single_device` または `anonymous_account_not_found`)を返した。`cli/main.ts` のトップレベルハンドラがそれを stderr 上の実行可能なメッセージへ整形します。具体的な文言は [トークンの有効期限](#トークンの有効期限) を参照。 +- `1`: それ以外の非 2xx(一過性の 5xx、未マップの 4xx など)。以前は「`Failed to fetch /v1/me (). Token may be expired.` を stdout に出して終了コード `0`」でしたが、本リリースで撤廃しました。`arkor whoami` は `CloudApiError` を投げ、`bin.ts` が `err.message`(例: `cloud-api 503`)だけを stderr に出してから非ゼロで終了します。スタックトレースは未知の `Error` 形(実質 SDK のバグ)に限り出すので、通常の HTTP 失敗はラッパーのログを汚しません。 ## 認証情報の保存場所 @@ -110,10 +115,16 @@ OAuth セッションでは、認証情報ファイルにアクセストーク 実用上の意味は次のとおりです。 -- 期限切れアクセストークンは `arkor whoami` から `Failed to fetch /v1/me (401). Token may be expired.`(`/v1/me` の取得に失敗しました(401)。トークンが期限切れの可能性があります)として、あるいはクラウド API と話すあらゆるものから類似の失敗として現れます。 +- 期限切れまたは無効化されたトークンはクラウド API からの非 2xx として現れます。`arkor whoami` は汎用的な「Token may be expired」を出さなくなり、代わりに上流の `code`(あれば)を持った `CloudApiError` を投げ、`cli/main.ts` のトップレベルハンドラが既知 2 種の認証状態 `code` を実行可能なガイダンスへ整形します。整形前に `main()` は `/v1/auth/cli/config` を best-effort で取得し、リカバリーヒントをデプロイ形態に揃えます。 + - `code: "anonymous_token_single_device"`: + - OAuth サポートのデプロイ: ``Anonymous credentials were rejected as single-device. Anonymous accounts only work on one machine. Sign up for an account that supports multiple devices: arkor login --oauth``(終了コード `1`)。 + - 匿名専用デプロイ: ``Anonymous credentials were rejected as single-device. Anonymous accounts only work on one machine. This deployment does not advertise OAuth, so the only recovery is to mint a new anonymous identity (your previous workspace data cannot be recovered): arkor login --anonymous``(終了コード `1`)。 + - `code: "anonymous_account_not_found"` も同様に分岐し、末尾は `arkor login --oauth` または `arkor login --anonymous` のいずれか。 + + 既知の `code` を持たないエラー(および `CloudApiError` 以外の例外)は `main()` から再 throw されます。`bin.ts` のトップレベル `await main(...)` は try/catch で囲まれており、エラーの形で挙動を分けます。`CloudApiError` は `err.message`(上流の `error` 本文、または本文が空のときの `cloud-api ` フォールバック)だけを出すので、通常の HTTP 失敗は 1 行で終わり、原因を切り分けやすくなります。それ以外の `Error` は `err.stack ?? err.message` で出すので、本物の SDK バグはフレームが見えます。いずれの場合も `process.exit(1)` ではなく `process.exitCode = 1` を立てるので、`main()` の `finally` ブロックの非推奨警告フラッシュ + テレメトリーシャットダウンは終了前にちゃんと走ります。明示的に catch しているのは、Node のデフォルトの unhandled rejection ハンドラがバンドル後の minified なコードフレームを出すのを避け、サポート対象の Node バージョン全体で stderr の flush を確定させるためでもあります。 - OAuth セッションの直し方は `arkor login --oauth` をもう一度走らせることです。フル PKCE フローを通って `~/.arkor/credentials.json` を新トークンで上書きします。 -匿名トークンはクライアント側に有効期限の追跡がなく、いつ動かなくなるかはクラウド API が決めます。匿名セッションが失敗し始めたら `arkor login --anonymous` で新しいものを発行してください(新しい `anonymousId` が発行されるので、実質的には別ワークスペースになります)。 +匿名トークンはサーバー側に 90 日の TTL がありますが、CLI はまだ自動リフレッシュをしません。その配線は `@arkor/cloud-api-client` の `getToken()` 側にあり、SDK ロードマップに残っています。今日時点で匿名セッションが失敗し始めたら `arkor login --anonymous` で新しいものを発行してください(新しい `anonymousId` が発行されるので、実質的には別ワークスペースになります)。 ## よくあるエラー @@ -125,5 +136,7 @@ OAuth セッションでは、認証情報ファイルにアクセストーク | `Auth0 did not return a refresh token. Make sure the Application has 'offline_access' scope enabled.`(Auth0 がリフレッシュトークンを返しませんでした。Application で `offline_access` スコープを有効にしてください) | `arkor login --oauth` | OAuth のトークン交換は成功したがレスポンスに `refresh_token` が無く、CLI がアクセストークンの寿命を超えてセッションを保てない。たいていデプロイ側の設定ミス。 | OAuth(Auth0)アプリケーションで `offline_access` スコープを有効化してから `arkor login --oauth` を再実行。 | | `No credentials on file.`(認証情報ファイルがありません) | `arkor logout` | `~/.arkor/credentials.json` が存在しない。削除対象なし。 | サインインしたいなら先に `arkor login`。 | | `Not signed in. Run \`arkor login\` or \`arkor login --anonymous\`.`(サインインしていません。`arkor login` または `arkor login --anonymous` を実行してください) | `arkor whoami` | 上と同じ条件を別コマンドから出している。 | 同じ対処。 | -| `Failed to fetch /v1/me (). Token may be expired.`(`/v1/me` の取得に失敗しました(``)。トークンが期限切れの可能性があります) | `arkor whoami` | クラウド API が 200 と 426 以外のステータスで拒否した。多くは期限切れアクセストークン。 | 現在のモードに合わせて再ログイン: OAuth セッション(`mode: "auth0"`)なら `arkor login --oauth`、匿名セッション(`mode: "anon"`)なら `arkor login --anonymous`(新しい `anonymousId` で別ワークスペースになる点に注意)。終了コードは `0` のままで、ラッパースクリプトはメッセージを検査できる。 | +| ``Anonymous credentials were rejected as single-device. …``(末尾は `arkor login --oauth` または `arkor login --anonymous`、デプロイ形態次第) | `arkor whoami` および認証付きの全コマンド | クラウド API が `code: "anonymous_token_single_device"` で拒否した(userAuth の jti チェックからの HTTP 401、または rotate-jti の CAS からの HTTP 409)。認証情報ファイルが別端末にコピーされたか、別のローカルプロセスがこのトークンを recovery window 内でローテートし越えた、のいずれか。 | メッセージ末尾のコマンドに従う: OAuth サポートのデプロイなら `arkor login --oauth`、匿名専用デプロイなら `arkor login --anonymous`。どちらも *新規* identity で、過去の匿名作業は移行できない。CLI は `1` で終了。 | +| ``Your anonymous credentials are no longer valid. …``(末尾は `arkor login --oauth` または `arkor login --anonymous`) | `arkor whoami` および認証付きの全コマンド | クラウド API が `code: "anonymous_account_not_found"` で拒否した(HTTP 401)。背後の `anonymous_users` 行が削除された(admin / cascade / 明示的な revoke)。 | 上と同じく、メッセージ末尾のデプロイ依存コマンドに従う。前の匿名ワークスペースは取り戻せない。CLI は `1` で終了。 | +| `cloud-api `(1 行、スタックなし) | `arkor whoami` および認証付きの全コマンド | 構造化された認証状態 `code` を持たない非 200 / 非 426 のクラウド API レスポンス(一過性の 5xx、未マップの 4xx など)。`cli/main.ts` が `CloudApiError` をそのまま再 throw し、`bin.ts` がスタックの最上位で catch して `err.message`(上流の `error` 本文、または本文が空のときの `cloud-api ` フォールバック)だけを表示した後、`process.exitCode = 1` をセットする。スタックトレースは未知の `Error` 形(実質 SDK のバグ)に限り出す。 | メッセージをそのまま確認し、トランスポート / サーバー障害なら再試行。OAuth アクセストークンの期限切れなら `arkor login --oauth` を再実行。匿名トークンの期限切れなら `arkor login --anonymous` を再実行(新しい `anonymousId` で別ワークスペースになる)。 | | `426 Upgrade Required`(アップグレードヒント付き) | `arkor whoami`(および他のクラウド API 呼び出し) | デプロイがより新しい SDK バージョンを要求している。CLI は検出したパッケージマネージャ用のアップグレードコマンドを表示し、`process.exitCode = 1` を立てる。 | `arkor` パッケージをアップグレードして再実行。 | diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 8e88170f..8250f26e 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -58,8 +58,9 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | `TypeError: fetch failed`(または同等のトランスポートエラーで `arkor dev` がそのまま終了する場合) | `/v1/auth/cli/config` 自体に届かなかったため、デプロイモードが特定できず fail-fast。 | 接続を回復してから `arkor dev` を再実行。 | | ``No credentials on file — bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションをブートストラップします。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください) | OAuth をアドバタイズしているデプロイでこのマシーン初回の `arkor dev`。Studio をすぐ起動できるよう CLI が匿名でブートストラップしている旨の案内で、エラーではない。 | 何もしなくてよい。本物のアカウントにアップグレードしたいなら、別途 `arkor login --oauth` を実行(`~/.arkor/credentials.json` を上書き)して Studio をリロード。 | | `No credentials on file — requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します) | 同上だが匿名専用デプロイの場合(`/v1/auth/cli/config` で Auth0 がアドバタイズされていない)。`arkor login --oauth` は失敗するので OAuth ヒントは省かれる。 | 何もしなくてよい。 | -| ``Anonymous id: — Arkor Cloud uses this id to recognise this client across sessions. Keep `/.arkor/credentials.json` to stay signed in as the same anonymous identity.``(匿名 id: ``。Arkor Cloud はこの id でセッション間でこのクライアントを識別します。同じ匿名 identity を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`) | 匿名ブートストラップ完了後の情報行。クラウド側の識別子と、それを保持しているファイルの場所を明示する。 | 何もしなくてよい。別マシーンから同じ匿名 identity を使いたいなら認証情報ファイルをバックアップ。 | +| ``Anonymous id: — Arkor Cloud uses this id to recognise this client across sessions. Keep `/.arkor/credentials.json` to stay signed in as the same anonymous identity.``(匿名 id: ``。Arkor Cloud はこの id でセッション間でこのクライアントを識別します。同じ匿名 identity を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`) | 匿名ブートストラップ完了後の情報行。クラウド側の識別子と、それを保持しているファイルの場所を明示する。 | 何もしなくてよい。「同じ匿名 identity を維持」の案内は「ファイルを削除しない」という意味です。クラウド API はサーバー側で単一端末を強制しています: 認証付きリクエストは毎回 `userAuth` の `latest_jti` チェックを通るので、サーバー側で保持する jti と任意のコピーが持つ jti がずれた瞬間、そのコピーは次の呼び出しで `code: "anonymous_token_single_device"`(HTTP 401)を返されるようになります。今のところ SDK は自前で jti をローテーションしません(自動 refresh はロードマップ)。そのため第二端末のコピーがしばらく動いてしまうことはあるものの、その猶予はローテーションが起きた瞬間に消滅します。同じマシーンに置いた古いバックアップでも同様です。バックアップして復旧する対象ではなく、live state として扱ってください。 | | ``Anonymous sessions aren't guaranteed to persist — sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``(匿名セッションは永続性が保証されないので、今後の作業を Arkor Cloud アカウントに紐付けたいなら `arkor login --oauth` でサインインしてください) | デプロイが OAuth をサポートすると分かっているときに、成功メッセージと並んで出る永続性ナッジ。匿名作業はクラウド API 側で SLA がないので、本格的に作業する前にアップグレード経路を提示している。匿名専用デプロイでは抑制される。 | 任意。今後の作業をアカウントに紐付けたいなら `arkor login --oauth`。既存の匿名作業はその id に残り、現状マイグレーションパスはない。 | +| ``Note: anonymous accounts work on this machine only.``(OAuth サポート時には ``Run `arkor login --oauth` to sign up for multi-device access.`` が後に続く / 和訳: 注意: 匿名アカウントはこのマシーンでのみ動作します。複数端末で使うには `arkor login --oauth` でサインアップしてください) | 匿名ブートストラップ成功行と並んで出る情報行。匿名アカウントは発行マシーンに対して単一端末ガードでバインドされるので、別端末での 401 を待たず最初に制約を提示する。OAuth フレーバーのアップグレードヒントは永続性ナッジと同じ `oauthAvailable` でゲートされるので、匿名専用デプロイでは bare な fact のみが出る(`arkor login --oauth` は失敗するため)。 | 任意。OAuth サポートのデプロイでは、複数端末でアカウント付きの作業を始めたいタイミングで `arkor login --oauth` を実行。既存の匿名作業はマイグレーションできず、OAuth アカウントは新規スタート。 | | ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in — run `arkor login --oauth` and try again.``(匿名セッションのブートストラップに失敗しました(HTTP ``)。このデプロイはサインインが必要かもしれません。`arkor login --oauth` を実行して再試行してください) | `/v1/auth/anonymous` が 4xx で拒否され、匿名ブートストラップが進められない。 | `arkor login --oauth` でブラウザーフローを完了してから `arkor dev` を再実行。 | | `Could not write ~/.arkor/studio-token (...). The Studio at http://localhost: is unaffected, but the Vite SPA dev workflow will see 403s on /api/*.`(`~/.arkor/studio-token` を書き込めませんでした(...)。`http://localhost:` の Studio には影響しませんが、Vite SPA の dev ワークフローでは `/api/*` で 403 になります) | `$HOME` が読み取り専用、または umask が `0600` をブロック。同梱 Studio は機能し、影響はスタンドアローン Vite dev ワークフローのみ。 | 書ける home から実行するか、`arkor dev` が提供する同梱 Studio のみを使う。 | | HTTP 403 with `{ "error": "Studio API is loopback-only" }`(Studio API はループバック専用です。ブラウザーの devtools で確認) | `Host` ヘッダーが `127.0.0.1` / `localhost` 以外。 | `http://localhost:` か `http://127.0.0.1:` で Studio に到達。リバースプロキシや `0.0.0.0` バインドのシェルは設計通り拒否されます。 | diff --git a/packages/arkor/README.md b/packages/arkor/README.md index 41835cc7..1bb08fd2 100644 --- a/packages/arkor/README.md +++ b/packages/arkor/README.md @@ -87,6 +87,7 @@ login` / `arkor logout`. |---|---| | `arkor init` | Scaffold a project in the current directory | | `arkor login` / `logout` / `whoami` | Auth0 PKCE / anonymous tokens | +| `arkor login --anonymous` | Throwaway single-device token (see below) | | `arkor dev` | Launch the local Studio (hot reload + GUI) | | `arkor build [entry]` | Bundle `src/arkor/index.ts` (or `entry`) to `.arkor/build/index.mjs` | | `arkor start [entry]` | Run the build artifact; rebuilds when an entry is supplied | @@ -95,6 +96,39 @@ login` / `arkor logout`. (`arkor`, anything from `node_modules`) resolve at runtime against the project's installed copy. Relative imports get inlined. +### Anonymous accounts are single-device + +`arkor login --anonymous` (and the auto-bootstrap on first `arkor dev`) +issues a throwaway token tied to a brand-new personal org. **It is +designed for one machine.** The cloud-api stores a `latest_jti` per +anonymous user and rejects every authenticated request whose JWT +carries a different jti, so copying `~/.arkor/credentials.json` to a +second machine and using it from both will eventually trip the +server's single-device guard. The exact timing depends on whether the +SDK is currently auto-refreshing the token: today it isn't, so a +copy may keep working for a while; once one of the copies forces a +fresh login (or auto-refresh ships and rotates the stored jti), every +*other* copy starts failing on its next call with +`anonymous_token_single_device`. On OAuth-supporting deployments the +CLI directs the user at `arkor login --oauth` to start a real +account; on anon-only deployments it points at `arkor login +--anonymous` instead, since `--oauth` would fail there. Either path +is a *new* identity. Existing anonymous workspaces are not migrated +today. + +Re-issuing credentials alone is not enough to resume work in an +existing project directory: `.arkor/state.json` still pins the +directory to the previous `(orgSlug, projectSlug)`, so commands here +will keep targeting the now-defunct workspace until the state file +is reset. Delete `.arkor/state.json` before re-running anonymous +flows, or run `arkor init` to bootstrap a fresh project under the +OAuth account. + +Anonymous data isn't recoverable across re-issuance: deleting the +credentials file or losing the single-device race means the org and +everything in it become unreachable. Treat anonymous mode as a quick +trial path; sign up before you store anything you'd want to keep. + ## Studio `arkor dev` boots a Hono server on `127.0.0.1:4000` and serves a Vite + diff --git a/packages/arkor/src/bin.ts b/packages/arkor/src/bin.ts index eba8b216..dd9257b8 100644 --- a/packages/arkor/src/bin.ts +++ b/packages/arkor/src/bin.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; import process from "node:process"; +import { CloudApiError } from "./core/client"; import { main } from "./cli/main"; /** @@ -51,10 +52,24 @@ if (!hasStripTypesSupport()) { // but we want CI green across the whole supported Node range. // Setting `process.exitCode` (instead of `process.exit(1)`) lets the event // loop drain naturally so stderr fully flushes before exit. + // + // `CloudApiError` is special-cased to print just `err.message` (the + // upstream cloud-api `error` body or `cloud-api ` fallback). + // These cover routine failures — expired OAuth sessions, transient + // 5xx, unmapped 4xx — so dumping a full stack frame for every one + // turns expected auth/transport errors into noisy debugging output. + // For unknown `Error`s (likely actual SDK bugs) we still print the + // stack so users have something to file against. try { await main(process.argv.slice(2)); } catch (err) { - console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); + if (err instanceof CloudApiError) { + console.error(err.message); + } else if (err instanceof Error) { + console.error(err.stack ?? err.message); + } else { + console.error(String(err)); + } process.exitCode = 1; } } diff --git a/packages/arkor/src/cli/anonymous.ts b/packages/arkor/src/cli/anonymous.ts index fba7b956..4ed667f9 100644 --- a/packages/arkor/src/cli/anonymous.ts +++ b/packages/arkor/src/cli/anonymous.ts @@ -30,3 +30,23 @@ export async function acquireAnonymousTokenResult(baseUrl: string) { // defaulting to show. export const ANON_PERSISTENCE_NUDGE = "Anonymous sessions aren't guaranteed to persist — sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account."; + +// Single-device limitation note. The fact applies on every deployment +// (the cloud-api binds the issued token to the calling machine via +// `latest_jti` rotation regardless of whether OAuth is offered), so the +// bare `ANON_SINGLE_DEVICE_NOTE` is always safe to emit. The +// `..._WITH_OAUTH` variant additionally points at `arkor login --oauth` +// as the upgrade path; per the same gating contract as +// `ANON_PERSISTENCE_NUDGE`, callers must only emit it when +// `oauthAvailable === true`. The `false` and `undefined` cases fall back +// to the bare note so anon-only deployments don't advertise a command +// that fails immediately. +// +// The wording here is also what `formatAnonymousAuthError` returns when +// the cloud-api emits `code: "anonymous_token_single_device"`, so users +// see consistent phrasing whether they hit it at issuance or at the +// first 401. +export const ANON_SINGLE_DEVICE_NOTE = + "Note: anonymous accounts work on this machine only."; +export const ANON_SINGLE_DEVICE_NOTE_WITH_OAUTH = + `${ANON_SINGLE_DEVICE_NOTE} Run \`arkor login --oauth\` to sign up for multi-device access.`; diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 1104489c..3c5f2aa1 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -542,6 +542,85 @@ describe("ensureCredentialsForStudio", () => { ); }); }); + + // Same gating contract as the persistence nudge: the OAuth-flavoured + // single-device note ("Run `arkor login --oauth` to sign up for + // multi-device access") fires only when OAuth is *confirmed* available. + // Anon-only deployments fall back to the bare fact so users aren't + // pointed at a command that fails immediately. Both paths surface the + // single-device limitation up-front rather than letting users discover + // it via a 401 on a second machine. + describe("anon-issuance output (ANON_SINGLE_DEVICE_NOTE gating)", () => { + const okConfigResponse = () => + new Response( + JSON.stringify({ + auth0Domain: "tenant.auth0.com", + clientId: "client-id", + audience: "https://api.arkor.ai", + callbackPorts: [4000], + }), + { status: 200 }, + ); + const noOauthConfigResponse = () => + new Response( + JSON.stringify({ + auth0Domain: null, + clientId: null, + audience: null, + callbackPorts: [], + }), + { status: 200 }, + ); + const okAnonResponse = () => + new Response( + JSON.stringify({ + token: "anon-tok", + anonymousId: "anon-aid", + kind: "cli", + personalOrg: { id: "o", slug: "anon-aid", name: "Anon" }, + }), + { status: 200 }, + ); + + it("emits the OAuth-flavoured note when the deployment advertises OAuth", async () => { + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/v1/auth/cli/config")) return okConfigResponse(); + if (url.endsWith("/v1/auth/anonymous")) return okAnonResponse(); + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + const infoSpy = vi.spyOn(clack.log, "info"); + + await ensureCredentialsForStudio(); + + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Run `arkor login --oauth` to sign up for multi-device access", + ), + ); + }); + + it("emits the bare note on anon-only deployments", async () => { + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/v1/auth/cli/config")) return noOauthConfigResponse(); + if (url.endsWith("/v1/auth/anonymous")) return okAnonResponse(); + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + const infoSpy = vi.spyOn(clack.log, "info"); + + await ensureCredentialsForStudio(); + + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining( + "anonymous accounts work on this machine only", + ), + ); + expect(infoSpy).not.toHaveBeenCalledWith( + expect.stringContaining("arkor login --oauth"), + ); + }); + }); }); describe("runDev", () => { diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index e2bf01cf..6241ae3a 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -16,7 +16,11 @@ import { type AnonymousCredentials, } from "../../core/credentials"; import { buildStudioApp } from "../../studio/server"; -import { ANON_PERSISTENCE_NUDGE } from "../anonymous"; +import { + ANON_PERSISTENCE_NUDGE, + ANON_SINGLE_DEVICE_NOTE, + ANON_SINGLE_DEVICE_NOTE_WITH_OAUTH, +} from "../anonymous"; import { ui } from "../prompts"; export interface DevOptions { @@ -153,6 +157,18 @@ export async function ensureCredentialsForStudio(): Promise { ui.log.warn(ANON_PERSISTENCE_NUDGE); } ui.log.success(`Signed in anonymously (${anon.orgSlug}).`); + // Match the `arkor login --anonymous` outro: anonymous accounts are + // single-device on purpose, so discovering that on a second machine + // via a 401 is a worse UX than being told here. Same gating contract + // as the persistence nudge above — the OAuth-flavoured variant fires + // only when OAuth is confirmed available, otherwise we surface the + // bare fact so anon-only deployments aren't pointed at a command that + // cannot succeed. + ui.log.info( + oauthAvailable + ? ANON_SINGLE_DEVICE_NOTE_WITH_OAUTH + : ANON_SINGLE_DEVICE_NOTE, + ); } /** diff --git a/packages/arkor/src/cli/commands/login.test.ts b/packages/arkor/src/cli/commands/login.test.ts index 88883d5a..dec5b300 100644 --- a/packages/arkor/src/cli/commands/login.test.ts +++ b/packages/arkor/src/cli/commands/login.test.ts @@ -348,6 +348,107 @@ describe("runLogin", () => { }); }); + // The single-device note follows the same gating contract as + // `ANON_PERSISTENCE_NUDGE`: the OAuth-flavoured variant only fires when + // `oauthAvailable === true`, anything else falls back to the bare fact + // so anon-only deployments aren't pointed at a command that fails. The + // bare fact ("works on this machine only") is universally true and + // always emitted, regardless of deployment shape. + describe("anon-issuance output (ANON_SINGLE_DEVICE_NOTE gating)", () => { + const okConfigResponse = () => + new Response( + JSON.stringify({ + auth0Domain: "tenant.auth0.com", + clientId: "client-id", + audience: "https://api.arkor.ai", + callbackPorts: [4000], + }), + { status: 200 }, + ); + const noOauthConfigResponse = () => + new Response( + JSON.stringify({ + auth0Domain: null, + clientId: null, + audience: null, + callbackPorts: [], + }), + { status: 200 }, + ); + const okAnonResponse = () => + new Response( + JSON.stringify({ + token: "anon-tok", + anonymousId: "anon-aid", + kind: "cli", + personalOrg: { id: "o", slug: "anon-aid", name: "Anon" }, + }), + { status: 200 }, + ); + + it("emits the OAuth-flavoured note on picker → Anonymous when OAuth is configured", async () => { + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/v1/auth/cli/config")) return okConfigResponse(); + if (url.endsWith("/v1/auth/anonymous")) return okAnonResponse(); + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + const infoSpy = vi.spyOn(clack.log, "info"); + + await runLogin(); + + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Run `arkor login --oauth` to sign up for multi-device access", + ), + ); + }); + + it("emits the bare note on the explicit --anonymous shortcut", async () => { + // `--anonymous` skips the cfg fetch, so `oauthAvailable` is undefined. + // The bare fact is always safe to surface; the OAuth pointer is not + // — that gating is what this test enforces. + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/v1/auth/anonymous")) return okAnonResponse(); + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + const infoSpy = vi.spyOn(clack.log, "info"); + + await runLogin({ anonymous: true }); + + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining( + "anonymous accounts work on this machine only", + ), + ); + expect(infoSpy).not.toHaveBeenCalledWith( + expect.stringContaining("arkor login --oauth"), + ); + }); + + it("emits the bare note when OAuth is not configured", async () => { + globalThis.fetch = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/v1/auth/cli/config")) return noOauthConfigResponse(); + if (url.endsWith("/v1/auth/anonymous")) return okAnonResponse(); + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + const infoSpy = vi.spyOn(clack.log, "info"); + + await runLogin(); + + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining( + "anonymous accounts work on this machine only", + ), + ); + expect(infoSpy).not.toHaveBeenCalledWith( + expect.stringContaining("arkor login --oauth"), + ); + }); + }); + it("completes the PKCE flow end-to-end and persists Auth0 credentials when --oauth + non-CI", async () => { // Lift the CI guard: --oauth is rejected in CI before any browser // interaction, so we have to pretend we're on a developer machine. diff --git a/packages/arkor/src/cli/commands/login.ts b/packages/arkor/src/cli/commands/login.ts index 3113f53c..c08987be 100644 --- a/packages/arkor/src/cli/commands/login.ts +++ b/packages/arkor/src/cli/commands/login.ts @@ -15,6 +15,8 @@ import { } from "../../core/credentials"; import { ANON_PERSISTENCE_NUDGE, + ANON_SINGLE_DEVICE_NOTE, + ANON_SINGLE_DEVICE_NOTE_WITH_OAUTH, acquireAnonymousTokenResult, } from "../anonymous"; import { promptSelect, ui } from "../prompts"; @@ -153,6 +155,17 @@ async function runAnonymousLogin(opts: { ui.log.warn(ANON_PERSISTENCE_NUDGE); } ui.log.success(`Signed in anonymously (personal org: ${result.orgSlug}).`); + // Surface the single-device constraint immediately so users don't + // discover it the hard way when copying credentials.json to a second + // machine. Same gating contract as `ANON_PERSISTENCE_NUDGE`: the + // OAuth-flavoured variant fires only when OAuth is *confirmed* + // available, anything else falls back to the bare fact so anon-only + // deployments aren't pointed at a command that cannot succeed. + ui.log.info( + opts.oauthAvailable === true + ? ANON_SINGLE_DEVICE_NOTE_WITH_OAUTH + : ANON_SINGLE_DEVICE_NOTE, + ); } interface ResolvedCliConfig { diff --git a/packages/arkor/src/cli/commands/whoami.test.ts b/packages/arkor/src/cli/commands/whoami.test.ts index a6cb32f2..e125917a 100644 --- a/packages/arkor/src/cli/commands/whoami.test.ts +++ b/packages/arkor/src/cli/commands/whoami.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudApiError } from "../../core/client"; import { writeCredentials } from "../../core/credentials"; import { runWhoami } from "./whoami"; @@ -237,6 +238,34 @@ describe("runWhoami", () => { expect(capturedAuth).toBe("Bearer auth0-at"); }); + it("targets the credentials' arkorCloudApiUrl, not ARKOR_CLOUD_API_URL", async () => { + // Anonymous credentials pin the cloud-api they were issued against. + // If the env-derived default has changed since login (or this command + // is run against a non-default endpoint), `whoami` must follow the + // credentials, otherwise the dead-end formatter in `cli/main.ts` + // could surface single-device guidance for a token that's actually + // valid on its original deployment. + await writeCredentials({ + mode: "anon", + token: "anon-tok", + anonymousId: "abc", + arkorCloudApiUrl: "https://custom.cloud.example", + orgSlug: "anon-abc", + }); + process.env.ARKOR_CLOUD_API_URL = "http://mock-cloud-api"; + let seenUrl = ""; + globalThis.fetch = vi.fn(async (input) => { + seenUrl = String(input); + return new Response( + JSON.stringify({ user: { id: "u-anon" }, orgs: [] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + + await runWhoami(); + expect(seenUrl).toBe("https://custom.cloud.example/v1/me"); + }); + it("falls back to org id when an org has no slug", async () => { // Branch coverage for `o.slug ?? o.id` — historic data may have orgs // without a slug column populated; the helper must still render @@ -264,7 +293,66 @@ describe("runWhoami", () => { expect(out).toMatch(/Orgs: o-without-slug, named/); }); - it("prints a 'token may be expired' hint on other non-2xx without setting exitCode", async () => { + it("appends the bare single-device note on TTY when credentials are anonymous", async () => { + // The note is keyed on the local `creds.mode === "anon"` rather + // than the cloud-api's `/v1/me` body shape, because the response + // schema doesn't guarantee a discriminator field. The fixture + // here mirrors what the existing E2E tests assume the server + // returns (a plain user object, no `kind`) so a regression that + // re-introduces a body-shape dependency is caught here. + await writeCredentials({ + mode: "anon", + token: "anon-tok", + anonymousId: "abc", + arkorCloudApiUrl: "http://mock-cloud-api", + orgSlug: "anon-abc", + }); + globalThis.fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + user: { id: "u-anon", email: null }, + orgs: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as typeof fetch; + + // Pretend the user is in an interactive terminal; the TTY gate + // (see whoami.ts) only emits the note when both stdout and stderr + // are TTYs so wrappers/CI scripts aren't surprised by output. + const origStdoutIsTTY = process.stdout.isTTY; + const origStderrIsTTY = process.stderr.isTTY; + process.stdout.isTTY = true; + process.stderr.isTTY = true; + try { + await runWhoami(); + } finally { + process.stdout.isTTY = origStdoutIsTTY; + process.stderr.isTTY = origStderrIsTTY; + } + // The note goes to stderr so wrapper scripts piping `arkor whoami` + // through `jq` (or grepping the JSON / `Orgs:` line on stdout) + // aren't broken by human-oriented prose appearing in their data + // stream. stdout must stay machine-parseable. + const out = stdoutChunks.join(""); + const err = stderrChunks.join(""); + expect(err).toMatch( + /Note: anonymous accounts work on this machine only\./, + ); + expect(out).not.toMatch(/this machine only/); + // The OAuth-flavoured upgrade hint is suppressed here because + // whoami does not know whether OAuth is configured on the + // deployment. + expect(err).not.toMatch(/arkor login --oauth/); + }); + + it("suppresses the single-device note when stdout/stderr aren't TTY (CI/scripts)", async () => { + // CI runners and shell wrappers (`arkor whoami | jq`, + // `arkor whoami > out`) drop `isTTY` on the redirected stream. + // Many CI tools also treat any stderr-on-success output as a + // warning marker, so the note must stay quiet outside an + // interactive terminal. await writeCredentials({ mode: "anon", token: "anon-tok", @@ -273,15 +361,104 @@ describe("runWhoami", () => { orgSlug: "anon-abc", }); globalThis.fetch = vi.fn( - async () => new Response("{}", { status: 401 }), + async () => + new Response( + JSON.stringify({ + user: { id: "u-anon", email: null }, + orgs: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as typeof fetch; + + // vitest workers default to non-TTY; assert that explicitly so + // the test isn't depending on host-environment defaults. + const origStdoutIsTTY = process.stdout.isTTY; + const origStderrIsTTY = process.stderr.isTTY; + process.stdout.isTTY = false; + process.stderr.isTTY = false; + try { + await runWhoami(); + } finally { + process.stdout.isTTY = origStdoutIsTTY; + process.stderr.isTTY = origStderrIsTTY; + } + const out = stdoutChunks.join(""); + const err = stderrChunks.join(""); + expect(out).not.toMatch(/this machine only/); + expect(err).not.toMatch(/this machine only/); + }); + + it("does not append the single-device note for non-anonymous users", async () => { + // Auth0 sessions are unaffected by the anonymous single-device + // guard, so the note should not surface there. + await writeCredentials({ + mode: "auth0", + accessToken: "at", + refreshToken: "rt", + expiresAt: 0, + auth0Domain: "tenant.auth0.com", + audience: "https://api.arkor.ai", + clientId: "cid", + }); + globalThis.fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + user: { kind: "auth0", auth0Id: "auth0|sub" }, + orgs: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), ) as typeof fetch; await runWhoami(); const out = stdoutChunks.join(""); - expect(out).toMatch(/Failed to fetch \/v1\/me \(401\)/); - expect(out).toMatch(/Token may be expired/); - // Distinct from the 426 path: no hard block on auth failures, just a - // hint, so the deprecation flush in main.ts can still run. - expect(process.exitCode).not.toBe(1); + const err = stderrChunks.join(""); + expect(out).not.toMatch(/this machine only/); + expect(err).not.toMatch(/this machine only/); + }); + + it("throws CloudApiError on other non-2xx so cli/main.ts can format auth-state codes", async () => { + // Previously the command swallowed non-426 failures with a generic + // "Token may be expired" hint. The new contract is to throw a + // `CloudApiError` (with the upstream `code` if present) so the + // top-level handler in `cli/main.ts` can surface + // `anonymous_token_single_device` / `anonymous_account_not_found` + // with actionable guidance instead of a vague hint. + await writeCredentials({ + mode: "anon", + token: "anon-tok", + anonymousId: "abc", + arkorCloudApiUrl: "http://mock-cloud-api", + orgSlug: "anon-abc", + }); + globalThis.fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + error: "Anonymous token revoked", + code: "anonymous_token_single_device", + }), + { status: 401, headers: { "content-type": "application/json" } }, + ), + ) as typeof fetch; + + let caught: unknown; + try { + await runWhoami(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(CloudApiError); + const err = caught as CloudApiError; + expect(err.status).toBe(401); + expect(err.code).toBe("anonymous_token_single_device"); + expect(err.message).toMatch(/revoked/); + // No print-and-return: the previous "Token may be expired" hint is + // intentionally gone so callers can't accidentally degrade structured + // codes into a vague string. + const out = stdoutChunks.join(""); + expect(out).not.toMatch(/Token may be expired/); }); }); diff --git a/packages/arkor/src/cli/commands/whoami.ts b/packages/arkor/src/cli/commands/whoami.ts index da630a0c..4bb4f948 100644 --- a/packages/arkor/src/cli/commands/whoami.ts +++ b/packages/arkor/src/cli/commands/whoami.ts @@ -1,4 +1,4 @@ -import { CloudApiClient } from "../../core/client"; +import { buildCloudApiError, CloudApiClient } from "../../core/client"; import { defaultArkorCloudApiUrl, readCredentials, @@ -6,6 +6,7 @@ import { import { recordDeprecation } from "../../core/deprecation"; import { formatSdkUpgradeError } from "../../core/upgrade-hint"; import { SDK_VERSION } from "../../core/version"; +import { ANON_SINGLE_DEVICE_NOTE } from "../anonymous"; import { createClient } from "@arkor/cloud-api-client"; export async function runWhoami(): Promise { @@ -16,7 +17,19 @@ export async function runWhoami(): Promise { ); return; } - const baseUrl = defaultArkorCloudApiUrl(); + // Anonymous credentials carry the cloud-api URL they were issued + // against; honour that over `ARKOR_CLOUD_API_URL` so a user whose env + // changed since login (or who ran a previous command against a non- + // default endpoint) still hits the deployment that issued the token. + // Without this, `whoami` would query the wrong cloud-api and the + // top-level handler in `cli/main.ts` could surface dead-end auth-state + // guidance for a token that's actually valid on its original + // deployment. Auth0 credentials don't pin a cloud-api URL, so they + // fall through to the env-derived default. + const baseUrl = + creds.mode === "anon" && creds.arkorCloudApiUrl + ? creds.arkorCloudApiUrl + : defaultArkorCloudApiUrl(); // Use the RPC client directly for /v1/me rather than CloudApiClient so we // hit the typed surface and avoid duplicating the plumbing. const rpc = createClient({ @@ -44,10 +57,12 @@ export async function runWhoami(): Promise { process.exitCode = 1; return; } - process.stdout.write( - `Failed to fetch /v1/me (${status}). Token may be expired.\n`, - ); - return; + // Throw a `CloudApiError` carrying the structured `code` so the + // top-level handler in `cli/main.ts` can format anonymous auth-state + // failures (`anonymous_token_single_device`, + // `anonymous_account_not_found`) into actionable guidance instead of + // a generic "Token may be expired" line. + throw await buildCloudApiError(res); } const body = (await res.json()) as { user: Record; @@ -59,6 +74,40 @@ export async function runWhoami(): Promise { `Orgs: ${body.orgs.map((o) => String(o.slug ?? o.id)).join(", ")}\n`, ); } + if (creds.mode === "anon" && process.stdout.isTTY && process.stderr.isTTY) { + // Anonymous accounts are single-device on purpose, so surface the + // limitation here so users discover it before hitting a 401 on a + // second machine. We key off `creds.mode` (already in scope from + // `readCredentials()`) rather than `body.user.kind` because the + // cloud-api's `/v1/me` schema doesn't guarantee a `kind` field on + // the response — relying on it would silently skip the note for + // anonymous users on every deployment that doesn't surface the + // discriminator. + // + // We deliberately emit the *bare* fact rather than the + // OAuth-flavoured variant: `whoami` doesn't fetch + // `/v1/auth/cli/config`, so it can't tell whether OAuth is offered, + // and steering anon-only users at `arkor login --oauth` would point + // them at a command that fails immediately. The matching login/dev + // surfaces, which already know `oauthAvailable`, do append the + // upgrade hint when warranted. + // + // TTY gate: emit only when *both* stdout and stderr are + // interactive. Any redirected stream drops `stdout.isTTY`, and + // CI runners that treat any stderr-on-success output as a + // warning marker drop `stderr.isTTY`. Both groups get clean + // output. The note goes to stderr (not stdout) so it never + // reaches a downstream consumer parsing stdout, even on hosts + // where stdout happens to be a TTY but the user is still + // capturing it (e.g. `script(1)`). + // + // Note that stdout itself isn't a strict-JSON stream regardless + // of TTY: the optional `Orgs: , …` line above is a human + // summary tail, not part of the JSON document. Don't pipe the + // full output through `jq`. Read the JSON head with a parser + // that stops at the first complete value, or grep for `Orgs:`. + process.stderr.write(`\n${ANON_SINGLE_DEVICE_NOTE}\n`); + } // Avoid "unused import" noise by referencing CloudApiClient in an assertion. void CloudApiClient; } diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index 37fc1f6f..380ee3f0 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -12,6 +12,30 @@ vi.mock("./commands/build", () => ({ runBuild: vi.fn() })); vi.mock("./commands/start", () => ({ runStart: vi.fn() })); vi.mock("./commands/dev", () => ({ runDev: vi.fn() })); +// `auth0.fetchCliConfig` is consulted by main()'s anonymous-auth-error +// branch to decide whether to recommend `--oauth` (OAuth-supporting +// deployment) or `--anonymous` (anon-only deployment) in the friendly +// error message. Mock it so tests can pin the deployment shape without +// going to the network. +vi.mock("../core/auth0", () => ({ + fetchCliConfig: vi.fn(), +})); + +// `readCredentials` is read by `probeOauthAvailability` so the probe can +// hit the credentials' *own* cloud-api URL (the one that produced the +// auth error) rather than the global default. Partial-mock the module so +// `defaultArkorCloudApiUrl` keeps its real implementation while +// `readCredentials` becomes controllable per-test. +vi.mock("../core/credentials", async () => { + const actual = await vi.importActual< + typeof import("../core/credentials") + >("../core/credentials"); + return { + ...actual, + readCredentials: vi.fn(async () => null), + }; +}); + // Telemetry: the wrapper just delegates to the inner handler in tests // so we don't have to thread PostHog state through every assertion. vi.mock("../core/telemetry", () => ({ @@ -40,6 +64,9 @@ import { runLogin } from "./commands/login"; import { runLogout } from "./commands/logout"; import { runStart } from "./commands/start"; import { runWhoami } from "./commands/whoami"; +import { fetchCliConfig } from "../core/auth0"; +import { CloudApiError } from "../core/client"; +import { readCredentials } from "../core/credentials"; import { shutdownTelemetry } from "../core/telemetry"; import { main } from "./main"; @@ -58,6 +85,9 @@ beforeEach(() => { vi.mocked(runDev).mockReset(); vi.mocked(shutdownTelemetry).mockReset(); vi.mocked(shutdownTelemetry).mockResolvedValue(undefined); + vi.mocked(fetchCliConfig).mockReset(); + vi.mocked(readCredentials).mockReset(); + vi.mocked(readCredentials).mockResolvedValue(null); mockDeprecation.value = null; }); @@ -202,6 +232,208 @@ describe("main (CLI Commander wiring)", () => { expect(shutdownTelemetry).toHaveBeenCalledOnce(); }); + describe("anonymous auth-state error formatting", () => { + // Helper: spy on stderr so we can assert the friendly message landed, + // and snapshot/restore process.exitCode so the assertions for one test + // don't leak into the next. + function captureStderr(): { + chunks: string[]; + restore: () => void; + } { + const chunks: string[] = []; + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation(((c: unknown) => { + chunks.push(String(c)); + return true; + }) as typeof process.stderr.write); + return { chunks, restore: () => spy.mockRestore() }; + } + + const ORIG_EXIT_CODE = process.exitCode; + afterEach(() => { + process.exitCode = ORIG_EXIT_CODE; + }); + + it("formats anonymous_token_single_device with --oauth hint when OAuth is configured", async () => { + vi.mocked(fetchCliConfig).mockResolvedValueOnce({ + auth0Domain: "tenant.auth0.com", + clientId: "cid", + audience: "https://api.arkor.ai", + callbackPorts: [52521], + }); + vi.mocked(runWhoami).mockRejectedValueOnce( + new CloudApiError( + 409, + "Anonymous token is no longer current.", + "anonymous_token_single_device", + ), + ); + + const { chunks, restore } = captureStderr(); + try { + await main(["whoami"]); + } finally { + restore(); + } + const buf = chunks.join(""); + expect(buf).toMatch(/Anonymous credentials were rejected as single-device/); + expect(buf).toMatch(/arkor login --oauth/); + expect(process.exitCode).toBe(1); + }); + + it("formats anonymous_token_single_device with --anonymous hint on anon-only deployments", async () => { + // No Auth0 advertised → probeOauthAvailability returns "absent" → + // formatter confidently recommends the only working recovery. + vi.mocked(fetchCliConfig).mockResolvedValueOnce({ + auth0Domain: null, + clientId: null, + audience: null, + callbackPorts: [52521], + }); + vi.mocked(runWhoami).mockRejectedValueOnce( + new CloudApiError( + 409, + "...", + "anonymous_token_single_device", + ), + ); + + const { chunks, restore } = captureStderr(); + try { + await main(["whoami"]); + } finally { + restore(); + } + const buf = chunks.join(""); + expect(buf).toMatch(/arkor login --anonymous/); + expect(buf).not.toMatch(/arkor login --oauth/); + expect(process.exitCode).toBe(1); + }); + + it("formats anonymous_account_not_found with the right hint", async () => { + vi.mocked(fetchCliConfig).mockResolvedValueOnce({ + auth0Domain: "tenant.auth0.com", + clientId: "cid", + audience: "https://api.arkor.ai", + callbackPorts: [52521], + }); + vi.mocked(runWhoami).mockRejectedValueOnce( + new CloudApiError( + 401, + "Anonymous credentials are no longer valid.", + "anonymous_account_not_found", + ), + ); + + const { chunks, restore } = captureStderr(); + try { + await main(["whoami"]); + } finally { + restore(); + } + const buf = chunks.join(""); + expect(buf).toMatch(/no longer valid/); + expect(buf).toMatch(/arkor login --oauth/); + expect(process.exitCode).toBe(1); + }); + + it("rethrows CloudApiErrors without a known anonymous-auth code", async () => { + // Generic non-2xx without the structured code goes through the + // existing bin.ts/Node default handling, not the friendly formatter. + // The probe should NOT fire for these — it's only useful for the + // dead-end codes — so this test also asserts fetchCliConfig stayed + // un-called. + vi.mocked(runWhoami).mockRejectedValueOnce( + new CloudApiError(500, "boom"), + ); + await expect(main(["whoami"])).rejects.toThrow(/boom/); + expect(fetchCliConfig).not.toHaveBeenCalled(); + }); + + it("rethrows non-CloudApiError exceptions unchanged", async () => { + vi.mocked(runWhoami).mockRejectedValueOnce(new Error("not an api error")); + await expect(main(["whoami"])).rejects.toThrow(/not an api error/); + expect(fetchCliConfig).not.toHaveBeenCalled(); + }); + + it("probes OAuth against the credentials' arkorCloudApiUrl, not the global default", async () => { + // Anonymous credentials carry the cloud-api URL they were issued + // against. The probe must hit *that* URL rather than + // `defaultArkorCloudApiUrl()` so we don't inspect the wrong + // deployment when `ARKOR_CLOUD_API_URL` differs from the URL the + // failing token is bound to. + vi.mocked(readCredentials).mockResolvedValueOnce({ + mode: "anon", + token: "anon-token", + anonymousId: "anon_abc", + arkorCloudApiUrl: "https://custom.cloud.example", + orgSlug: "custom-personal", + }); + vi.mocked(fetchCliConfig).mockResolvedValueOnce({ + auth0Domain: "tenant.auth0.com", + clientId: "cid", + audience: "https://api.arkor.ai", + callbackPorts: [52521], + }); + vi.mocked(runWhoami).mockRejectedValueOnce( + new CloudApiError( + 409, + "...", + "anonymous_token_single_device", + ), + ); + + const { restore } = captureStderr(); + try { + await main(["whoami"]); + } finally { + restore(); + } + // The probe also bounds itself with `AbortSignal.timeout(...)` so a + // hung cli/config can't block the recovery message; assert the + // signal makes it through alongside the URL. + expect(fetchCliConfig).toHaveBeenCalledWith( + "https://custom.cloud.example", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("hedges with both commands when fetchCliConfig fails (probe inconclusive)", async () => { + // Network blip → probeOauthAvailability returns "unknown" → + // formatter surfaces both `--oauth` and `--anonymous` so a + // transient probe failure on an OAuth-supporting deployment + // doesn't push users toward the wrong recovery. An earlier + // version collapsed every probe failure to `false` and + // confidently steered users at `--anonymous` only. + vi.mocked(fetchCliConfig).mockRejectedValueOnce( + new TypeError("fetch failed"), + ); + vi.mocked(runWhoami).mockRejectedValueOnce( + new CloudApiError( + 409, + "...", + "anonymous_token_single_device", + ), + ); + + const { chunks, restore } = captureStderr(); + try { + await main(["whoami"]); + } finally { + restore(); + } + const buf = chunks.join(""); + expect(buf).toMatch(/Couldn't reach the deployment/); + expect(buf).toMatch(/arkor login --oauth/); + expect(buf).toMatch(/arkor login --anonymous/); + // The hedge text must NOT claim the deployment is anon-only + // when we couldn't confirm. + expect(buf).not.toMatch(/does not advertise OAuth/); + expect(process.exitCode).toBe(1); + }); + }); + it("omits the Cutoff suffix when the deprecation has no sunset value", async () => { // Branch coverage for the `notice.sunset ? ` Cutoff: …` : ""` ternary. mockDeprecation.value = { diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index 6a718706..b193f1a2 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -7,12 +7,74 @@ import { runDev } from "./commands/dev"; import { runBuild } from "./commands/build"; import { runStart } from "./commands/start"; import { resolvePackageManager, type TemplateId } from "@arkor/cli-internal"; +import { + formatAnonymousAuthError, + isAnonymousAuthDeadEnd, +} from "../core/anonymous-auth-error"; +import { fetchCliConfig } from "../core/auth0"; +import { + defaultArkorCloudApiUrl, + readCredentials, +} from "../core/credentials"; import { getRecordedDeprecation } from "../core/deprecation"; import { shutdownTelemetry, withTelemetry } from "../core/telemetry"; import { detectedUpgradeCommand } from "../core/upgrade-hint"; import { SDK_VERSION } from "../core/version"; import { ui } from "./prompts"; +/** + * Resolve OAuth availability for the current deployment so anonymous-auth + * dead-end errors recommend a recovery path that actually works on + * anon-only deployments. Probes the *credentials' own* cloud-api URL + * (the one that just produced the auth error), not the global default + * — `ARKOR_CLOUD_API_URL` may have changed since the credentials were + * issued, or a command may have run against a non-default endpoint, in + * which case probing `defaultArkorCloudApiUrl()` would inspect the + * wrong deployment and recommend the opposite recovery path. + * + * Returns a tri-state rather than a boolean. An earlier version + * collapsed every probe failure to `false`, which made the formatter + * confidently steer users at `arkor login --anonymous` even when the + * config endpoint was just timing out on an OAuth-supporting + * deployment, hiding the real recovery (`arkor login --oauth`). + * Distinguishing the cases lets the formatter hedge when we genuinely + * don't know: + * + * - `"available"`: cfg fetched, Auth0 fields present → `--oauth`. + * - `"absent"`: cfg fetched, no Auth0 fields → `--anonymous`. + * - `"unknown"`: probe failed (network, timeout, malformed cfg, + * missing credentials) → suggest both, with an honest hedge. + * + * The probe runs *after* a command has already failed, so blocking + * the recovery hint behind an unbounded HTTP call would compound the + * outage. `AbortSignal.timeout` caps the probe at 3 s so the user + * always gets *some* guidance even when the cloud-api is sick. That + * timeout falls into `"unknown"`. + */ +type OauthAvailability = "available" | "absent" | "unknown"; +const PROBE_TIMEOUT_MS = 3000; +async function probeOauthAvailability(): Promise { + try { + const creds = await readCredentials().catch(() => null); + // Only `AnonymousCredentials` carries `arkorCloudApiUrl`; the + // anon-auth-error path only fires for anonymous tokens anyway, but + // we defensively fall through to the global default for any other + // shape rather than throwing on a `Auth0Credentials` narrowing. + const baseUrl = + creds?.mode === "anon" && creds.arkorCloudApiUrl + ? creds.arkorCloudApiUrl + : defaultArkorCloudApiUrl(); + const cfg = await fetchCliConfig(baseUrl, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + return cfg.auth0Domain && cfg.clientId && cfg.audience + ? "available" + : "absent"; + } catch { + return "unknown"; + } +} + export async function main(argv: string[]): Promise { const program = new Command(); program.name("arkor").description("Arkor CLI").version(SDK_VERSION); @@ -149,6 +211,40 @@ export async function main(argv: string[]): Promise { try { await program.parseAsync(argv, { from: "user" }); + } catch (err) { + // Intercept the structured anonymous-auth-state errors before they + // propagate to bin.ts and get rendered with a stack trace. Only the + // two known dead-end codes (`anonymous_token_single_device`, + // `anonymous_account_not_found`) are formatted here; everything + // else rethrows so the existing fallback in bin.ts surfaces it. + // Setting `process.exitCode` (rather than calling `process.exit` + // directly) keeps the deprecation + telemetry-shutdown step in the + // `finally` block reachable. + if (isAnonymousAuthDeadEnd(err)) { + // Probe deployment OAuth status only on the dead-end path so we + // don't add a network round-trip to every successful command. + // The probe is tri-state (`"available"` / `"absent"` / + // `"unknown"`), so the formatter can hedge instead of + // confidently recommending `--anonymous` when the config + // endpoint just timed out on an OAuth-supporting deployment. + const probe = await probeOauthAvailability(); + const friendly = formatAnonymousAuthError(err, { + oauthAvailable: + probe === "available" + ? true + : probe === "absent" + ? false + : undefined, + }); + if (friendly !== null) { + process.stderr.write(`${friendly}\n`); + process.exitCode = 1; + } else { + throw err; + } + } else { + throw err; + } } finally { const notice = getRecordedDeprecation(); if (notice) { diff --git a/packages/arkor/src/core/anonymous-auth-error.test.ts b/packages/arkor/src/core/anonymous-auth-error.test.ts new file mode 100644 index 00000000..55722a75 --- /dev/null +++ b/packages/arkor/src/core/anonymous-auth-error.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ANONYMOUS_ACCOUNT_NOT_FOUND, + ANONYMOUS_TOKEN_SINGLE_DEVICE, + formatAnonymousAuthError, + isAnonymousAuthDeadEnd, +} from "./anonymous-auth-error"; +import { CloudApiError } from "./client"; + +// `formatAnonymousAuthError` reads `process.env.CI` by default to +// decide whether the recovery hint can include `arkor login --oauth` +// (`runLogin` rejects `--oauth` up front in CI, so suggesting it +// there would point users at a guaranteed failure). Default each +// test to non-CI; the `inCi` ctx flag is the override path. +const ORIG_CI = process.env.CI; +beforeEach(() => { + delete process.env.CI; +}); +afterEach(() => { + if (ORIG_CI !== undefined) process.env.CI = ORIG_CI; + else delete process.env.CI; +}); + +describe("formatAnonymousAuthError", () => { + it("returns null for non-CloudApiError values", () => { + expect(formatAnonymousAuthError(new Error("boom"))).toBeNull(); + expect(formatAnonymousAuthError("string")).toBeNull(); + expect(formatAnonymousAuthError(undefined)).toBeNull(); + }); + + it("returns null for CloudApiError without a known code", () => { + expect(formatAnonymousAuthError(new CloudApiError(500, "boom"))).toBeNull(); + expect( + formatAnonymousAuthError(new CloudApiError(401, "Unauthorized")), + ).toBeNull(); + expect( + formatAnonymousAuthError( + new CloudApiError(400, "validation", "some_other_code"), + ), + ).toBeNull(); + }); + + describe("anonymous_token_single_device", () => { + it("recommends `arkor login --oauth` when OAuth is confirmed available", () => { + const out = formatAnonymousAuthError( + new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), + { oauthAvailable: true }, + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/single-device/); + // Must direct at the OAuth flow specifically. `arkor login` + // alone would launch an interactive picker that defaults to + // Anonymous and would just re-issue another single-device + // token. + expect(out!).toMatch(/arkor login --oauth/); + // Re-issuing credentials alone isn't enough: ensureProjectState + // reuses any pre-existing `.arkor/state.json` unchanged, so a + // working directory left over from the now-defunct workspace + // would keep targeting the old (orgSlug, projectSlug). The + // formatter has to tell users to reset that local state, or + // the recovery they just performed appears not to take + // effect. + expect(out!).toMatch(/\.arkor\/state\.json/); + }); + + it("falls back to `arkor login --anonymous` when OAuth is not available", () => { + // Anon-only deployments don't have OAuth configured. Pointing + // users at `--oauth` there would surface a command that fails + // immediately, so the formatter recommends the only recovery + // that always works (mint a new anon identity). + const out = formatAnonymousAuthError( + new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), + { oauthAvailable: false }, + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/single-device/); + expect(out!).toMatch(/arkor login --anonymous/); + expect(out!).not.toMatch(/arkor login --oauth/); + expect(out!).toMatch(/\.arkor\/state\.json/); + }); + + it("hedges with both commands when probe is inconclusive (oauthAvailable === undefined)", () => { + // An earlier version collapsed `undefined` into `false` (same + // gating contract as ANON_PERSISTENCE_NUDGE), but on the + // dead-end formatter path that hid the correct recovery + // (`--oauth`) whenever the config probe just timed out. Now we + // surface both commands and tell the user what to try first. + const out = formatAnonymousAuthError( + new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/single-device/); + expect(out!).toMatch(/Couldn't reach the deployment/); + expect(out!).toMatch(/arkor login --oauth/); + expect(out!).toMatch(/arkor login --anonymous/); + // The "OAuth is not configured" hedge must NOT claim that's + // the current state. It only describes what to do if the user + // hits that error. + expect(out!).not.toMatch(/does not advertise OAuth/); + }); + + it("drops `--oauth` from the OAuth-confirmed branch when running in CI", () => { + // `runLogin()` hard-rejects `--oauth` whenever `process.env.CI` + // is set (PKCE needs a browser callback CI runners can't + // provide), so a confidently-recommend-`--oauth` formatter + // would steer the runner at a guaranteed failure. The CI + // branch points at `--anonymous` instead and tells the user + // where `--oauth` would actually work. + const out = formatAnonymousAuthError( + new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), + { oauthAvailable: true, inCi: true }, + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/single-device/); + expect(out!).toMatch(/arkor login --anonymous/); + expect(out!).toMatch(/from a developer machine/); + // Must NOT recommend running `--oauth` here. The "developer + // machine" mention contains `arkor login --oauth` literally, + // which is fine context, but the recovery list should be + // anonymous-only. + const recoveryBlock = out!.split("\n").filter((line) => + line.startsWith(" arkor login"), + ); + expect(recoveryBlock).toEqual([" arkor login --anonymous"]); + }); + + it("drops `--oauth` from the unknown-state hedge when running in CI", () => { + // Same reasoning as the OAuth-confirmed CI branch: in CI, + // `--oauth` is dead on arrival. The hedge text still + // acknowledges that we couldn't confirm the deployment shape, + // but only suggests the command that can actually run. + const out = formatAnonymousAuthError( + new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), + { inCi: true }, + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/Couldn't reach the deployment/); + expect(out!).toMatch(/rejected in CI/); + const recoveryBlock = out!.split("\n").filter((line) => + line.startsWith(" arkor login"), + ); + expect(recoveryBlock).toEqual([" arkor login --anonymous"]); + }); + + it("auto-detects CI from process.env.CI when ctx.inCi is not provided", () => { + // Default behaviour for callers that don't plumb `inCi` + // explicitly. `cli/main.ts` doesn't pass it, so the auto-read + // is the production code path. + process.env.CI = "1"; + const out = formatAnonymousAuthError( + new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), + { oauthAvailable: true }, + ); + expect(out!).toMatch(/from a developer machine/); + const recoveryBlock = out!.split("\n").filter((line) => + line.startsWith(" arkor login"), + ); + expect(recoveryBlock).toEqual([" arkor login --anonymous"]); + }); + }); + + describe("anonymous_account_not_found", () => { + it("recommends `arkor login --oauth` when OAuth is confirmed available", () => { + const out = formatAnonymousAuthError( + new CloudApiError(401, "...", ANONYMOUS_ACCOUNT_NOT_FOUND), + { oauthAvailable: true }, + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/no longer valid/); + expect(out!).toMatch(/arkor login --oauth/); + }); + + it("falls back to `arkor login --anonymous` when OAuth is not available", () => { + const out = formatAnonymousAuthError( + new CloudApiError(401, "...", ANONYMOUS_ACCOUNT_NOT_FOUND), + { oauthAvailable: false }, + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/no longer valid/); + expect(out!).toMatch(/arkor login --anonymous/); + expect(out!).not.toMatch(/arkor login --oauth/); + }); + + it("hedges with both commands when probe is inconclusive (oauthAvailable === undefined)", () => { + const out = formatAnonymousAuthError( + new CloudApiError(401, "...", ANONYMOUS_ACCOUNT_NOT_FOUND), + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/no longer valid/); + expect(out!).toMatch(/Couldn't reach the deployment/); + expect(out!).toMatch(/arkor login --oauth/); + expect(out!).toMatch(/arkor login --anonymous/); + }); + + it("drops `--oauth` from the OAuth-confirmed branch when running in CI", () => { + const out = formatAnonymousAuthError( + new CloudApiError(401, "...", ANONYMOUS_ACCOUNT_NOT_FOUND), + { oauthAvailable: true, inCi: true }, + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/no longer valid/); + expect(out!).toMatch(/from a developer machine/); + const recoveryBlock = out!.split("\n").filter((line) => + line.startsWith(" arkor login"), + ); + expect(recoveryBlock).toEqual([" arkor login --anonymous"]); + }); + }); +}); + +describe("isAnonymousAuthDeadEnd", () => { + it("identifies the two known auth-state codes", () => { + expect( + isAnonymousAuthDeadEnd( + new CloudApiError(409, "x", ANONYMOUS_TOKEN_SINGLE_DEVICE), + ), + ).toBe(true); + expect( + isAnonymousAuthDeadEnd( + new CloudApiError(401, "x", ANONYMOUS_ACCOUNT_NOT_FOUND), + ), + ).toBe(true); + }); + + it("rejects everything else", () => { + expect(isAnonymousAuthDeadEnd(new Error("x"))).toBe(false); + expect(isAnonymousAuthDeadEnd(new CloudApiError(500, "x"))).toBe(false); + expect( + isAnonymousAuthDeadEnd(new CloudApiError(401, "x", "other_code")), + ).toBe(false); + }); +}); diff --git a/packages/arkor/src/core/anonymous-auth-error.ts b/packages/arkor/src/core/anonymous-auth-error.ts new file mode 100644 index 00000000..8321a124 --- /dev/null +++ b/packages/arkor/src/core/anonymous-auth-error.ts @@ -0,0 +1,198 @@ +import { CloudApiError } from "./client"; + +/** + * Structured error codes the cloud-api emits on anonymous auth-state + * failures. Mirrors the strings produced by control-plane's + * `anonymous_users` middleware + `rotate-jti` route. Keep this list in + * sync if the server adds new ones. + */ +export const ANONYMOUS_TOKEN_SINGLE_DEVICE = "anonymous_token_single_device"; +export const ANONYMOUS_ACCOUNT_NOT_FOUND = "anonymous_account_not_found"; + +export interface FormatAnonymousAuthErrorContext { + /** + * Whether OAuth is available on the current deployment. Tri-state: + * + * - `true`: confirmed available (cfg fetched, Auth0 fields present). + * The formatter recommends `arkor login --oauth`. + * - `false`: confirmed *absent* (cfg fetched, no Auth0 fields). The + * formatter recommends `arkor login --anonymous` and explicitly + * tells the user OAuth isn't offered on this deployment. + * - `undefined`: probe inconclusive (cfg fetch skipped, network + * blip, timeout, etc.). The formatter hedges by surfacing both + * commands and pointing at `--oauth` first because it works on + * the majority of deployments, with a clear fall-through to + * `--anonymous` if it fails. + * + * Mapping `undefined` to "OAuth not advertised" was the previous + * behaviour and was misleading: a transient probe failure on an + * OAuth-supporting deployment would steer users away from the + * correct recovery. + */ + oauthAvailable?: boolean; + /** + * Whether the current process is running in CI. Defaults to + * reading `process.env.CI` so callers don't have to plumb this + * explicitly; tests override it to keep assertions deterministic. + * + * `runLogin()` rejects `--oauth` outright when CI is set (PKCE + * needs a browser callback CI runners can't satisfy), so the + * formatter must avoid recommending a command that's guaranteed to + * fail in the current environment. CI builds that hit a dead-end + * get pointed at `--anonymous` instead, with a side note that + * `--oauth` (when applicable) needs to be run from a developer + * machine. + */ + inCi?: boolean; +} + +/** + * If `err` is a `CloudApiError` whose `code` indicates an anonymous-auth + * dead-end, return a CLI-shaped message guiding the user to recovery. + * Returns `null` for everything else so callers can re-throw. + * + * The two recoverable cases differ in cause: + * + * - `anonymous_token_single_device`: another device or a racing refresh + * rotated `latest_jti` past ours. Signal that anonymous accounts are + * single-device on purpose; the path forward depends on whether OAuth + * is configured (sign up vs. mint a new throwaway anon). + * - `anonymous_account_not_found`: the `anonymous_users` row is gone + * (admin / cascade / explicit revocation). Token can't be salvaged; + * user has to either sign up (OAuth) or start fresh as anon. + * + * Every recovery branch ends with a STATE_RESET_NOTE because + * re-issuing credentials alone is not sufficient: `ensureProjectState` + * (`packages/arkor/src/core/projectState.ts`) reuses any existing + * `.arkor/state.json` unchanged, so a project directory left over + * from the now-defunct workspace would keep targeting the old + * `(orgSlug, projectSlug)` and either 401 again or quietly write + * into a workspace the user can't access. Telling users to delete + * the state file (or run `arkor init` for OAuth) is what actually + * closes the loop. + */ +const STATE_RESET_NOTE = + "Local project state pins the working directory to the old workspace. Delete `.arkor/state.json` (or, for an OAuth account, re-run `arkor init`) before resuming work in this directory, otherwise commands here will keep targeting the previous identity's org/project."; + +export function formatAnonymousAuthError( + err: unknown, + ctx: FormatAnonymousAuthErrorContext = {}, +): string | null { + if (!(err instanceof CloudApiError)) return null; + const inCi = ctx.inCi ?? Boolean(process.env.CI); + // The unknown-state branch surfaces both commands so users on an + // OAuth-supporting deployment aren't denied the correct recovery + // just because the config endpoint timed out. The order points at + // `--oauth` first because it covers the majority of deployments; + // anon-only users will get a clean "OAuth is not configured" error + // and can fall through to the second command. In CI we drop the + // `--oauth` half entirely: `runLogin()` rejects `--oauth` up front + // when `process.env.CI` is set, so suggesting it would just send + // the runner at a guaranteed failure. + const unknownTail = inCi + ? [ + "Couldn't reach the deployment to confirm whether OAuth is offered. From a CI environment the only viable recovery is to mint a new anonymous identity (`arkor login --oauth` is rejected in CI; run it from a developer machine if a multi-device account is what you actually want):", + "", + " arkor login --anonymous", + ] + : [ + "Couldn't reach the deployment to confirm whether OAuth is offered. Try the OAuth path first; if it fails with `OAuth is not configured`, fall through to the anonymous path:", + "", + " arkor login --oauth", + " arkor login --anonymous", + ]; + if (err.code === ANONYMOUS_TOKEN_SINGLE_DEVICE) { + if (ctx.oauthAvailable === true) { + if (inCi) { + return [ + "Anonymous credentials were rejected as single-device.", + "Anonymous accounts only work on one machine. Re-mint anonymous credentials to continue here (this CI environment can't run the OAuth browser flow; for a multi-device account, run `arkor login --oauth` from a developer machine):", + "", + " arkor login --anonymous", + "", + STATE_RESET_NOTE, + ].join("\n"); + } + return [ + "Anonymous credentials were rejected as single-device.", + "Anonymous accounts only work on one machine. Sign up for an account that supports multiple devices:", + "", + " arkor login --oauth", + "", + STATE_RESET_NOTE, + ].join("\n"); + } + if (ctx.oauthAvailable === false) { + return [ + "Anonymous credentials were rejected as single-device.", + "Anonymous accounts only work on one machine. This deployment does not advertise OAuth, so the only recovery is to mint a new anonymous identity (your previous workspace data cannot be recovered):", + "", + " arkor login --anonymous", + "", + STATE_RESET_NOTE, + ].join("\n"); + } + return [ + "Anonymous credentials were rejected as single-device.", + "Anonymous accounts only work on one machine.", + "", + ...unknownTail, + "", + STATE_RESET_NOTE, + ].join("\n"); + } + if (err.code === ANONYMOUS_ACCOUNT_NOT_FOUND) { + if (ctx.oauthAvailable === true) { + if (inCi) { + return [ + "Your anonymous credentials are no longer valid.", + "Mint a new anonymous identity to continue here (this CI environment can't run the OAuth browser flow; for a multi-device account, run `arkor login --oauth` from a developer machine):", + "", + " arkor login --anonymous", + "", + STATE_RESET_NOTE, + ].join("\n"); + } + return [ + "Your anonymous credentials are no longer valid.", + "Sign up to continue:", + "", + " arkor login --oauth", + "", + STATE_RESET_NOTE, + ].join("\n"); + } + if (ctx.oauthAvailable === false) { + return [ + "Your anonymous credentials are no longer valid.", + "Mint a new anonymous identity to continue (your previous workspace data cannot be recovered):", + "", + " arkor login --anonymous", + "", + STATE_RESET_NOTE, + ].join("\n"); + } + return [ + "Your anonymous credentials are no longer valid.", + "", + ...unknownTail, + "", + STATE_RESET_NOTE, + ].join("\n"); + } + return null; +} + +/** + * `true` if the error is one of the auth-state codes formatted by + * `formatAnonymousAuthError`. Useful for callers that want to skip + * silent retries (e.g. don't keep looping on a token the server already + * rejected as single-device). + */ +export function isAnonymousAuthDeadEnd(err: unknown): err is CloudApiError { + return ( + err instanceof CloudApiError && + (err.code === ANONYMOUS_TOKEN_SINGLE_DEVICE || + err.code === ANONYMOUS_ACCOUNT_NOT_FOUND) + ); +} diff --git a/packages/arkor/src/core/auth0.test.ts b/packages/arkor/src/core/auth0.test.ts index a280fbe2..ab2c3004 100644 --- a/packages/arkor/src/core/auth0.test.ts +++ b/packages/arkor/src/core/auth0.test.ts @@ -94,7 +94,9 @@ describe("fetchCliConfig", () => { status: 200, headers: { "content-type": "application/json" }, })) as typeof fetch; - const cfg = await fetchCliConfig("http://localhost:3003", fetchImpl); + const cfg = await fetchCliConfig("http://localhost:3003", { + fetch: fetchImpl, + }); expect(cfg).toEqual(payload); }); @@ -102,7 +104,7 @@ describe("fetchCliConfig", () => { const fetchImpl = (async () => new Response("nope", { status: 500 })) as typeof fetch; await expect( - fetchCliConfig("http://localhost:3003", fetchImpl), + fetchCliConfig("http://localhost:3003", { fetch: fetchImpl }), ).rejects.toThrow(/Failed to fetch CLI config/); }); @@ -120,9 +122,37 @@ describe("fetchCliConfig", () => { { status: 200 }, ); }) as typeof fetch; - await fetchCliConfig("http://localhost:3003/", fetchImpl); + await fetchCliConfig("http://localhost:3003/", { fetch: fetchImpl }); expect(captured).toBe("http://localhost:3003/v1/auth/cli/config"); }); + + it("forwards an AbortSignal so callers can bound the request", async () => { + // The probe path in `cli/main.ts` runs after a command has already + // failed; without a signal a stalled `/v1/auth/cli/config` would + // leave the user waiting indefinitely with no recovery message. + let capturedSignal: AbortSignal | undefined; + const fetchImpl = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { + capturedSignal = init?.signal ?? undefined; + return new Response( + JSON.stringify({ + auth0Domain: null, + clientId: null, + audience: null, + callbackPorts: [], + }), + { status: 200 }, + ); + }) as typeof fetch; + const ac = new AbortController(); + await fetchCliConfig("http://localhost:3003", { + fetch: fetchImpl, + signal: ac.signal, + }); + expect(capturedSignal).toBe(ac.signal); + }); }); describe("exchangeCode", () => { diff --git a/packages/arkor/src/core/auth0.ts b/packages/arkor/src/core/auth0.ts index 80af28ae..78c94c32 100644 --- a/packages/arkor/src/core/auth0.ts +++ b/packages/arkor/src/core/auth0.ts @@ -14,13 +14,21 @@ export interface CliConfig { * Fetch the arkor-cloud-api deployment's CLI config. Needed before starting * the PKCE flow so the CLI learns the Auth0 tenant + client id without env * vars on the user's machine. + * + * Accepts an optional `AbortSignal` so callers on the dead-end auth-error + * path in `cli/main.ts` can bound the probe — without it a degraded + * deployment (cli/config endpoint hung) would leave the CLI sitting + * indefinitely *after* a command has already failed, with no recovery + * guidance ever reaching the user. */ export async function fetchCliConfig( baseUrl: string, - fetchImpl: typeof fetch = fetch, + options: { fetch?: typeof fetch; signal?: AbortSignal } = {}, ): Promise { + const fetchImpl = options.fetch ?? fetch; const res = await fetchImpl( `${baseUrl.replace(/\/$/, "")}/v1/auth/cli/config`, + { signal: options.signal }, ); if (!res.ok) { throw new Error( diff --git a/packages/arkor/src/core/client.test.ts b/packages/arkor/src/core/client.test.ts index 2bb46e12..9625e27f 100644 --- a/packages/arkor/src/core/client.test.ts +++ b/packages/arkor/src/core/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CloudApiClient, CloudApiError } from "./client"; +import { buildCloudApiError, CloudApiClient, CloudApiError } from "./client"; import type { AnonymousCredentials } from "./credentials"; import { clearRecordedDeprecation, @@ -597,4 +597,60 @@ describe("CloudApiError", () => { expect(e.status).toBe(418); expect(e.message).toBe("I'm a teapot"); }); + + it("carries the optional `code` field when supplied", () => { + // Direct constructor coverage for the `code` parameter — the field is + // what `cli/main.ts`'s anonymous-auth-error formatter pivots on, so + // an accidental drop would silently regress the friendly-error path. + const e = new CloudApiError(409, "...", "anonymous_token_single_device"); + expect(e.code).toBe("anonymous_token_single_device"); + }); +}); + +describe("buildCloudApiError", () => { + it("preserves the structured `code` from the cloud-api error body", async () => { + // The dead-end formatter in cli/main.ts branches on `err.code`, so this + // round-trip (JSON body → parseErrorBody → CloudApiError.code) must + // survive any future refactor of buildCloudApiError. + const res = new Response( + JSON.stringify({ + error: "Anonymous token is no longer current.", + code: "anonymous_token_single_device", + }), + { + status: 409, + headers: { "content-type": "application/json" }, + }, + ); + const err = await buildCloudApiError(res); + expect(err).toBeInstanceOf(CloudApiError); + expect(err.status).toBe(409); + expect(err.message).toBe("Anonymous token is no longer current."); + expect(err.code).toBe("anonymous_token_single_device"); + }); + + it("leaves `code` undefined when the body has no `code` field", async () => { + // Generic 4xx/5xx without a structured identifier — formatter falls + // through to the default Node error rendering rather than the + // anon-auth-error branch. + const res = new Response(JSON.stringify({ error: "bad request" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + const err = await buildCloudApiError(res); + expect(err.message).toBe("bad request"); + expect(err.code).toBeUndefined(); + }); + + it("leaves `code` undefined when the body is non-JSON", async () => { + const res = new Response("plain text failure", { + status: 502, + headers: { "content-type": "text/plain" }, + }); + const err = await buildCloudApiError(res); + expect(err.status).toBe(502); + // `||` (not `??`) means the raw text becomes the message. + expect(err.message).toBe("plain text failure"); + expect(err.code).toBeUndefined(); + }); }); diff --git a/packages/arkor/src/core/client.ts b/packages/arkor/src/core/client.ts index 9c8e5e0e..13c90dc0 100644 --- a/packages/arkor/src/core/client.ts +++ b/packages/arkor/src/core/client.ts @@ -18,10 +18,18 @@ import { SDK_VERSION } from "./version"; export class CloudApiError extends Error { status: number; + /** + * Stable machine-readable identifier surfaced by cloud-api / control-plane. + * Lets callers branch on auth-state failures (e.g. + * `anonymous_token_single_device`, `anonymous_account_not_found`) + * without parsing the human-readable `message`. + */ + code?: string; - constructor(status: number, message: string) { + constructor(status: number, message: string, code?: string) { super(message); this.status = status; + this.code = code; this.name = "CloudApiError"; } } @@ -40,8 +48,12 @@ async function decode(res: Response, schema: z.ZodType): Promise { /** * Build a `CloudApiError` from a non-ok Response, inlining the cloud-api * gate's upgrade hint when the status is 426. + * + * Exported so non-`CloudApiClient` callers (e.g. `whoami` hitting the + * RPC directly) can produce errors with the same `{error, code}` shape + * that `cli/main.ts`'s top-level handler knows how to format. */ -async function buildCloudApiError(res: Response): Promise { +export async function buildCloudApiError(res: Response): Promise { const text = await res.text().catch(() => ""); let parsed: unknown = null; try { @@ -58,7 +70,7 @@ async function buildCloudApiError(res: Response): Promise { // Use `||` (not `??`) so an empty-string body falls through to the // generic `cloud-api ` instead of becoming an empty message. const message = fields.error || text || `cloud-api ${res.status}`; - return new CloudApiError(res.status, message); + return new CloudApiError(res.status, message, fields.code); } export interface CloudApiClientOptions { diff --git a/packages/arkor/src/core/trainer.test.ts b/packages/arkor/src/core/trainer.test.ts index 3e5633d2..e58696b0 100644 --- a/packages/arkor/src/core/trainer.test.ts +++ b/packages/arkor/src/core/trainer.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { CloudApiError } from "./client"; import { createTrainer } from "./trainer"; import { writeState } from "./state"; import type { AnonymousCredentials } from "./credentials"; @@ -626,6 +627,58 @@ describe("createTrainer (reconnect backoff + max attempts)", () => { } } + it("fails fast on anonymous-auth dead-end errors without retrying", async () => { + // Anonymous-auth dead-ends (`anonymous_token_single_device`, + // `anonymous_account_not_found`) never recover by reconnecting: + // the server has already rejected this credentials' jti or + // removed the underlying anonymous row. Burning the reconnect + // budget here would just delay the inevitable failure and bury + // the actionable recovery hint that `cli/main.ts` formats from + // the same error. The fast-fail path bubbles the original + // CloudApiError straight up so the top-level handler can format + // it. + await writeState( + { orgSlug: "anon-org", projectSlug: "proj", projectId: "p1" }, + cwd, + ); + const deadEnd = new CloudApiError( + 409, + "Anonymous token is no longer current.", + "anonymous_token_single_device", + ); + const { fetch: fetcher, streamCalls } = streamFetcher([ + { kind: "throw", error: deadEnd }, + // Extra handlers in case of an unexpected retry; presence here + // would make the assertion below clearly fail rather than + // misleadingly pass on a "no more handlers" runtime error. + { kind: "throw", error: deadEnd }, + { kind: "throw", error: deadEnd }, + ]); + + const trainer = createTrainer( + { + name: "run", + model: "m", + dataset: { type: "huggingface", name: "x" }, + }, + { + baseUrl: "http://mock", + credentials: creds, + cwd, + reconnectDelayMs: 1, + maxReconnectDelayMs: 5, + maxReconnectAttempts: 5, + }, + ); + + const error = await withMockedFetch(fetcher, async () => + trainer.wait().catch((e: unknown) => e), + ); + expect(error).toBe(deadEnd); + // Exactly one open attempt: no retry burn. + expect(streamCalls()).toBe(1); + }); + it("rejects after maxReconnectAttempts of consecutive open failures", async () => { await writeState( { orgSlug: "anon-org", projectSlug: "proj", projectId: "p1" }, diff --git a/packages/arkor/src/core/trainer.ts b/packages/arkor/src/core/trainer.ts index 874382f0..7a34f4bd 100644 --- a/packages/arkor/src/core/trainer.ts +++ b/packages/arkor/src/core/trainer.ts @@ -1,4 +1,5 @@ import { iterateEvents } from "@arkor/cloud-api-client"; +import { isAnonymousAuthDeadEnd } from "./anonymous-auth-error"; import { CloudApiClient } from "./client"; import { defaultArkorCloudApiUrl, @@ -306,6 +307,15 @@ export function createTrainer( const handleFailure = async (err: unknown): Promise => { if (abortSignal?.aborted) throw err; + // Anonymous-auth dead-ends never recover by reconnecting: the + // server has already rejected this credentials' jti + // (`anonymous_token_single_device`) or removed the underlying + // anonymous row (`anonymous_account_not_found`). Bubble up + // immediately so `cli/main.ts`'s top-level handler can format + // the actionable recovery hint, instead of burning the + // reconnect budget on a request that will keep returning the + // same 401/409. + if (isAnonymousAuthDeadEnd(err)) throw err; if ( maxReconnectAttempts !== undefined && attempt >= maxReconnectAttempts diff --git a/packages/cli-internal/src/templates.ts b/packages/cli-internal/src/templates.ts index 5abcfc21..4c69781a 100644 --- a/packages/cli-internal/src/templates.ts +++ b/packages/cli-internal/src/templates.ts @@ -137,12 +137,31 @@ npm install && npm run dev \`arkor dev\` opens the local Studio GUI (most workflows live there). -Optional — log in to your own org instead of using anonymous tokens: +Anonymous tokens are designed for one machine. The cloud-api enforces +a single-device guard server-side, so copying +\`~/.arkor/credentials.json\` to another device isn't a supported +workflow. The issuing machine and the copy share one identity, and +once either side forces a token rotation (today: a manual +\`arkor login --anonymous\`; future: any auto-refresh) the other side +starts failing with a single-device error on its next call. When +you're ready for an account-backed workspace that follows you across +devices, run: \`\`\` -npx arkor login +npx arkor login --oauth \`\`\` +The OAuth flow starts a *new* identity. Existing anonymous work cannot +be migrated, so any work you create after sign-in is what ends up under +the account. + +\`arkor login --oauth\` does not automatically reroute commands in an +existing project directory: \`.arkor/state.json\` still pins this +directory to the original anonymous \`(orgSlug, projectSlug)\`. Reset +local state with \`rm -rf .arkor/state.json\` and run +\`npx arkor init\` to bootstrap a project under the new OAuth +account, or work from a fresh directory. + CLI-only flow (no GUI): \`\`\`