From 80adb1749119b4efb6d7447bbe285dae1cec24f2 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sat, 2 May 2026 23:22:19 +0900 Subject: [PATCH 01/10] docs: clarify anonymous workspace limitations and guidance Updated the documentation to emphasize that anonymous workspaces are single-device only, and provided clear instructions for users on how to transition to an OAuth account for multi-device access. This includes updates to the README and CLI documentation, ensuring users are aware of the constraints and the necessary steps to retain their work across devices. --- README.ja.md | 4 +- README.md | 4 +- docs/cli/auth.mdx | 16 ++++- docs/cli/dev.mdx | 1 + docs/ja/cli/auth.mdx | 19 +++-- docs/ja/cli/dev.mdx | 1 + packages/arkor/README.md | 17 +++++ packages/arkor/src/cli/commands/dev.ts | 6 ++ packages/arkor/src/cli/commands/login.ts | 7 ++ .../arkor/src/cli/commands/whoami.test.ts | 39 +++++++--- packages/arkor/src/cli/commands/whoami.ts | 26 +++++-- packages/arkor/src/cli/main.ts | 17 +++++ .../src/core/anonymous-auth-error.test.ts | 72 +++++++++++++++++++ .../arkor/src/core/anonymous-auth-error.ts | 59 +++++++++++++++ packages/arkor/src/core/client.ts | 18 ++++- packages/cli-internal/src/templates.ts | 7 +- 16 files changed, 284 insertions(+), 29 deletions(-) create mode 100644 packages/arkor/src/core/anonymous-auth-error.test.ts create mode 100644 packages/arkor/src/core/anonymous-auth-error.ts diff --git a/README.ja.md b/README.ja.md index 3901a9a0..ef251eb9 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` を実行してください。 +匿名ワークスペースは `arkor dev` を最初に実行したマシーン専用です。複数の端末から作業を続けたい場合は、後から `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` はそのまま新しい匿名ワークスペースで起動します。匿名ワークスペースは単一端末専用で、発行したマシンでのみ動作します。複数端末で作業を続けたい場合は `arkor login --oauth` で Arkor Cloud の OAuth (PKCE) フローを開始してアカウントに紐付けてください。 ## これから来るもの diff --git a/README.md b/README.md index 95e58207..e1ae669a 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. They only work where you ran `arkor dev` first. Run `arkor login --oauth` later to attach your work to an account 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. Run `arkor login --oauth` to start the Arkor Cloud OAuth (PKCE) flow and attach the work to 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..2dcfdbea 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 binds the issued token to the machine that received it (via a `latest_jti` rotation each time the SDK refreshes the token), so copying `~/.arkor/credentials.json` to a second machine and using it from both will trip the server's single-device guard. The losing client receives an HTTP 401 / 409 with `code: "anonymous_token_single_device"`, which the CLI surfaces as guidance to sign up for a real account; deletion of the underlying anonymous row surfaces as `code: "anonymous_account_not_found"`. Both dead-end paths point at `arkor login --oauth`, since multi-device usage is what the OAuth account is for. + ### 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: ``Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.`` This is the same advice the cloud-api emits when the single-device guard fires later, so users see consistent phrasing whether they hit it at issuance or at the first 401. + ## `arkor logout` ```bash @@ -110,10 +114,14 @@ 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: + - `code: "anonymous_token_single_device"` → ``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`). + - `code: "anonymous_account_not_found"` → ``Your anonymous credentials are no longer valid. Sign up to continue: arkor login --oauth`` (exit `1`). + + Errors without a known `code` propagate as commander's default error message (the upstream `error` body), so the user sees the cloud-api's reason verbatim. - 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 +133,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. … arkor login --oauth`` | `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. | Run `arkor login --oauth` for an OAuth account that supports multiple devices. Existing anonymous work cannot be migrated. The CLI exits `1`. | +| ``Your anonymous credentials are no longer valid. … arkor login --oauth`` | `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). | Run `arkor login --oauth` to sign up. The previous anonymous workspace cannot be recovered. The CLI exits `1`. | +| `cloud-api ` (or the upstream `error` body) | `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.). The CLI re-raises it through commander, which prints the message and exits non-zero. | Inspect the upstream message; 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..bb0e0e88 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -60,6 +60,7 @@ This means `arkor dev` is safe on a shared dev machine: another tab cannot read | `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 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. Run `arkor login --oauth` to sign up for multi-device access.`` | Informational note fired alongside the bootstrap success line on every deployment. 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. | Optional. If you need multi-device access, run `arkor login --oauth` for an OAuth account. Existing anonymous work cannot be migrated; the OAuth account starts fresh. | | ``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..bf12c5f0 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` ローテーションで束ねるので、`~/.arkor/credentials.json` を別マシーンにコピーして両方から使うとサーバー側の単一端末ガードに引っかかります。負けた側のクライアントは HTTP 401 / 409 を `code: "anonymous_token_single_device"` で受け取り、CLI はそれを「実アカウントへサインアップしてください」というガイダンスとして整形します。匿名行そのものが削除された場合は `code: "anonymous_account_not_found"` として現れます。どちらの行き止まりも `arkor login --oauth` を案内するのは、複数端末利用は OAuth アカウントの責務だからです。 + ### 匿名発行時の出力 どちらの匿名パスでも、新しい `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 します(``Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.``、和訳: 注意: 匿名アカウントはこのマシーンでのみ動作します。複数端末で使うには `arkor login --oauth` でサインアップしてください)。これはクラウド API 側の単一端末ガードが後で 401 を返すときと同じ文言なので、発行時に見るヒントと後で見るエラーがそのまま符合します。 + ## `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` のトップレベルハンドラがそれを実行可能なメッセージへ整形し、`process.exitCode = 1` を立てます。 +- それ以外の 4xx / 5xx は `CloudApiError` として再 throw され、commander のデフォルトハンドラが上流の `error` 本文をそのまま表示して非ゼロで終了します(旧来の `Token may be expired` ヒントは削除されました。構造化 `code` の方が誤解を生まずに済むためです)。 ## 認証情報の保存場所 @@ -110,10 +115,14 @@ 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` を実行可能なガイダンスへ整形します。 + - `code: "anonymous_token_single_device"` → ``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`。 + - `code: "anonymous_account_not_found"` → ``Your anonymous credentials are no longer valid. Sign up to continue: arkor login --oauth``(和訳: 匿名認証情報はもう有効ではありません。続けるにはサインアップしてください)。終了コード `1`。 + + 既知の `code` を持たないエラーは commander のデフォルトとして上流の `error` 本文をそのまま表示するので、ユーザーはクラウド API の理由を一字一句見ます。 - 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 +134,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 --oauth`) | `arkor whoami` および認証付きの全コマンド | クラウド API が `code: "anonymous_token_single_device"` で拒否した(userAuth の jti チェックからの HTTP 401、または rotate-jti の CAS からの HTTP 409)。認証情報ファイルが別端末にコピーされたか、別のローカルプロセスがこのトークンを recovery window 内でローテートし越えた、のいずれか。 | 複数端末対応の OAuth アカウントには `arkor login --oauth`。既存の匿名作業はマイグレーションできない。CLI は `1` で終了。 | +| ``Your anonymous credentials are no longer valid. … arkor login --oauth``(和訳: 匿名認証情報はもう有効ではありません。… `arkor login --oauth`) | `arkor whoami` および認証付きの全コマンド | クラウド API が `code: "anonymous_account_not_found"` で拒否した(HTTP 401)。背後の `anonymous_users` 行が削除された(admin / cascade / 明示的な revoke)。 | サインアップするには `arkor login --oauth`。前の匿名ワークスペースは取り戻せない。CLI は `1` で終了。 | +| `cloud-api `(または上流の `error` 本文) | `arkor whoami` および認証付きの全コマンド | 構造化された認証状態 `code` を持たない非 200 / 非 426 のクラウド API レスポンス(一過性の 5xx、未マップの 4xx など)。CLI は commander 経由で再 throw し、メッセージを表示して非ゼロで終了する。 | 上流メッセージを確認し、トランスポート / サーバー障害なら再試行。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..874bcdc3 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -60,6 +60,7 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | `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 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. Run `arkor login --oauth` to sign up for multi-device access.``(注意: 匿名アカウントはこのマシーンでのみ動作します。複数端末で使うには `arkor login --oauth` でサインアップしてください) | 匿名ブートストラップ成功行と並んで全デプロイで出る情報行。匿名アカウントは発行マシーンに対して単一端末ガードでバインドされるので、別端末での 401 を待たず最初に制約を提示する。 | 任意。複数端末でのアクセスが必要なら 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..22d5e6dd 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,22 @@ 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 only +works on the machine where it was issued.** Copying +`~/.arkor/credentials.json` to a second machine and using it from both +will trip the server's single-device guard, and one of the two will be +locked out with `anonymous_token_single_device`. The CLI surfaces this +as a hint to run `arkor login --oauth` for a real account that supports +multiple devices. + +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/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index e2bf01cf..50cbfd20 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -153,6 +153,12 @@ 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. + ui.log.info( + "Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.", + ); } /** diff --git a/packages/arkor/src/cli/commands/login.ts b/packages/arkor/src/cli/commands/login.ts index 3113f53c..e5230712 100644 --- a/packages/arkor/src/cli/commands/login.ts +++ b/packages/arkor/src/cli/commands/login.ts @@ -153,6 +153,13 @@ 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. The wording aligns with `formatAnonymousAuthError` so the + // hint they see now matches the error they'd see later. + ui.log.info( + "Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.", + ); } interface ResolvedCliConfig { diff --git a/packages/arkor/src/cli/commands/whoami.test.ts b/packages/arkor/src/cli/commands/whoami.test.ts index a6cb32f2..6663a47e 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"; @@ -264,7 +265,13 @@ 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("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", @@ -273,15 +280,31 @@ describe("runWhoami", () => { orgSlug: "anon-abc", }); globalThis.fetch = vi.fn( - async () => new Response("{}", { status: 401 }), + async () => + new Response( + JSON.stringify({ + error: "Anonymous token revoked", + code: "anonymous_token_single_device", + }), + { status: 401, headers: { "content-type": "application/json" } }, + ), ) as typeof fetch; - await runWhoami(); + 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).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); + 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..53d6a62d 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, @@ -44,21 +44,37 @@ 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; orgs: Record[]; }; + const isAnonymous = + typeof body.user === "object" && + body.user !== null && + (body.user as { kind?: unknown }).kind === "anonymous"; process.stdout.write(`${JSON.stringify(body.user, null, 2)}\n`); if (body.orgs.length > 0) { process.stdout.write( `Orgs: ${body.orgs.map((o) => String(o.slug ?? o.id)).join(", ")}\n`, ); } + if (isAnonymous) { + // Anonymous accounts are single-device on purpose, so surface the + // limitation here so users discover it before hitting a 401 on a + // second machine. `arkor login --oauth` is the explicit upgrade + // path; phrasing matches the auth-error formatter so users see the + // same advice from both surfaces. + process.stdout.write( + "\nNote: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.\n", + ); + } // Avoid "unused import" noise by referencing CloudApiClient in an assertion. void CloudApiClient; } diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index 6a718706..2fb7e581 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -7,6 +7,7 @@ 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 } from "../core/anonymous-auth-error"; import { getRecordedDeprecation } from "../core/deprecation"; import { shutdownTelemetry, withTelemetry } from "../core/telemetry"; import { detectedUpgradeCommand } from "../core/upgrade-hint"; @@ -149,6 +150,22 @@ export async function main(argv: string[]): Promise { try { await program.parseAsync(argv, { from: "user" }); + } catch (err) { + // Intercept the structured anonymous-auth-state errors before + // commander's default handler converts them into a noisy stack + // trace. The helper returns a CLI-shaped string for the two known + // dead-end codes (`anonymous_token_single_device`, + // `anonymous_account_not_found`); everything else rethrows so + // commander still surfaces it. Setting `process.exitCode` (rather + // than calling `process.exit` directly) keeps the deprecation + + // telemetry-shutdown step in the `finally` block reachable. + const friendly = formatAnonymousAuthError(err); + if (friendly !== null) { + process.stderr.write(`${friendly}\n`); + process.exitCode = 1; + } 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..810716c8 --- /dev/null +++ b/packages/arkor/src/core/anonymous-auth-error.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + ANONYMOUS_ACCOUNT_NOT_FOUND, + ANONYMOUS_TOKEN_SINGLE_DEVICE, + formatAnonymousAuthError, + isAnonymousAuthDeadEnd, +} from "./anonymous-auth-error"; +import { CloudApiError } from "./client"; + +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(); + }); + + it("formats anonymous_token_single_device with multi-device guidance", () => { + const out = formatAnonymousAuthError( + new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/single-device/); + // Must direct at the OAuth flow specifically, not the bare `arkor + // login` (whose interactive picker defaults to Anonymous and would + // just re-issue another single-device token). + expect(out!).toMatch(/arkor login --oauth/); + }); + + it("formats anonymous_account_not_found with re-login guidance", () => { + const out = formatAnonymousAuthError( + new CloudApiError(401, "...", ANONYMOUS_ACCOUNT_NOT_FOUND), + ); + expect(out).not.toBeNull(); + expect(out!).toMatch(/no longer valid/); + expect(out!).toMatch(/arkor login --oauth/); + }); +}); + +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..72357e2b --- /dev/null +++ b/packages/arkor/src/core/anonymous-auth-error.ts @@ -0,0 +1,59 @@ +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"; + +/** + * 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 cases share an end-user action (`arkor login --oauth`) but + * 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 is signing up via OAuth. + * - `anonymous_account_not_found`: the `anonymous_users` row is gone + * (admin / cascade / explicit revocation). Token can't be salvaged. + */ +export function formatAnonymousAuthError(err: unknown): string | null { + if (!(err instanceof CloudApiError)) return null; + if (err.code === ANONYMOUS_TOKEN_SINGLE_DEVICE) { + 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", + ].join("\n"); + } + if (err.code === ANONYMOUS_ACCOUNT_NOT_FOUND) { + return [ + "Your anonymous credentials are no longer valid.", + "Sign up to continue:", + "", + " arkor login --oauth", + ].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/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/cli-internal/src/templates.ts b/packages/cli-internal/src/templates.ts index 5abcfc21..4c93e729 100644 --- a/packages/cli-internal/src/templates.ts +++ b/packages/cli-internal/src/templates.ts @@ -137,10 +137,13 @@ 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 tied to this machine. Copying +\`~/.arkor/credentials.json\` to another device will be rejected as a +single-device policy violation. Sign up for a real account to keep your +work and use it across machines: \`\`\` -npx arkor login +npx arkor login --oauth \`\`\` CLI-only flow (no GUI): From 5d5c0aabeef89d717792592f60d57901b3f3484f Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 3 May 2026 00:12:50 +0900 Subject: [PATCH 02/10] docs: enhance clarity on anonymous workspace limitations and OAuth transition Updated documentation across multiple files to clarify that anonymous workspaces are strictly single-device and that switching to OAuth does not migrate existing anonymous work. Clear guidance is provided for users on how to transition to an OAuth account for multi-device access, ensuring they understand the implications of their workspace choices. --- README.ja.md | 4 +- README.md | 4 +- docs/cli/auth.mdx | 20 ++- docs/cli/dev.mdx | 2 +- docs/ja/cli/auth.mdx | 20 ++- docs/ja/cli/dev.mdx | 2 +- packages/arkor/README.md | 9 +- packages/arkor/src/cli/anonymous.ts | 20 +++ packages/arkor/src/cli/commands/dev.ts | 16 +- packages/arkor/src/cli/commands/login.ts | 12 +- .../arkor/src/cli/commands/whoami.test.ts | 65 +++++++ packages/arkor/src/cli/commands/whoami.ts | 15 +- packages/arkor/src/cli/main.test.ts | 165 ++++++++++++++++++ packages/arkor/src/cli/main.ts | 58 ++++-- .../src/core/anonymous-auth-error.test.ts | 78 +++++++-- .../arkor/src/core/anonymous-auth-error.ts | 56 +++++- packages/cli-internal/src/templates.ts | 8 +- 17 files changed, 474 insertions(+), 80 deletions(-) diff --git a/README.ja.md b/README.ja.md index ef251eb9..ad016a41 100644 --- a/README.ja.md +++ b/README.ja.md @@ -52,7 +52,7 @@ pnpm dev **サインアップ不要:** `arkor dev` は **Studio** と呼ばれるローカル Web UI を `http://localhost:4000` で開きます。初回起動時に使い捨ての匿名ワークスペースをプロビジョニングするので、すぐに実際のトレーニング実行を開始できます。 -匿名ワークスペースは `arkor dev` を最初に実行したマシーン専用です。複数の端末から作業を続けたい場合は、後から `arkor login --oauth` でアカウントに紐付けてください。 +匿名ワークスペースは `arkor dev` を最初に実行したマシーン専用です。OAuth に切り替えても既存の匿名ワークスペースは引き継げません。`arkor login --oauth` は `~/.arkor/credentials.json` を新しい OAuth identity で上書きし、それ以降の作業は OAuth アカウントに紐付きますが、既存の匿名ジョブや org は発行元の credentials ファイルからしか辿れません。複数端末で作業したくなった時点で `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 e1ae669a..477be859 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. -Anonymous workspaces are tied to this machine. They only work where you ran `arkor dev` first. Run `arkor login --oauth` later to attach your work to an account that follows you across devices. +Anonymous workspaces are tied to this machine. They only work where you ran `arkor dev` first, and switching to OAuth does not migrate them: `arkor login --oauth` overwrites `~/.arkor/credentials.json` with a fresh OAuth identity that any *future* work will be associated with, but existing anonymous jobs and orgs stay reachable only from the credentials file that issued them. 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. Anonymous workspaces are single-device: they live on the machine that issued them. Run `arkor login --oauth` to start the Arkor Cloud OAuth (PKCE) flow and attach the work to an account that follows you across devices. +- [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 2dcfdbea..53f5ccc2 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -43,13 +43,13 @@ 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 binds the issued token to the machine that received it (via a `latest_jti` rotation each time the SDK refreshes the token), so copying `~/.arkor/credentials.json` to a second machine and using it from both will trip the server's single-device guard. The losing client receives an HTTP 401 / 409 with `code: "anonymous_token_single_device"`, which the CLI surfaces as guidance to sign up for a real account; deletion of the underlying anonymous row surfaces as `code: "anonymous_account_not_found"`. Both dead-end paths point at `arkor login --oauth`, since multi-device usage is what the OAuth account is for. +Anonymous accounts are intentionally **single-device**: the cloud-api binds the issued token to the machine that received it (via a `latest_jti` rotation each time the SDK refreshes the token), so copying `~/.arkor/credentials.json` to a second machine and using it from both will trip the server's single-device guard. 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: ``Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.`` This is the same advice the cloud-api emits when the single-device guard fires later, so users see consistent phrasing whether they hit it at issuance or at the first 401. +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, because it doesn't fetch `/v1/auth/cli/config` to learn whether OAuth is offered. ## `arkor logout` @@ -114,11 +114,13 @@ For OAuth sessions, the credentials file records both the access token and the i In practice that means: -- 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: - - `code: "anonymous_token_single_device"` → ``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`). - - `code: "anonymous_account_not_found"` → ``Your anonymous credentials are no longer valid. Sign up to continue: arkor login --oauth`` (exit `1`). +- 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` propagate as commander's default error message (the upstream `error` body), so the user sees the cloud-api's reason verbatim. + Errors without a known `code` (and any non-`CloudApiError` exceptions) are rethrown out of `main()`. They reach Node's top-level rejection handler via `bin.ts`, which logs them with the standard error formatting (class name + stack trace) and exits non-zero — so you'll see something like `CloudApiError: cloud-api 503` followed by a stack frame, not just the upstream `error` body. - 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 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). @@ -133,7 +135,7 @@ Anonymous tokens have a server-side 90-day TTL, but the CLI does not yet auto-re | `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. | -| ``Anonymous credentials were rejected as single-device. … arkor login --oauth`` | `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. | Run `arkor login --oauth` for an OAuth account that supports multiple devices. Existing anonymous work cannot be migrated. The CLI exits `1`. | -| ``Your anonymous credentials are no longer valid. … arkor login --oauth`` | `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). | Run `arkor login --oauth` to sign up. The previous anonymous workspace cannot be recovered. The CLI exits `1`. | -| `cloud-api ` (or the upstream `error` body) | `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.). The CLI re-raises it through commander, which prints the message and exits non-zero. | Inspect the upstream message; 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`). | +| ``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`. | +| `CloudApiError: cloud-api ` (and a stack trace) | `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 it; bin.ts has no catch, so Node's default top-level rejection handler renders it. | Inspect the upstream message at the top of the trace; 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 bb0e0e88..3b2dcd09 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -60,7 +60,7 @@ This means `arkor dev` is safe on a shared dev machine: another tab cannot read | `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 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. Run `arkor login --oauth` to sign up for multi-device access.`` | Informational note fired alongside the bootstrap success line on every deployment. 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. | Optional. If you need multi-device access, run `arkor login --oauth` for an OAuth account. Existing anonymous work cannot be migrated; the OAuth account starts fresh. | +| ``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 bf12c5f0..dfb6471e 100644 --- a/docs/ja/cli/auth.mdx +++ b/docs/ja/cli/auth.mdx @@ -43,13 +43,13 @@ arkor login [options] 匿名認証は、アカウントなしで Arkor を試すためのものです。学習、ジョブ、その他の作業は匿名トークンを介してローカルマシーンに紐づきます。あとで OAuth に切り替える(`arkor login --oauth`、またはピッカーから `OAuth (browser)` を選ぶ)と認証情報ファイルは差し替えられますが、作業は移行されません。匿名で学習したものを残したいなら、学習を始める前に `arkor login --oauth` を走らせてください。 -匿名アカウントは設計上 **単一端末専用** です。クラウド API は発行したマシーンに対してトークンを `latest_jti` ローテーションで束ねるので、`~/.arkor/credentials.json` を別マシーンにコピーして両方から使うとサーバー側の単一端末ガードに引っかかります。負けた側のクライアントは HTTP 401 / 409 を `code: "anonymous_token_single_device"` で受け取り、CLI はそれを「実アカウントへサインアップしてください」というガイダンスとして整形します。匿名行そのものが削除された場合は `code: "anonymous_account_not_found"` として現れます。どちらの行き止まりも `arkor login --oauth` を案内するのは、複数端末利用は OAuth アカウントの責務だからです。 +匿名アカウントは設計上 **単一端末専用** です。クラウド API は発行したマシーンに対してトークンを `latest_jti` ローテーションで束ねるので、`~/.arkor/credentials.json` を別マシーンにコピーして両方から使うとサーバー側の単一端末ガードに引っかかります。負けた側のクライアントは 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 します(``Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.``、和訳: 注意: 匿名アカウントはこのマシーンでのみ動作します。複数端末で使うには `arkor login --oauth` でサインアップしてください)。これはクラウド API 側の単一端末ガードが後で 401 を返すときと同じ文言なので、発行時に見るヒントと後で見るエラーがそのまま符合します。 +匿名発行のあらゆる入口で、単一端末の制約も別行の 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 に対しては `/v1/auth/cli/config` をフェッチしないので、bare 版のみを出します。 ## `arkor logout` @@ -115,11 +115,13 @@ OAuth セッションでは、認証情報ファイルにアクセストーク 実用上の意味は次のとおりです。 -- 期限切れまたは無効化されたトークンはクラウド API からの非 2xx として現れます。`arkor whoami` は汎用的な「Token may be expired」を出さなくなり、代わりに上流の `code`(あれば)を持った `CloudApiError` を投げ、`cli/main.ts` のトップレベルハンドラが既知 2 種の認証状態 `code` を実行可能なガイダンスへ整形します。 - - `code: "anonymous_token_single_device"` → ``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`。 - - `code: "anonymous_account_not_found"` → ``Your anonymous credentials are no longer valid. Sign up to continue: arkor login --oauth``(和訳: 匿名認証情報はもう有効ではありません。続けるにはサインアップしてください)。終了コード `1`。 +- 期限切れまたは無効化されたトークンはクラウド 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` を持たないエラーは commander のデフォルトとして上流の `error` 本文をそのまま表示するので、ユーザーはクラウド API の理由を一字一句見ます。 + 既知の `code` を持たないエラー(および `CloudApiError` 以外の例外)は `main()` から再 throw され、`bin.ts` には catch がないので Node のデフォルトのトップレベル拒否ハンドラが標準のエラーフォーマット(クラス名 + スタックトレース)で表示し、非ゼロで終了します。つまり `CloudApiError: cloud-api 503` の後にスタックフレームが見え、上流の `error` 本文だけが見えるわけではありません。 - OAuth セッションの直し方は `arkor login --oauth` をもう一度走らせることです。フル PKCE フローを通って `~/.arkor/credentials.json` を新トークンで上書きします。 匿名トークンはサーバー側に 90 日の TTL がありますが、CLI はまだ自動リフレッシュをしません。その配線は `@arkor/cloud-api-client` の `getToken()` 側にあり、SDK ロードマップに残っています。今日時点で匿名セッションが失敗し始めたら `arkor login --anonymous` で新しいものを発行してください(新しい `anonymousId` が発行されるので、実質的には別ワークスペースになります)。 @@ -134,7 +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` | 上と同じ条件を別コマンドから出している。 | 同じ対処。 | -| ``Anonymous credentials were rejected as single-device. … arkor login --oauth``(和訳: 匿名認証情報が単一端末ポリシー違反として拒否されました。… `arkor login --oauth`) | `arkor whoami` および認証付きの全コマンド | クラウド API が `code: "anonymous_token_single_device"` で拒否した(userAuth の jti チェックからの HTTP 401、または rotate-jti の CAS からの HTTP 409)。認証情報ファイルが別端末にコピーされたか、別のローカルプロセスがこのトークンを recovery window 内でローテートし越えた、のいずれか。 | 複数端末対応の OAuth アカウントには `arkor login --oauth`。既存の匿名作業はマイグレーションできない。CLI は `1` で終了。 | -| ``Your anonymous credentials are no longer valid. … arkor login --oauth``(和訳: 匿名認証情報はもう有効ではありません。… `arkor login --oauth`) | `arkor whoami` および認証付きの全コマンド | クラウド API が `code: "anonymous_account_not_found"` で拒否した(HTTP 401)。背後の `anonymous_users` 行が削除された(admin / cascade / 明示的な revoke)。 | サインアップするには `arkor login --oauth`。前の匿名ワークスペースは取り戻せない。CLI は `1` で終了。 | -| `cloud-api `(または上流の `error` 本文) | `arkor whoami` および認証付きの全コマンド | 構造化された認証状態 `code` を持たない非 200 / 非 426 のクラウド API レスポンス(一過性の 5xx、未マップの 4xx など)。CLI は commander 経由で再 throw し、メッセージを表示して非ゼロで終了する。 | 上流メッセージを確認し、トランスポート / サーバー障害なら再試行。OAuth アクセストークンの期限切れなら `arkor login --oauth` を再実行。匿名トークンの期限切れなら `arkor login --anonymous` を再実行(新しい `anonymousId` で別ワークスペースになる)。 | +| ``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` で終了。 | +| `CloudApiError: cloud-api `(とスタックトレース) | `arkor whoami` および認証付きの全コマンド | 構造化された認証状態 `code` を持たない非 200 / 非 426 のクラウド API レスポンス(一過性の 5xx、未マップの 4xx など)。`cli/main.ts` がそのまま再 throw し、`bin.ts` に catch がないので Node のデフォルトのトップレベル拒否ハンドラがレンダリングする。 | スタック先頭の上流メッセージを確認し、トランスポート / サーバー障害なら再試行。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 874bcdc3..34341edb 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -60,7 +60,7 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | `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 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. Run `arkor login --oauth` to sign up for multi-device access.``(注意: 匿名アカウントはこのマシーンでのみ動作します。複数端末で使うには `arkor login --oauth` でサインアップしてください) | 匿名ブートストラップ成功行と並んで全デプロイで出る情報行。匿名アカウントは発行マシーンに対して単一端末ガードでバインドされるので、別端末での 401 を待たず最初に制約を提示する。 | 任意。複数端末でのアクセスが必要なら OAuth アカウント用に `arkor login --oauth`。既存の匿名作業はマイグレーションできず、OAuth アカウントは新規スタート。 | +| ``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 22d5e6dd..fd7ac03a 100644 --- a/packages/arkor/README.md +++ b/packages/arkor/README.md @@ -103,9 +103,12 @@ issues a throwaway token tied to a brand-new personal org. **It only works on the machine where it was issued.** Copying `~/.arkor/credentials.json` to a second machine and using it from both will trip the server's single-device guard, and one of the two will be -locked out with `anonymous_token_single_device`. The CLI surfaces this -as a hint to run `arkor login --oauth` for a real account that supports -multiple devices. +locked out 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 — there is no migration of the existing anonymous +workspace today. Anonymous data isn't recoverable across re-issuance: deleting the credentials file or losing the single-device race means the org and 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.ts b/packages/arkor/src/cli/commands/dev.ts index 50cbfd20..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 { @@ -155,9 +159,15 @@ export async function ensureCredentialsForStudio(): Promise { 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. + // 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( - "Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.", + oauthAvailable + ? ANON_SINGLE_DEVICE_NOTE_WITH_OAUTH + : ANON_SINGLE_DEVICE_NOTE, ); } diff --git a/packages/arkor/src/cli/commands/login.ts b/packages/arkor/src/cli/commands/login.ts index e5230712..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"; @@ -155,10 +157,14 @@ async function runAnonymousLogin(opts: { 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. The wording aligns with `formatAnonymousAuthError` so the - // hint they see now matches the error they'd see later. + // 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( - "Note: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.", + opts.oauthAvailable === true + ? ANON_SINGLE_DEVICE_NOTE_WITH_OAUTH + : ANON_SINGLE_DEVICE_NOTE, ); } diff --git a/packages/arkor/src/cli/commands/whoami.test.ts b/packages/arkor/src/cli/commands/whoami.test.ts index 6663a47e..d179dfd3 100644 --- a/packages/arkor/src/cli/commands/whoami.test.ts +++ b/packages/arkor/src/cli/commands/whoami.test.ts @@ -265,6 +265,71 @@ describe("runWhoami", () => { expect(out).toMatch(/Orgs: o-without-slug, named/); }); + it("appends the bare single-device note when /v1/me reports an anonymous identity", async () => { + // Anonymous accounts are bound to the issuing machine on the + // server side (jti rotation). Surface that fact at whoami time so + // users discover it before hitting a 401 on a second machine. The + // note here is intentionally the *bare* fact — whoami doesn't fetch + // /v1/auth/cli/config, so it can't tell whether `arkor login --oauth` + // would actually work, and recommending it on anon-only deployments + // would point users at a command that fails. + 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: { kind: "anonymous", anonymousId: "abc" }, + orgs: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as typeof fetch; + + await runWhoami(); + const out = stdoutChunks.join(""); + expect(out).toMatch( + /Note: anonymous accounts work on this machine only\./, + ); + // The OAuth-flavoured upgrade hint is suppressed here because + // whoami does not know whether OAuth is configured on the + // deployment. + expect(out).not.toMatch(/arkor login --oauth/); + }); + + 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).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 diff --git a/packages/arkor/src/cli/commands/whoami.ts b/packages/arkor/src/cli/commands/whoami.ts index 53d6a62d..60d8cae5 100644 --- a/packages/arkor/src/cli/commands/whoami.ts +++ b/packages/arkor/src/cli/commands/whoami.ts @@ -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 { @@ -68,12 +69,14 @@ export async function runWhoami(): Promise { if (isAnonymous) { // Anonymous accounts are single-device on purpose, so surface the // limitation here so users discover it before hitting a 401 on a - // second machine. `arkor login --oauth` is the explicit upgrade - // path; phrasing matches the auth-error formatter so users see the - // same advice from both surfaces. - process.stdout.write( - "\nNote: anonymous accounts work on this machine only. Run `arkor login --oauth` to sign up for multi-device access.\n", - ); + // second machine. We deliberately emit the *bare* fact rather than + // the OAuth-flavoured variant: `whoami` doesn't know whether the + // current deployment advertises OAuth (that would require a second + // network call to `/v1/auth/cli/config`), 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. + process.stdout.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..28c68d13 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -12,6 +12,15 @@ 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(), +})); + // 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 +49,8 @@ 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 { shutdownTelemetry } from "../core/telemetry"; import { main } from "./main"; @@ -58,6 +69,7 @@ beforeEach(() => { vi.mocked(runDev).mockReset(); vi.mocked(shutdownTelemetry).mockReset(); vi.mocked(shutdownTelemetry).mockResolvedValue(undefined); + vi.mocked(fetchCliConfig).mockReset(); mockDeprecation.value = null; }); @@ -202,6 +214,159 @@ 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 false → + // formatter falls back to the universally-available 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("treats fetchCliConfig failure as anon-only (probe falls back to false)", async () => { + // Network blip → probeOauthAvailability returns false → users + // get the universally-available recovery rather than a `--oauth` + // hint that might fail on this deployment. + 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(/arkor login --anonymous/); + expect(buf).not.toMatch(/arkor login --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 2fb7e581..d33963b3 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -7,13 +7,36 @@ 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 } from "../core/anonymous-auth-error"; +import { + formatAnonymousAuthError, + isAnonymousAuthDeadEnd, +} from "../core/anonymous-auth-error"; +import { fetchCliConfig } from "../core/auth0"; +import { defaultArkorCloudApiUrl } 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 `oauthAvailable` for the current deployment so anonymous-auth + * dead-end errors recommend a recovery path that actually works on + * anon-only deployments. Best-effort: any failure (network, malformed + * cfg) collapses to `false`, which makes the formatter point at + * `arkor login --anonymous` rather than `--oauth` — i.e. the only + * recovery that's universally available. Cheap, but worth pre-fetching + * so the formatter stays synchronous. + */ +async function probeOauthAvailability(): Promise { + try { + const cfg = await fetchCliConfig(defaultArkorCloudApiUrl()); + return Boolean(cfg.auth0Domain && cfg.clientId && cfg.audience); + } catch { + return false; + } +} + export async function main(argv: string[]): Promise { const program = new Command(); program.name("arkor").description("Arkor CLI").version(SDK_VERSION); @@ -151,18 +174,27 @@ export async function main(argv: string[]): Promise { try { await program.parseAsync(argv, { from: "user" }); } catch (err) { - // Intercept the structured anonymous-auth-state errors before - // commander's default handler converts them into a noisy stack - // trace. The helper returns a CLI-shaped string for the two known - // dead-end codes (`anonymous_token_single_device`, - // `anonymous_account_not_found`); everything else rethrows so - // commander still surfaces it. Setting `process.exitCode` (rather - // than calling `process.exit` directly) keeps the deprecation + - // telemetry-shutdown step in the `finally` block reachable. - const friendly = formatAnonymousAuthError(err); - if (friendly !== null) { - process.stderr.write(`${friendly}\n`); - process.exitCode = 1; + // 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. + // Failure collapses to "no OAuth", which steers the formatter at + // the universally-available `arkor login --anonymous` recovery. + const oauthAvailable = await probeOauthAvailability(); + const friendly = formatAnonymousAuthError(err, { oauthAvailable }); + if (friendly !== null) { + process.stderr.write(`${friendly}\n`); + process.exitCode = 1; + } else { + throw err; + } } else { throw err; } diff --git a/packages/arkor/src/core/anonymous-auth-error.test.ts b/packages/arkor/src/core/anonymous-auth-error.test.ts index 810716c8..2b116bfa 100644 --- a/packages/arkor/src/core/anonymous-auth-error.test.ts +++ b/packages/arkor/src/core/anonymous-auth-error.test.ts @@ -26,25 +26,69 @@ describe("formatAnonymousAuthError", () => { ).toBeNull(); }); - it("formats anonymous_token_single_device with multi-device guidance", () => { - const out = formatAnonymousAuthError( - new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), - ); - expect(out).not.toBeNull(); - expect(out!).toMatch(/single-device/); - // Must direct at the OAuth flow specifically, not the bare `arkor - // login` (whose interactive picker defaults to Anonymous and would - // just re-issue another single-device token). - expect(out!).toMatch(/arkor login --oauth/); + 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/); + }); + + 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/); + }); + + it("treats `oauthAvailable: undefined` as anon-only (errs on suppression)", () => { + // Same gating contract as ANON_PERSISTENCE_NUDGE in anonymous.ts: + // `undefined` (cfg fetch skipped or failed) is treated like + // `false` so we never dead-end users on `--oauth` we can't + // confirm works. + const out = formatAnonymousAuthError( + new CloudApiError(409, "...", ANONYMOUS_TOKEN_SINGLE_DEVICE), + ); + expect(out!).toMatch(/arkor login --anonymous/); + expect(out!).not.toMatch(/arkor login --oauth/); + }); }); - it("formats anonymous_account_not_found with re-login guidance", () => { - const out = formatAnonymousAuthError( - new CloudApiError(401, "...", ANONYMOUS_ACCOUNT_NOT_FOUND), - ); - expect(out).not.toBeNull(); - expect(out!).toMatch(/no longer valid/); - expect(out!).toMatch(/arkor login --oauth/); + 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/); + }); }); }); diff --git a/packages/arkor/src/core/anonymous-auth-error.ts b/packages/arkor/src/core/anonymous-auth-error.ts index 72357e2b..b353bb32 100644 --- a/packages/arkor/src/core/anonymous-auth-error.ts +++ b/packages/arkor/src/core/anonymous-auth-error.ts @@ -9,36 +9,74 @@ import { CloudApiError } from "./client"; 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 *confirmed* available on the current deployment. + * Same gating contract as the login/dev surfaces: only a `true` value + * unlocks the `arkor login --oauth` recovery hint. `false` / + * `undefined` (cfg fetch skipped or failed) fall back to the + * `arkor login --anonymous` re-mint path, which is the only recovery + * that works on every supported deployment shape — pointing anon-only + * users at `--oauth` would just send them to a command that fails + * immediately. + */ + oauthAvailable?: 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 cases share an end-user action (`arkor login --oauth`) but - * differ in cause: + * 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 is signing up via OAuth. + * 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. + * (admin / cascade / explicit revocation). Token can't be salvaged; + * user has to either sign up (OAuth) or start fresh as anon. */ -export function formatAnonymousAuthError(err: unknown): string | null { +export function formatAnonymousAuthError( + err: unknown, + ctx: FormatAnonymousAuthErrorContext = {}, +): string | null { if (!(err instanceof CloudApiError)) return null; + const oauthLine = + ctx.oauthAvailable === true + ? " arkor login --oauth" + : " arkor login --anonymous"; if (err.code === ANONYMOUS_TOKEN_SINGLE_DEVICE) { + if (ctx.oauthAvailable === true) { + return [ + "Anonymous credentials were rejected as single-device.", + "Anonymous accounts only work on one machine. Sign up for an account that supports multiple devices:", + "", + oauthLine, + ].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:", + "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 --oauth", + oauthLine, ].join("\n"); } if (err.code === ANONYMOUS_ACCOUNT_NOT_FOUND) { + if (ctx.oauthAvailable === true) { + return [ + "Your anonymous credentials are no longer valid.", + "Sign up to continue:", + "", + oauthLine, + ].join("\n"); + } return [ "Your anonymous credentials are no longer valid.", - "Sign up to continue:", + "Mint a new anonymous identity to continue (your previous workspace data cannot be recovered):", "", - " arkor login --oauth", + oauthLine, ].join("\n"); } return null; diff --git a/packages/cli-internal/src/templates.ts b/packages/cli-internal/src/templates.ts index 4c93e729..2b7c1d23 100644 --- a/packages/cli-internal/src/templates.ts +++ b/packages/cli-internal/src/templates.ts @@ -139,13 +139,17 @@ npm install && npm run dev Anonymous tokens are tied to this machine. Copying \`~/.arkor/credentials.json\` to another device will be rejected as a -single-device policy violation. Sign up for a real account to keep your -work and use it across machines: +single-device policy violation. When you're ready for an account-backed +workspace that follows you across devices, run: \`\`\` npx arkor login --oauth \`\`\` +The OAuth flow starts a *new* identity — existing anonymous work cannot +be migrated, so future work created after sign-in is what ends up under +the account. + CLI-only flow (no GUI): \`\`\` From 1869df3b4359b4f09b9080f4fa86b6c818dd3a79 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 3 May 2026 00:38:20 +0900 Subject: [PATCH 03/10] review (round 3): align with bin.ts catch + drop body.user.kind dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #100 round 3 (Copilot, 2026-05-02 15:26 UTC). Three concrete issues to chase: 1. `whoami.ts` keyed the single-device note on `body.user.kind === "anonymous"`, but the cloud-api `/v1/me` schema doesn't guarantee a `kind` field on the response — and the existing test fixtures (and E2E response shapes) don't surface one. That meant the note silently never fired for anonymous users in practice. Switch to `creds.mode === "anon"` (already in scope from `readCredentials()`) so the discriminator is local-only and can't drift with the server schema. Update the unit test fixture to a plain user object so a regression that re-introduces a body-shape dependency would catch it. 2. The previous commit's docs claimed unmapped `CloudApiError`s reach "Node's default top-level rejection handler because `bin.ts` has no catch". That was wrong: `bin.ts:54-58` actually wraps the top-level `await main()` in a try/catch that logs `err.stack ?? err.message` to stderr and sets `process.exitCode = 1`. The explicit catch is there to dodge the bundled minified code-frame Node's default would surface, and to keep stderr flush deterministic across the supported Node range. Update both the prose paragraph and the troubleshooting table row in `docs/cli/auth.mdx` + `docs/ja/cli/auth.mdx`. 3. `docs/cli/dev.mdx` (and Japanese counterpart) had a pre-existing table row telling readers to "back up the credentials file if you want to keep using the same anonymous identity from another machine" directly above the new single-device note that says cross-device use is rejected. Reword the action text so the backup advice is scoped to local recovery on the *same* machine and points at the single-device row for cross-device guidance. 344 unit tests pass; new fixture exercises the post-regression path. --- docs/cli/auth.mdx | 4 +-- docs/cli/dev.mdx | 2 +- docs/ja/cli/auth.mdx | 4 +-- docs/ja/cli/dev.mdx | 2 +- .../arkor/src/cli/commands/whoami.test.ts | 17 ++++++------ packages/arkor/src/cli/commands/whoami.ts | 27 ++++++++++--------- 6 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx index 53f5ccc2..43261f1d 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -120,7 +120,7 @@ In practice that means: - 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()`. They reach Node's top-level rejection handler via `bin.ts`, which logs them with the standard error formatting (class name + stack trace) and exits non-zero — so you'll see something like `CloudApiError: cloud-api 503` followed by a stack frame, not just the upstream `error` body. + 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 logs `err.stack ?? err.message` to stderr and sets `process.exitCode = 1` — so you'll see something like `CloudApiError: cloud-api 503` followed by a stack frame, not just the upstream `error` body. (The explicit catch is there to avoid the bundled minified frame Node's default unhandled-rejection handler would surface, 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 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). @@ -137,5 +137,5 @@ Anonymous tokens have a server-side 90-day TTL, but the CLI does not yet auto-re | `Not signed in. Run \`arkor login\` or \`arkor login --anonymous\`.` | `arkor whoami` | Same condition as above, surfaced from a different command. | Same fix. | | ``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`. | -| `CloudApiError: cloud-api ` (and a stack trace) | `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 it; bin.ts has no catch, so Node's default top-level rejection handler renders it. | Inspect the upstream message at the top of the trace; 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`). | +| `CloudApiError: cloud-api ` (and a stack trace) | `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 it; `bin.ts` catches it at the top of the stack and renders it with `console.error(err.stack ?? err.message)` before setting `process.exitCode = 1`. | Inspect the upstream message at the top of the trace; 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 3b2dcd09..a07eb1c4 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -58,7 +58,7 @@ 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, so the file is not portable. Copying it to another machine will be rejected on the next refresh, and once a refresh lands (the current SDK doesn't auto-refresh, but it's on the roadmap), even an older backup of the file on this machine becomes stale because the server's `latest_jti` has moved on. 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`. | diff --git a/docs/ja/cli/auth.mdx b/docs/ja/cli/auth.mdx index dfb6471e..ba04c6ad 100644 --- a/docs/ja/cli/auth.mdx +++ b/docs/ja/cli/auth.mdx @@ -121,7 +121,7 @@ OAuth セッションでは、認証情報ファイルにアクセストーク - 匿名専用デプロイ: ``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` には catch がないので Node のデフォルトのトップレベル拒否ハンドラが標準のエラーフォーマット(クラス名 + スタックトレース)で表示し、非ゼロで終了します。つまり `CloudApiError: cloud-api 503` の後にスタックフレームが見え、上流の `error` 本文だけが見えるわけではありません。 + 既知の `code` を持たないエラー(および `CloudApiError` 以外の例外)は `main()` から再 throw されます。`bin.ts` のトップレベル `await main(...)` は try/catch で囲まれており、`console.error(err.stack ?? err.message)` で stderr に出力した上で `process.exitCode = 1` をセットします。つまり `CloudApiError: cloud-api 503` の後にスタックフレームが見え、上流の `error` 本文だけが見えるわけではありません。明示的に catch しているのは、Node のデフォルトの unhandled rejection ハンドラがバンドル後の minified なコードフレームを出すのを避け、サポート対象の Node バージョン全体で stderr の flush を確定させるためです。 - OAuth セッションの直し方は `arkor login --oauth` をもう一度走らせることです。フル PKCE フローを通って `~/.arkor/credentials.json` を新トークンで上書きします。 匿名トークンはサーバー側に 90 日の TTL がありますが、CLI はまだ自動リフレッシュをしません。その配線は `@arkor/cloud-api-client` の `getToken()` 側にあり、SDK ロードマップに残っています。今日時点で匿名セッションが失敗し始めたら `arkor login --anonymous` で新しいものを発行してください(新しい `anonymousId` が発行されるので、実質的には別ワークスペースになります)。 @@ -138,5 +138,5 @@ OAuth セッションでは、認証情報ファイルにアクセストーク | `Not signed in. Run \`arkor login\` or \`arkor login --anonymous\`.`(サインインしていません。`arkor login` または `arkor login --anonymous` を実行してください) | `arkor whoami` | 上と同じ条件を別コマンドから出している。 | 同じ対処。 | | ``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` で終了。 | -| `CloudApiError: cloud-api `(とスタックトレース) | `arkor whoami` および認証付きの全コマンド | 構造化された認証状態 `code` を持たない非 200 / 非 426 のクラウド API レスポンス(一過性の 5xx、未マップの 4xx など)。`cli/main.ts` がそのまま再 throw し、`bin.ts` に catch がないので Node のデフォルトのトップレベル拒否ハンドラがレンダリングする。 | スタック先頭の上流メッセージを確認し、トランスポート / サーバー障害なら再試行。OAuth アクセストークンの期限切れなら `arkor login --oauth` を再実行。匿名トークンの期限切れなら `arkor login --anonymous` を再実行(新しい `anonymousId` で別ワークスペースになる)。 | +| `CloudApiError: cloud-api `(とスタックトレース) | `arkor whoami` および認証付きの全コマンド | 構造化された認証状態 `code` を持たない非 200 / 非 426 のクラウド API レスポンス(一過性の 5xx、未マップの 4xx など)。`cli/main.ts` がそのまま再 throw し、`bin.ts` がスタックの最上位で catch して `console.error(err.stack ?? err.message)` で表示した後、`process.exitCode = 1` をセットする。 | スタック先頭の上流メッセージを確認し、トランスポート / サーバー障害なら再試行。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 34341edb..be0ad742 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -58,7 +58,7 @@ 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 を維持」の案内は「ファイルを削除しない」という意味です。サーバー側の単一端末ガードのため、このファイルは可搬性がありません: 別マシーンへコピーすると次回 refresh で拒否され、一度 refresh が走ると(現在の SDK は自動 refresh しませんがロードマップに乗っています)同じマシーン上の古いバックアップですらサーバーの `latest_jti` が進んでしまうため stale になります。バックアップして復旧する対象ではなく、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` を再実行。 | diff --git a/packages/arkor/src/cli/commands/whoami.test.ts b/packages/arkor/src/cli/commands/whoami.test.ts index d179dfd3..d08d0830 100644 --- a/packages/arkor/src/cli/commands/whoami.test.ts +++ b/packages/arkor/src/cli/commands/whoami.test.ts @@ -265,14 +265,13 @@ describe("runWhoami", () => { expect(out).toMatch(/Orgs: o-without-slug, named/); }); - it("appends the bare single-device note when /v1/me reports an anonymous identity", async () => { - // Anonymous accounts are bound to the issuing machine on the - // server side (jti rotation). Surface that fact at whoami time so - // users discover it before hitting a 401 on a second machine. The - // note here is intentionally the *bare* fact — whoami doesn't fetch - // /v1/auth/cli/config, so it can't tell whether `arkor login --oauth` - // would actually work, and recommending it on anon-only deployments - // would point users at a command that fails. + it("appends the bare single-device note 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", @@ -284,7 +283,7 @@ describe("runWhoami", () => { async () => new Response( JSON.stringify({ - user: { kind: "anonymous", anonymousId: "abc" }, + user: { id: "u-anon", email: null }, orgs: [], }), { status: 200, headers: { "content-type": "application/json" } }, diff --git a/packages/arkor/src/cli/commands/whoami.ts b/packages/arkor/src/cli/commands/whoami.ts index 60d8cae5..2be80ce8 100644 --- a/packages/arkor/src/cli/commands/whoami.ts +++ b/packages/arkor/src/cli/commands/whoami.ts @@ -56,26 +56,29 @@ export async function runWhoami(): Promise { user: Record; orgs: Record[]; }; - const isAnonymous = - typeof body.user === "object" && - body.user !== null && - (body.user as { kind?: unknown }).kind === "anonymous"; process.stdout.write(`${JSON.stringify(body.user, null, 2)}\n`); if (body.orgs.length > 0) { process.stdout.write( `Orgs: ${body.orgs.map((o) => String(o.slug ?? o.id)).join(", ")}\n`, ); } - if (isAnonymous) { + if (creds.mode === "anon") { // 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 deliberately emit the *bare* fact rather than - // the OAuth-flavoured variant: `whoami` doesn't know whether the - // current deployment advertises OAuth (that would require a second - // network call to `/v1/auth/cli/config`), 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. + // 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. process.stdout.write(`\n${ANON_SINGLE_DEVICE_NOTE}\n`); } // Avoid "unused import" noise by referencing CloudApiClient in an assertion. From 96bfc07530e977267e6b36d7d3ce38b1a00bfdc9 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 3 May 2026 02:35:00 +0900 Subject: [PATCH 04/10] review (round 4): probe OAuth at credentials' URL + cover code/note gating - main.ts: probe `/v1/auth/cli/config` against the credentials' own `arkorCloudApiUrl` instead of the global default, so users on a non-default deployment (or with a stale `ARKOR_CLOUD_API_URL`) get the recovery hint that matches the deployment that actually rejected them. (Codex P2 on PR #100) - main.test.ts: partial-mock `core/credentials` so `readCredentials` becomes controllable per-test, and add a case that pins the probe URL to the credentials' value rather than the env-derived default. - client.test.ts: cover `buildCloudApiError` preserving `code` (the field cli/main.ts pivots on for the friendly anon-auth-error path) and the `code === undefined` fall-through on bodies without it. - login.test.ts / dev.test.ts: add gating tests for `ANON_SINGLE_DEVICE_NOTE` mirroring the persistence-nudge tests, so the OAuth-flavoured variant only fires when `oauthAvailable === true`. --- packages/arkor/src/cli/commands/dev.test.ts | 79 ++++++++++++++ packages/arkor/src/cli/commands/login.test.ts | 101 ++++++++++++++++++ packages/arkor/src/cli/main.test.ts | 56 ++++++++++ packages/arkor/src/cli/main.ts | 33 ++++-- packages/arkor/src/core/client.test.ts | 58 +++++++++- 5 files changed, 319 insertions(+), 8 deletions(-) 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/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/main.test.ts b/packages/arkor/src/cli/main.test.ts index 28c68d13..678e20ac 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -21,6 +21,21 @@ 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", () => ({ @@ -51,6 +66,7 @@ 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"; @@ -70,6 +86,8 @@ beforeEach(() => { 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; }); @@ -339,6 +357,44 @@ describe("main (CLI Commander wiring)", () => { 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(); + } + expect(fetchCliConfig).toHaveBeenCalledWith( + "https://custom.cloud.example", + ); + }); + it("treats fetchCliConfig failure as anon-only (probe falls back to false)", async () => { // Network blip → probeOauthAvailability returns false → users // get the universally-available recovery rather than a `--oauth` diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index d33963b3..f48e5592 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -12,7 +12,10 @@ import { isAnonymousAuthDeadEnd, } from "../core/anonymous-auth-error"; import { fetchCliConfig } from "../core/auth0"; -import { defaultArkorCloudApiUrl } from "../core/credentials"; +import { + defaultArkorCloudApiUrl, + readCredentials, +} from "../core/credentials"; import { getRecordedDeprecation } from "../core/deprecation"; import { shutdownTelemetry, withTelemetry } from "../core/telemetry"; import { detectedUpgradeCommand } from "../core/upgrade-hint"; @@ -22,15 +25,31 @@ import { ui } from "./prompts"; /** * Resolve `oauthAvailable` for the current deployment so anonymous-auth * dead-end errors recommend a recovery path that actually works on - * anon-only deployments. Best-effort: any failure (network, malformed - * cfg) collapses to `false`, which makes the formatter point at - * `arkor login --anonymous` rather than `--oauth` — i.e. the only - * recovery that's universally available. Cheap, but worth pre-fetching - * so the formatter stays synchronous. + * 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. + * + * Best-effort: missing credentials, network failure, or malformed cfg + * all collapse to `false`, which makes the formatter point at + * `arkor login --anonymous` rather than `--oauth`. That's the only + * recovery that's universally available, so erring on the + * suppression-of-`--oauth` side is safe. */ async function probeOauthAvailability(): Promise { try { - const cfg = await fetchCliConfig(defaultArkorCloudApiUrl()); + 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); return Boolean(cfg.auth0Domain && cfg.clientId && cfg.audience); } catch { return false; 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(); + }); }); From dca30437ee330f2b780985605f964db3b191e4ba Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 3 May 2026 03:31:07 +0900 Subject: [PATCH 05/10] review (round 5): pin whoami to creds URL, move note to stderr, fix docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - whoami.ts: target the credentials' own `arkorCloudApiUrl` for `/v1/me` instead of `defaultArkorCloudApiUrl()`. Without this a stale `ARKOR_CLOUD_API_URL` (or a token issued against a non-default endpoint) would route the call to the wrong cloud-api, and the dead-end formatter in `cli/main.ts` could surface single-device guidance for a token that's still valid on its real deployment. Auth0 creds don't pin a URL and fall through to the env default. (Copilot on PR #100) - whoami.ts: emit `ANON_SINGLE_DEVICE_NOTE` on stderr, not stdout. `arkor whoami` writes the user JSON + `Orgs:` line to stdout; mixing human-oriented prose into that stream broke wrappers grepping/jq-ing the output. stdout now stays a stable, machine-parseable shape. (Copilot on PR #100) - whoami.test.ts: split the existing assertion onto stdout/stderr to match the new contract, and add a case that pins the resolved baseUrl to the credentials' value over the env-derived default. - docs/cli/dev.mdx + ja: broaden the "rejected on the next refresh" wording. The `latest_jti` mismatch fires from `userAuth` on every authenticated request, not just refresh, so the original phrasing understated when failures land. The current SDK still doesn't rotate the jti on its own (auto-refresh is on the roadmap) so a copied file often appears to work for a while; the new text spells that out without implying refresh is the only trigger. - docs/cli/auth.mdx + ja: replace the stale "Other 4xx/5xx exit `0` with `Token may be expired`" exit-code line with the new contract (rethrow → `bin.ts` stack-trace → exit `1`), and split out the anonymous-auth dead-end exit `1` row so the section no longer contradicts the Token-expiry / Common-errors paragraphs below. --- docs/cli/auth.mdx | 3 +- docs/cli/dev.mdx | 2 +- docs/ja/cli/auth.mdx | 4 +- docs/ja/cli/dev.mdx | 2 +- .../arkor/src/cli/commands/whoami.test.ts | 40 ++++++++++++++++++- packages/arkor/src/cli/commands/whoami.ts | 21 +++++++++- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx index 43261f1d..ef1bad20 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -102,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 to stderr as `err.stack ?? err.message` before exiting non-zero. ## Where the credentials live diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index a07eb1c4..ceb30879 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -58,7 +58,7 @@ 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. 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, so the file is not portable. Copying it to another machine will be rejected on the next refresh, and once a refresh lands (the current SDK doesn't auto-refresh, but it's on the roadmap), even an older backup of the file on this machine becomes stale because the server's `latest_jti` has moved on. Treat the file as live state, not as something to back up and restore. | +| ``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`. | diff --git a/docs/ja/cli/auth.mdx b/docs/ja/cli/auth.mdx index ba04c6ad..b011930a 100644 --- a/docs/ja/cli/auth.mdx +++ b/docs/ja/cli/auth.mdx @@ -102,8 +102,8 @@ arkor whoami - `0`: サインイン中、identity を表示。 - `0`: 未サインイン、メッセージは情報用のみ。 - `1`: クラウド API が `426 Upgrade Required` を返した。CLI はアップグレードのヒント(と検出したパッケージマネージャ用のアップグレードコマンド)を表示し、`process.exitCode = 1` を立てて、`arkor` のシャットダウンフックの非推奨警告フラッシュが終了前に走るようにします。 -- `1`: クラウド API が認証状態系の構造化 `code`(`anonymous_token_single_device` または `anonymous_account_not_found`)を返した。`cli/main.ts` のトップレベルハンドラがそれを実行可能なメッセージへ整形し、`process.exitCode = 1` を立てます。 -- それ以外の 4xx / 5xx は `CloudApiError` として再 throw され、commander のデフォルトハンドラが上流の `error` 本文をそのまま表示して非ゼロで終了します(旧来の `Token may be expired` ヒントは削除されました。構造化 `code` の方が誤解を生まずに済むためです)。 +- `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.stack ?? err.message` を stderr に出してから非ゼロで終了します。 ## 認証情報の保存場所 diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index be0ad742..8250f26e 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -58,7 +58,7 @@ 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 を維持」の案内は「ファイルを削除しない」という意味です。サーバー側の単一端末ガードのため、このファイルは可搬性がありません: 別マシーンへコピーすると次回 refresh で拒否され、一度 refresh が走ると(現在の SDK は自動 refresh しませんがロードマップに乗っています)同じマシーン上の古いバックアップですらサーバーの `latest_jti` が進んでしまうため stale になります。バックアップして復旧する対象ではなく、live state として扱ってください。 | +| ``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` を再実行。 | diff --git a/packages/arkor/src/cli/commands/whoami.test.ts b/packages/arkor/src/cli/commands/whoami.test.ts index d08d0830..f02cbd54 100644 --- a/packages/arkor/src/cli/commands/whoami.test.ts +++ b/packages/arkor/src/cli/commands/whoami.test.ts @@ -238,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 @@ -291,14 +319,20 @@ describe("runWhoami", () => { ) as typeof fetch; await runWhoami(); + // 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(""); - expect(out).toMatch( + 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(out).not.toMatch(/arkor login --oauth/); + expect(err).not.toMatch(/arkor login --oauth/); }); it("does not append the single-device note for non-anonymous users", async () => { @@ -326,7 +360,9 @@ describe("runWhoami", () => { await runWhoami(); const out = stdoutChunks.join(""); + 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 () => { diff --git a/packages/arkor/src/cli/commands/whoami.ts b/packages/arkor/src/cli/commands/whoami.ts index 2be80ce8..53c67930 100644 --- a/packages/arkor/src/cli/commands/whoami.ts +++ b/packages/arkor/src/cli/commands/whoami.ts @@ -17,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({ @@ -79,7 +91,12 @@ export async function runWhoami(): Promise { // them at a command that fails immediately. The matching login/dev // surfaces, which already know `oauthAvailable`, do append the // upgrade hint when warranted. - process.stdout.write(`\n${ANON_SINGLE_DEVICE_NOTE}\n`); + // + // 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 stays a stable, machine-parseable shape. + process.stderr.write(`\n${ANON_SINGLE_DEVICE_NOTE}\n`); } // Avoid "unused import" noise by referencing CloudApiClient in an assertion. void CloudApiClient; From 053803e93406b26d37efa64205b82e42d66873cb Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 3 May 2026 16:36:39 +0900 Subject: [PATCH 06/10] review (round 6): cap probe, gate note on TTY, message-only CloudApiError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.ts + auth0.ts: bound the OAuth-availability probe with `AbortSignal.timeout(3000)`. The probe runs *after* a command has already failed, so a degraded `/v1/auth/cli/config` would otherwise leave the user staring at nothing while the recovery hint waits on a hung HTTP call. `fetchCliConfig` now takes an `{ fetch, signal }` options object; the previous positional `fetchImpl` shape (still used by the older auth0.test.ts cases) was migrated. - whoami.ts: gate `ANON_SINGLE_DEVICE_NOTE` on `process.stdout.isTTY && process.stderr.isTTY`. Pipelines (`arkor whoami | jq`) drop `stdout.isTTY`; CI runners that treat stderr-on-success as a warning marker drop `stderr.isTTY`. Both groups now see clean output. The note still goes to stderr in the interactive case so stdout's machine-parseable JSON / `Orgs:` shape is preserved. - whoami.test.ts: split the existing assertion into TTY / non-TTY cases — the first forces both streams to TTY before invoking runWhoami, the second pins them to false to cover CI/script use. - bin.ts: render `CloudApiError` as just `err.message`; keep `err.stack ?? err.message` for unknown `Error`s. Routine HTTP failures (expired OAuth session, transient 5xx, unmapped 4xx) no longer dump a full stack frame, so wrappers and humans see one-line output that matches the upstream cloud-api message; genuine SDK bugs still surface a stack so they're filable. - docs/cli/auth.mdx + ja: update the exit-codes paragraph, the Token-expiry write-up, and the Common-errors row to describe the new bin.ts rendering rule (message-only for CloudApiError) and the TTY gate on whoami's anonymous note. --- docs/cli/auth.mdx | 8 +-- docs/ja/cli/auth.mdx | 8 +-- packages/arkor/src/bin.ts | 17 +++++- .../arkor/src/cli/commands/whoami.test.ts | 58 ++++++++++++++++++- packages/arkor/src/cli/commands/whoami.ts | 13 +++-- packages/arkor/src/cli/main.test.ts | 4 ++ packages/arkor/src/cli/main.ts | 16 ++++- packages/arkor/src/core/auth0.test.ts | 36 +++++++++++- packages/arkor/src/core/auth0.ts | 10 +++- 9 files changed, 147 insertions(+), 23 deletions(-) diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx index ef1bad20..a9a89fb0 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -49,7 +49,7 @@ Anonymous accounts are intentionally **single-device**: the cloud-api binds the 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, because it doesn't fetch `/v1/auth/cli/config` to learn whether OAuth is offered. +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, so a wrapper redirecting `arkor whoami | jq` (loses `stdout.isTTY`) or a CI runner that treats stderr-on-success as a warning marker (loses `stderr.isTTY`) sees clean output. The note is purely a UX hint; it doesn't gate any behaviour, and `whoami`'s machine-readable JSON shape on stdout never changes. ## `arkor logout` @@ -103,7 +103,7 @@ When signed in, the command prints the JSON `user` object pretty-printed, then a - `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. - `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 to stderr as `err.stack ?? err.message` before exiting non-zero. +- `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 @@ -121,7 +121,7 @@ In practice that means: - 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 logs `err.stack ?? err.message` to stderr and sets `process.exitCode = 1` — so you'll see something like `CloudApiError: cloud-api 503` followed by a stack frame, not just the upstream `error` body. (The explicit catch is there to avoid the bundled minified frame Node's default unhandled-rejection handler would surface, and to keep the stderr flush deterministic across the supported Node range.) + 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 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). @@ -138,5 +138,5 @@ Anonymous tokens have a server-side 90-day TTL, but the CLI does not yet auto-re | `Not signed in. Run \`arkor login\` or \`arkor login --anonymous\`.` | `arkor whoami` | Same condition as above, surfaced from a different command. | Same fix. | | ``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`. | -| `CloudApiError: cloud-api ` (and a stack trace) | `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 it; `bin.ts` catches it at the top of the stack and renders it with `console.error(err.stack ?? err.message)` before setting `process.exitCode = 1`. | Inspect the upstream message at the top of the trace; 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`). | +| `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/ja/cli/auth.mdx b/docs/ja/cli/auth.mdx index b011930a..98c2704f 100644 --- a/docs/ja/cli/auth.mdx +++ b/docs/ja/cli/auth.mdx @@ -49,7 +49,7 @@ arkor login [options] どちらの匿名パスでも、新しい `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 に対しては `/v1/auth/cli/config` をフェッチしないので、bare 版のみを出します。 +匿名発行のあらゆる入口で、単一端末の制約も別行の 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 のときに限るので、`arkor whoami | jq`(stdout が TTY でなくなる)や、stderr 出力をすべて警告扱いする CI ランナー(stderr が TTY でなくなる)では note は出ません。あくまで UX のヒントであり、どの挙動もゲートしません。stdout 側の機械可読 JSON は常に同じ形です。 ## `arkor logout` @@ -103,7 +103,7 @@ arkor whoami - `0`: 未サインイン、メッセージは情報用のみ。 - `1`: クラウド API が `426 Upgrade Required` を返した。CLI はアップグレードのヒント(と検出したパッケージマネージャ用のアップグレードコマンド)を表示し、`process.exitCode = 1` を立てて、`arkor` のシャットダウンフックの非推奨警告フラッシュが終了前に走るようにします。 - `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.stack ?? err.message` を 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 失敗はラッパーのログを汚しません。 ## 認証情報の保存場所 @@ -121,7 +121,7 @@ OAuth セッションでは、認証情報ファイルにアクセストーク - 匿名専用デプロイ: ``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 で囲まれており、`console.error(err.stack ?? err.message)` で stderr に出力した上で `process.exitCode = 1` をセットします。つまり `CloudApiError: cloud-api 503` の後にスタックフレームが見え、上流の `error` 本文だけが見えるわけではありません。明示的に catch しているのは、Node のデフォルトの unhandled rejection ハンドラがバンドル後の minified なコードフレームを出すのを避け、サポート対象の Node バージョン全体で stderr の flush を確定させるためです。 + 既知の `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` を新トークンで上書きします。 匿名トークンはサーバー側に 90 日の TTL がありますが、CLI はまだ自動リフレッシュをしません。その配線は `@arkor/cloud-api-client` の `getToken()` 側にあり、SDK ロードマップに残っています。今日時点で匿名セッションが失敗し始めたら `arkor login --anonymous` で新しいものを発行してください(新しい `anonymousId` が発行されるので、実質的には別ワークスペースになります)。 @@ -138,5 +138,5 @@ OAuth セッションでは、認証情報ファイルにアクセストーク | `Not signed in. Run \`arkor login\` or \`arkor login --anonymous\`.`(サインインしていません。`arkor login` または `arkor login --anonymous` を実行してください) | `arkor whoami` | 上と同じ条件を別コマンドから出している。 | 同じ対処。 | | ``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` で終了。 | -| `CloudApiError: cloud-api `(とスタックトレース) | `arkor whoami` および認証付きの全コマンド | 構造化された認証状態 `code` を持たない非 200 / 非 426 のクラウド API レスポンス(一過性の 5xx、未マップの 4xx など)。`cli/main.ts` がそのまま再 throw し、`bin.ts` がスタックの最上位で catch して `console.error(err.stack ?? err.message)` で表示した後、`process.exitCode = 1` をセットする。 | スタック先頭の上流メッセージを確認し、トランスポート / サーバー障害なら再試行。OAuth アクセストークンの期限切れなら `arkor login --oauth` を再実行。匿名トークンの期限切れなら `arkor login --anonymous` を再実行(新しい `anonymousId` で別ワークスペースになる)。 | +| `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/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/commands/whoami.test.ts b/packages/arkor/src/cli/commands/whoami.test.ts index f02cbd54..e125917a 100644 --- a/packages/arkor/src/cli/commands/whoami.test.ts +++ b/packages/arkor/src/cli/commands/whoami.test.ts @@ -293,7 +293,7 @@ describe("runWhoami", () => { expect(out).toMatch(/Orgs: o-without-slug, named/); }); - it("appends the bare single-device note when credentials are anonymous", 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 @@ -318,7 +318,19 @@ describe("runWhoami", () => { ), ) as typeof fetch; - await runWhoami(); + // 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 @@ -335,6 +347,48 @@ describe("runWhoami", () => { 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", + 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; + + // 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. diff --git a/packages/arkor/src/cli/commands/whoami.ts b/packages/arkor/src/cli/commands/whoami.ts index 53c67930..c2b45369 100644 --- a/packages/arkor/src/cli/commands/whoami.ts +++ b/packages/arkor/src/cli/commands/whoami.ts @@ -74,7 +74,7 @@ export async function runWhoami(): Promise { `Orgs: ${body.orgs.map((o) => String(o.slug ?? o.id)).join(", ")}\n`, ); } - if (creds.mode === "anon") { + 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 @@ -92,10 +92,13 @@ export async function runWhoami(): Promise { // surfaces, which already know `oauthAvailable`, do append the // upgrade hint when warranted. // - // 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 stays a stable, machine-parseable shape. + // TTY gate: emit only when *both* stdout and stderr are interactive. + // Wrappers piping stdout through `jq` (or any pipeline) drop + // `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 to keep stdout + // machine-parseable on the rare host where stdout is a TTY but + // a wrapper still parses it (e.g. `script(1)`). process.stderr.write(`\n${ANON_SINGLE_DEVICE_NOTE}\n`); } // Avoid "unused import" noise by referencing CloudApiClient in an assertion. diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index 678e20ac..d33d9455 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -390,8 +390,12 @@ describe("main (CLI Commander wiring)", () => { } 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) }), ); }); diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index f48e5592..c86405f4 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -32,12 +32,20 @@ import { ui } from "./prompts"; * which case probing `defaultArkorCloudApiUrl()` would inspect the * wrong deployment and recommend the opposite recovery path. * - * Best-effort: missing credentials, network failure, or malformed cfg - * all collapse to `false`, which makes the formatter point at + * Best-effort: missing credentials, network failure, malformed cfg, or + * timeout all collapse to `false`, which makes the formatter point at * `arkor login --anonymous` rather than `--oauth`. That's the only * recovery that's universally available, so erring on the * suppression-of-`--oauth` side is safe. + * + * The probe runs *after* a command has already failed, so blocking the + * recovery hint behind an unbounded HTTP call would compound the + * outage: a degraded `/v1/auth/cli/config` endpoint would leave the + * CLI sitting indefinitely with no message printed. `AbortSignal.timeout` + * caps the probe at 3 s so the user always gets *some* guidance even + * when the cloud-api is sick. */ +const PROBE_TIMEOUT_MS = 3000; async function probeOauthAvailability(): Promise { try { const creds = await readCredentials().catch(() => null); @@ -49,7 +57,9 @@ async function probeOauthAvailability(): Promise { creds?.mode === "anon" && creds.arkorCloudApiUrl ? creds.arkorCloudApiUrl : defaultArkorCloudApiUrl(); - const cfg = await fetchCliConfig(baseUrl); + const cfg = await fetchCliConfig(baseUrl, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); return Boolean(cfg.auth0Domain && cfg.clientId && cfg.audience); } catch { return false; 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( From b64c560113813a114aa41e27be464661ff0fb98c Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 3 May 2026 17:02:25 +0900 Subject: [PATCH 07/10] review (round 7): nuance single-device copy across READMEs/templates/docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth/dev docs already explain that single-device enforcement comes from a `latest_jti` mismatch on every authenticated request, but the summary copy in README.md, README.ja.md, packages/arkor/README.md, packages/cli-internal/src/templates.ts, and the one-paragraph blurb in docs/cli/auth.mdx (+ JA mirror) all overstated the timing — saying a copied credentials.json "will be rejected" immediately or "only works on the machine where it was issued". The current SDK doesn't auto-refresh anonymous tokens, so client-side rotation never happens today; a copy on a second machine often keeps working until either the issuing user runs `arkor login --anonymous` again (overwriting the stored jti) or auto-refresh ships and rotates server-side. Once that happens, every other copy fails with `anonymous_token_single_device` on its next call. Updated wording across these surfaces to: 1. Describe the mechanism as "userAuth checks `latest_jti` on every request" (matches the deployed reality), not "rotated each refresh" (which contradicts the same-page note that auto-refresh is not wired). 2. Surface the two timing regimes — "today: copies tend to keep working until a manual relogin or admin rotation", "after auto-refresh ships: any rotation invalidates other copies on the next call" — so users aren't surprised when a copied file appears to work for a while. 3. Soften "only works on this machine" / "will be rejected" to "designed for one machine" / "isn't a supported workflow" while keeping the policy clear. The user-facing CLI message string `ANON_SINGLE_DEVICE_NOTE` ("Note: anonymous accounts work on this machine only.") is left untouched: it's a short policy hint emitted at issuance, not a claim about timing. --- README.ja.md | 2 +- README.md | 2 +- docs/cli/auth.mdx | 2 +- docs/ja/cli/auth.mdx | 2 +- packages/arkor/README.md | 26 ++++++++++++++++---------- packages/cli-internal/src/templates.ts | 13 +++++++++---- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/README.ja.md b/README.ja.md index ad016a41..c1c14494 100644 --- a/README.ja.md +++ b/README.ja.md @@ -52,7 +52,7 @@ pnpm dev **サインアップ不要:** `arkor dev` は **Studio** と呼ばれるローカル Web UI を `http://localhost:4000` で開きます。初回起動時に使い捨ての匿名ワークスペースをプロビジョニングするので、すぐに実際のトレーニング実行を開始できます。 -匿名ワークスペースは `arkor dev` を最初に実行したマシーン専用です。OAuth に切り替えても既存の匿名ワークスペースは引き継げません。`arkor login --oauth` は `~/.arkor/credentials.json` を新しい OAuth identity で上書きし、それ以降の作業は OAuth アカウントに紐付きますが、既存の匿名ジョブや org は発行元の credentials ファイルからしか辿れません。複数端末で作業したくなった時点で `arkor login --oauth` を実行し、アカウント付きの新しいワークスペースで再スタートしてください。 +匿名ワークスペースはこのマシーンに紐づきます。クラウド API はサーバー側で単一端末ガードを強制しているので、`~/.arkor/credentials.json` を別端末にコピーする運用は想定外です。各匿名ワークスペースは発行されたマシーンで使う前提です。今のところ CLI は匿名トークンを自動 refresh しないので、コピーがしばらく動いてしまうことはあります。発行ユーザーがもう一度 `arkor login --anonymous` を実行する(または自動 refresh が入って保存中の jti が進む)と、そのタイミングで他の全コピーは次回呼び出しから単一端末エラーで失敗するようになります。OAuth に切り替えても既存の匿名ワークスペースは引き継げません。`arkor login --oauth` は `~/.arkor/credentials.json` を新しい OAuth identity で上書きし、それ以降の作業は OAuth アカウントに紐付きますが、既存の匿名ジョブや org は発行元の credentials ファイルからしか辿れません。複数端末で作業したくなった時点で `arkor login --oauth` を実行し、アカウント付きの新しいワークスペースで再スタートしてください。 ### テンプレートを選ぶ diff --git a/README.md b/README.md index 477be859..ecf3376b 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. -Anonymous workspaces are tied to this machine. They only work where you ran `arkor dev` first, and switching to OAuth does not migrate them: `arkor login --oauth` overwrites `~/.arkor/credentials.json` with a fresh OAuth identity that any *future* work will be associated with, but existing anonymous jobs and orgs stay reachable only from the credentials file that issued them. Run `arkor login --oauth` whenever you're ready to start an account-backed workspace that follows you across devices. +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 that any *future* work will be associated with, but existing anonymous jobs and orgs stay reachable only from the credentials file that issued them. Run `arkor login --oauth` whenever you're ready to start an account-backed workspace that follows you across devices. ### Pick a template diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx index a9a89fb0..71ac1484 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -43,7 +43,7 @@ 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 binds the issued token to the machine that received it (via a `latest_jti` rotation each time the SDK refreshes the token), so copying `~/.arkor/credentials.json` to a second machine and using it from both will trip the server's single-device guard. 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 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 diff --git a/docs/ja/cli/auth.mdx b/docs/ja/cli/auth.mdx index 98c2704f..3f02d161 100644 --- a/docs/ja/cli/auth.mdx +++ b/docs/ja/cli/auth.mdx @@ -43,7 +43,7 @@ arkor login [options] 匿名認証は、アカウントなしで Arkor を試すためのものです。学習、ジョブ、その他の作業は匿名トークンを介してローカルマシーンに紐づきます。あとで OAuth に切り替える(`arkor login --oauth`、またはピッカーから `OAuth (browser)` を選ぶ)と認証情報ファイルは差し替えられますが、作業は移行されません。匿名で学習したものを残したいなら、学習を始める前に `arkor login --oauth` を走らせてください。 -匿名アカウントは設計上 **単一端末専用** です。クラウド API は発行したマシーンに対してトークンを `latest_jti` ローテーションで束ねるので、`~/.arkor/credentials.json` を別マシーンにコピーして両方から使うとサーバー側の単一端末ガードに引っかかります。負けた側のクライアントは 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 ファイルからしか到達できません。 +匿名アカウントは設計上 **単一端末専用** です。クラウド 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 ファイルからしか到達できません。 ### 匿名発行時の出力 diff --git a/packages/arkor/README.md b/packages/arkor/README.md index fd7ac03a..634ead84 100644 --- a/packages/arkor/README.md +++ b/packages/arkor/README.md @@ -99,16 +99,22 @@ 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 only -works on the machine where it was issued.** Copying -`~/.arkor/credentials.json` to a second machine and using it from both -will trip the server's single-device guard, and one of the two will be -locked out 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 — there is no migration of the existing anonymous -workspace today. +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 — there is no migration of the existing +anonymous workspace today. Anonymous data isn't recoverable across re-issuance: deleting the credentials file or losing the single-device race means the org and diff --git a/packages/cli-internal/src/templates.ts b/packages/cli-internal/src/templates.ts index 2b7c1d23..1395c61a 100644 --- a/packages/cli-internal/src/templates.ts +++ b/packages/cli-internal/src/templates.ts @@ -137,10 +137,15 @@ npm install && npm run dev \`arkor dev\` opens the local Studio GUI (most workflows live there). -Anonymous tokens are tied to this machine. Copying -\`~/.arkor/credentials.json\` to another device will be rejected as a -single-device policy violation. When you're ready for an account-backed -workspace that follows you across devices, run: +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 --oauth From eeb2ca520b323fd03b0b16fd3b2dd39c2f209707 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 3 May 2026 18:53:35 +0900 Subject: [PATCH 08/10] review (round 8): tri-state OAuth probe + accurate whoami stdout doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (PR #100): probeOauthAvailability collapsed every probe failure to false, and formatAnonymousAuthError treated false as "OAuth is not advertised". On a transient probe failure (timeout, network blip, malformed cfg) against an OAuth-supporting deployment, the dead-end formatter would confidently steer users at arkor login --anonymous and hide the real recovery (--oauth). - main.ts: probe now returns the tri-state OauthAvailability ("available" | "absent" | "unknown"), and the call site translates to oauthAvailable: true | false | undefined for the formatter so "absent" (cfg fetched, no Auth0) and "unknown" (cfg fetch failed) no longer collide. - anonymous-auth-error.ts: split the oauthAvailable === false branch from the new oauthAvailable === undefined branch. The latter hedges with both commands and an explicit "Couldn't reach the deployment to confirm whether OAuth is offered" header so users aren't told the deployment is anon-only when we couldn't actually verify. The OAuth-confirmed and absent branches keep their previous output. - main.test.ts + anonymous-auth-error.test.ts: rename the previous "treats undefined as anon-only" cases to "hedges with both commands when probe is inconclusive", add the same coverage for anonymous_account_not_found, and assert the new wording does NOT claim the deployment is anon-only. Copilot (PR #100, EN + JA): docs/cli/auth.mdx claimed arkor whoami's stdout stays "machine-readable JSON" and used arkor whoami | jq as the motivating example. The actual stdout is JSON.stringify(user, …) followed by an optional human-readable Orgs: , … line, which isn't a valid JSON stream end-to-end. - docs/cli/auth.mdx + ja: drop the jq example, describe the real shape (JSON head + optional Orgs tail), and warn against piping the full output through a strict JSON parser. - whoami.ts: the inline TTY-gate comment was inheriting the same framing. Updated it to match the doc and to spell out that stdout isn't a strict-JSON stream. --- docs/cli/auth.mdx | 2 +- docs/ja/cli/auth.mdx | 2 +- packages/arkor/src/cli/commands/whoami.ts | 21 ++++-- packages/arkor/src/cli/main.test.ts | 21 ++++-- packages/arkor/src/cli/main.ts | 56 +++++++++------ .../src/core/anonymous-auth-error.test.ts | 31 +++++++-- .../arkor/src/core/anonymous-auth-error.ts | 68 ++++++++++++++----- 7 files changed, 142 insertions(+), 59 deletions(-) diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx index 71ac1484..0cd96ab2 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -49,7 +49,7 @@ Anonymous accounts are intentionally **single-device**: the cloud-api stores a ` 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, so a wrapper redirecting `arkor whoami | jq` (loses `stdout.isTTY`) or a CI runner that treats stderr-on-success as a warning marker (loses `stderr.isTTY`) sees clean output. The note is purely a UX hint; it doesn't gate any behaviour, and `whoami`'s machine-readable JSON shape on stdout never changes. +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` diff --git a/docs/ja/cli/auth.mdx b/docs/ja/cli/auth.mdx index 3f02d161..df814d4e 100644 --- a/docs/ja/cli/auth.mdx +++ b/docs/ja/cli/auth.mdx @@ -49,7 +49,7 @@ arkor login [options] どちらの匿名パスでも、新しい `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 のときに限るので、`arkor whoami | jq`(stdout が TTY でなくなる)や、stderr 出力をすべて警告扱いする CI ランナー(stderr が TTY でなくなる)では note は出ません。あくまで UX のヒントであり、どの挙動もゲートしません。stdout 側の機械可読 JSON は常に同じ形です。 +匿名発行のあらゆる入口で、単一端末の制約も別行の 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` diff --git a/packages/arkor/src/cli/commands/whoami.ts b/packages/arkor/src/cli/commands/whoami.ts index c2b45369..4bb4f948 100644 --- a/packages/arkor/src/cli/commands/whoami.ts +++ b/packages/arkor/src/cli/commands/whoami.ts @@ -92,13 +92,20 @@ export async function runWhoami(): Promise { // surfaces, which already know `oauthAvailable`, do append the // upgrade hint when warranted. // - // TTY gate: emit only when *both* stdout and stderr are interactive. - // Wrappers piping stdout through `jq` (or any pipeline) drop - // `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 to keep stdout - // machine-parseable on the rare host where stdout is a TTY but - // a wrapper still parses it (e.g. `script(1)`). + // 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. diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index d33d9455..380ee3f0 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -283,8 +283,8 @@ describe("main (CLI Commander wiring)", () => { }); it("formats anonymous_token_single_device with --anonymous hint on anon-only deployments", async () => { - // No Auth0 advertised → probeOauthAvailability returns false → - // formatter falls back to the universally-available recovery. + // No Auth0 advertised → probeOauthAvailability returns "absent" → + // formatter confidently recommends the only working recovery. vi.mocked(fetchCliConfig).mockResolvedValueOnce({ auth0Domain: null, clientId: null, @@ -399,10 +399,13 @@ describe("main (CLI Commander wiring)", () => { ); }); - it("treats fetchCliConfig failure as anon-only (probe falls back to false)", async () => { - // Network blip → probeOauthAvailability returns false → users - // get the universally-available recovery rather than a `--oauth` - // hint that might fail on this deployment. + 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"), ); @@ -421,8 +424,12 @@ describe("main (CLI Commander wiring)", () => { 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/); - expect(buf).not.toMatch(/arkor login --oauth/); + // 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); }); }); diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index c86405f4..b193f1a2 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -23,7 +23,7 @@ import { SDK_VERSION } from "../core/version"; import { ui } from "./prompts"; /** - * Resolve `oauthAvailable` for the current deployment so anonymous-auth + * 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 @@ -32,21 +32,28 @@ import { ui } from "./prompts"; * which case probing `defaultArkorCloudApiUrl()` would inspect the * wrong deployment and recommend the opposite recovery path. * - * Best-effort: missing credentials, network failure, malformed cfg, or - * timeout all collapse to `false`, which makes the formatter point at - * `arkor login --anonymous` rather than `--oauth`. That's the only - * recovery that's universally available, so erring on the - * suppression-of-`--oauth` side is safe. + * 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: * - * The probe runs *after* a command has already failed, so blocking the - * recovery hint behind an unbounded HTTP call would compound the - * outage: a degraded `/v1/auth/cli/config` endpoint would leave the - * CLI sitting indefinitely with no message printed. `AbortSignal.timeout` - * caps the probe at 3 s so the user always gets *some* guidance even - * when the cloud-api is sick. + * - `"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 { +async function probeOauthAvailability(): Promise { try { const creds = await readCredentials().catch(() => null); // Only `AnonymousCredentials` carries `arkorCloudApiUrl`; the @@ -60,9 +67,11 @@ async function probeOauthAvailability(): Promise { const cfg = await fetchCliConfig(baseUrl, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); - return Boolean(cfg.auth0Domain && cfg.clientId && cfg.audience); + return cfg.auth0Domain && cfg.clientId && cfg.audience + ? "available" + : "absent"; } catch { - return false; + return "unknown"; } } @@ -214,10 +223,19 @@ export async function main(argv: string[]): Promise { 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. - // Failure collapses to "no OAuth", which steers the formatter at - // the universally-available `arkor login --anonymous` recovery. - const oauthAvailable = await probeOauthAvailability(); - const friendly = formatAnonymousAuthError(err, { oauthAvailable }); + // 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; diff --git a/packages/arkor/src/core/anonymous-auth-error.test.ts b/packages/arkor/src/core/anonymous-auth-error.test.ts index 2b116bfa..af07be23 100644 --- a/packages/arkor/src/core/anonymous-auth-error.test.ts +++ b/packages/arkor/src/core/anonymous-auth-error.test.ts @@ -55,16 +55,24 @@ describe("formatAnonymousAuthError", () => { expect(out!).not.toMatch(/arkor login --oauth/); }); - it("treats `oauthAvailable: undefined` as anon-only (errs on suppression)", () => { - // Same gating contract as ANON_PERSISTENCE_NUDGE in anonymous.ts: - // `undefined` (cfg fetch skipped or failed) is treated like - // `false` so we never dead-end users on `--oauth` we can't - // confirm works. + 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/); - expect(out!).not.toMatch(/arkor login --oauth/); + // 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/); }); }); @@ -89,6 +97,17 @@ describe("formatAnonymousAuthError", () => { 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/); + }); }); }); diff --git a/packages/arkor/src/core/anonymous-auth-error.ts b/packages/arkor/src/core/anonymous-auth-error.ts index b353bb32..ed5eddf6 100644 --- a/packages/arkor/src/core/anonymous-auth-error.ts +++ b/packages/arkor/src/core/anonymous-auth-error.ts @@ -11,14 +11,23 @@ export const ANONYMOUS_ACCOUNT_NOT_FOUND = "anonymous_account_not_found"; export interface FormatAnonymousAuthErrorContext { /** - * Whether OAuth is *confirmed* available on the current deployment. - * Same gating contract as the login/dev surfaces: only a `true` value - * unlocks the `arkor login --oauth` recovery hint. `false` / - * `undefined` (cfg fetch skipped or failed) fall back to the - * `arkor login --anonymous` re-mint path, which is the only recovery - * that works on every supported deployment shape — pointing anon-only - * users at `--oauth` would just send them to a command that fails - * immediately. + * 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; } @@ -43,24 +52,40 @@ export function formatAnonymousAuthError( ctx: FormatAnonymousAuthErrorContext = {}, ): string | null { if (!(err instanceof CloudApiError)) return null; - const oauthLine = - ctx.oauthAvailable === true - ? " arkor login --oauth" - : " arkor login --anonymous"; + // 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. + const unknownTail = [ + "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) { return [ "Anonymous credentials were rejected as single-device.", "Anonymous accounts only work on one machine. Sign up for an account that supports multiple devices:", "", - oauthLine, + " arkor login --oauth", + ].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", ].join("\n"); } 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):", + "Anonymous accounts only work on one machine.", "", - oauthLine, + ...unknownTail, ].join("\n"); } if (err.code === ANONYMOUS_ACCOUNT_NOT_FOUND) { @@ -69,14 +94,21 @@ export function formatAnonymousAuthError( "Your anonymous credentials are no longer valid.", "Sign up to continue:", "", - oauthLine, + " arkor login --oauth", + ].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", ].join("\n"); } return [ "Your anonymous credentials are no longer valid.", - "Mint a new anonymous identity to continue (your previous workspace data cannot be recovered):", "", - oauthLine, + ...unknownTail, ].join("\n"); } return null; From 89adac0745795c9ea04a11d887be50fc76d4909b Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 3 May 2026 19:42:22 +0900 Subject: [PATCH 09/10] review (round 9): trainer fast-fail on dead-end + CI-aware recovery hints Copilot (PR #100): two findings about the anonymous-auth-error helpers. 1. `isAnonymousAuthDeadEnd` was exported but no retry loop consumed it. `createTrainer().wait()` happily reconnected on every failure, including dead-end auth errors where retrying never recovers (`anonymous_token_single_device`, `anonymous_account_not_found`). The reconnect budget would burn through and bury the actionable recovery hint that `cli/main.ts` formats from the same error. - trainer.ts: `handleFailure` now checks `isAnonymousAuthDeadEnd` up front and rethrows immediately on a hit. The error bubbles unchanged to `cli/main.ts`'s top-level handler. - trainer.test.ts: new "fails fast on anonymous-auth dead-end errors without retrying" case asserts exactly one stream-open attempt and that the original CloudApiError is what surfaces. 2. The formatter recommended `arkor login --oauth` even in CI, but `runLogin()` rejects `--oauth` outright when `process.env.CI` is set (PKCE needs a browser callback CI runners can't satisfy). A dead-end in a CI job on an OAuth-supporting deployment was being pointed at a guaranteed failure. - anonymous-auth-error.ts: ctx now carries `inCi?: boolean`, defaulting to `Boolean(process.env.CI)` for callers (cli/main.ts) that don't plumb it. In CI: * the OAuth-confirmed branch points at `arkor login --anonymous` and tells the user where `--oauth` would actually run (a developer machine); * the unknown-state hedge drops the OAuth half entirely, since suggesting it would just send the runner at a dead command. The OAuth-absent branch is unchanged (already `--anonymous`). - anonymous-auth-error.test.ts: beforeEach scrubs CI to keep existing assertions deterministic; new cases cover the OAuth-confirmed and unknown-state CI branches plus the env auto-read. --- .../src/core/anonymous-auth-error.test.ts | 89 ++++++++++++++++++- .../arkor/src/core/anonymous-auth-error.ts | 54 +++++++++-- packages/arkor/src/core/trainer.test.ts | 53 +++++++++++ packages/arkor/src/core/trainer.ts | 10 +++ 4 files changed, 198 insertions(+), 8 deletions(-) diff --git a/packages/arkor/src/core/anonymous-auth-error.test.ts b/packages/arkor/src/core/anonymous-auth-error.test.ts index af07be23..1c49fc1b 100644 --- a/packages/arkor/src/core/anonymous-auth-error.test.ts +++ b/packages/arkor/src/core/anonymous-auth-error.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ANONYMOUS_ACCOUNT_NOT_FOUND, ANONYMOUS_TOKEN_SINGLE_DEVICE, @@ -7,6 +7,20 @@ import { } 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(); @@ -74,6 +88,65 @@ describe("formatAnonymousAuthError", () => { // 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", () => { @@ -108,6 +181,20 @@ describe("formatAnonymousAuthError", () => { 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"]); + }); }); }); diff --git a/packages/arkor/src/core/anonymous-auth-error.ts b/packages/arkor/src/core/anonymous-auth-error.ts index ed5eddf6..b6d0966c 100644 --- a/packages/arkor/src/core/anonymous-auth-error.ts +++ b/packages/arkor/src/core/anonymous-auth-error.ts @@ -30,6 +30,20 @@ export interface FormatAnonymousAuthErrorContext { * 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; } /** @@ -52,20 +66,38 @@ export function formatAnonymousAuthError( 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. - const unknownTail = [ - "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", - ]; + // 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", + ].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:", @@ -90,6 +122,14 @@ export function formatAnonymousAuthError( } 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", + ].join("\n"); + } return [ "Your anonymous credentials are no longer valid.", "Sign up to continue:", 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 From 4ecce1e684061c3615c4722d124b7a5996e0246f Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Tue, 5 May 2026 20:45:46 +0900 Subject: [PATCH 10/10] review (round 9): tell users to reset .arkor/state.json after re-auth Copilot (PR #100, 5 hits): re-issuing credentials alone is not enough to resume work in an existing project directory. `ensureProjectState()` in `packages/arkor/src/core/projectState.ts` reads `.arkor/state.json` and reuses it unchanged when present, so after either path of recovery (`arkor login --anonymous` re-mint or `arkor login --oauth` switch) commands in the same directory keep targeting the previous identity's `(orgSlug, projectSlug)`. Without the state-reset step the recovery appears not to take effect: the user gets repeat 401s, or worse, silently writes into a workspace they cannot reach. - anonymous-auth-error.ts: introduce a STATE_RESET_NOTE constant and append it to every recovery branch (single-device + account-not-found, for `oauthAvailable` true / false / undefined / inCi). The note tells users to delete `.arkor/state.json` before resuming, or to run `arkor init` for an OAuth account. - anonymous-auth-error.test.ts: add `expect(out!).toMatch(/.arkor/state.json/)` assertions on the OAuth-confirmed and OAuth-absent branches so a future change cannot silently strip the note. - README.md: spell out that switching to OAuth does not auto-route subsequent runs in an existing project directory; users must delete `.arkor/state.json` (or run `arkor init` for the new account) before the new identity takes effect. Also drops the leftover em-dash on this paragraph. - README.ja.md: JP mirror of the same correction. - packages/arkor/README.md: add a separate paragraph describing the state-reset step so the package README is consistent with the root README. Replaces the em-dash on "Either path is a *new* identity" with a period. - packages/cli-internal/src/templates.ts: same fix in the scaffolded README, including a copy-pasteable `rm -rf .arkor/state.json && npx arkor init` recovery hint. --- README.ja.md | 2 +- README.md | 2 +- packages/arkor/README.md | 12 ++++++-- .../src/core/anonymous-auth-error.test.ts | 14 +++++++-- .../arkor/src/core/anonymous-auth-error.ts | 29 +++++++++++++++++++ packages/cli-internal/src/templates.ts | 13 +++++++-- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/README.ja.md b/README.ja.md index c1c14494..c7a3fcd5 100644 --- a/README.ja.md +++ b/README.ja.md @@ -52,7 +52,7 @@ pnpm dev **サインアップ不要:** `arkor dev` は **Studio** と呼ばれるローカル Web UI を `http://localhost:4000` で開きます。初回起動時に使い捨ての匿名ワークスペースをプロビジョニングするので、すぐに実際のトレーニング実行を開始できます。 -匿名ワークスペースはこのマシーンに紐づきます。クラウド API はサーバー側で単一端末ガードを強制しているので、`~/.arkor/credentials.json` を別端末にコピーする運用は想定外です。各匿名ワークスペースは発行されたマシーンで使う前提です。今のところ CLI は匿名トークンを自動 refresh しないので、コピーがしばらく動いてしまうことはあります。発行ユーザーがもう一度 `arkor login --anonymous` を実行する(または自動 refresh が入って保存中の jti が進む)と、そのタイミングで他の全コピーは次回呼び出しから単一端末エラーで失敗するようになります。OAuth に切り替えても既存の匿名ワークスペースは引き継げません。`arkor login --oauth` は `~/.arkor/credentials.json` を新しい OAuth identity で上書きし、それ以降の作業は OAuth アカウントに紐付きますが、既存の匿名ジョブや org は発行元の credentials ファイルからしか辿れません。複数端末で作業したくなった時点で `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` を実行し、アカウント付きの新しいワークスペースで再スタートしてください。 ### テンプレートを選ぶ diff --git a/README.md b/README.md index ecf3376b..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. -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 that any *future* work will be associated with, but existing anonymous jobs and orgs stay reachable only from the credentials file that issued them. Run `arkor login --oauth` whenever you're ready to start an account-backed workspace that follows you across devices. +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 diff --git a/packages/arkor/README.md b/packages/arkor/README.md index 634ead84..1bb08fd2 100644 --- a/packages/arkor/README.md +++ b/packages/arkor/README.md @@ -113,8 +113,16 @@ fresh login (or auto-refresh ships and rotates the stored jti), every 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 — there is no migration of the existing -anonymous workspace today. +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 diff --git a/packages/arkor/src/core/anonymous-auth-error.test.ts b/packages/arkor/src/core/anonymous-auth-error.test.ts index 1c49fc1b..55722a75 100644 --- a/packages/arkor/src/core/anonymous-auth-error.test.ts +++ b/packages/arkor/src/core/anonymous-auth-error.test.ts @@ -48,10 +48,19 @@ describe("formatAnonymousAuthError", () => { ); expect(out).not.toBeNull(); expect(out!).toMatch(/single-device/); - // Must direct at the OAuth flow specifically — `arkor login` + // 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. + // 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", () => { @@ -67,6 +76,7 @@ describe("formatAnonymousAuthError", () => { 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)", () => { diff --git a/packages/arkor/src/core/anonymous-auth-error.ts b/packages/arkor/src/core/anonymous-auth-error.ts index b6d0966c..8321a124 100644 --- a/packages/arkor/src/core/anonymous-auth-error.ts +++ b/packages/arkor/src/core/anonymous-auth-error.ts @@ -60,7 +60,20 @@ export interface FormatAnonymousAuthErrorContext { * - `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 = {}, @@ -96,6 +109,8 @@ export function formatAnonymousAuthError( "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 [ @@ -103,6 +118,8 @@ export function formatAnonymousAuthError( "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) { @@ -111,6 +128,8 @@ export function formatAnonymousAuthError( "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 [ @@ -118,6 +137,8 @@ export function formatAnonymousAuthError( "Anonymous accounts only work on one machine.", "", ...unknownTail, + "", + STATE_RESET_NOTE, ].join("\n"); } if (err.code === ANONYMOUS_ACCOUNT_NOT_FOUND) { @@ -128,6 +149,8 @@ export function formatAnonymousAuthError( "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 [ @@ -135,6 +158,8 @@ export function formatAnonymousAuthError( "Sign up to continue:", "", " arkor login --oauth", + "", + STATE_RESET_NOTE, ].join("\n"); } if (ctx.oauthAvailable === false) { @@ -143,12 +168,16 @@ export function formatAnonymousAuthError( "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; diff --git a/packages/cli-internal/src/templates.ts b/packages/cli-internal/src/templates.ts index 1395c61a..4c69781a 100644 --- a/packages/cli-internal/src/templates.ts +++ b/packages/cli-internal/src/templates.ts @@ -140,7 +140,7 @@ npm install && npm run dev 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 +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 @@ -151,10 +151,17 @@ devices, run: npx arkor login --oauth \`\`\` -The OAuth flow starts a *new* identity — existing anonymous work cannot -be migrated, so future work created after sign-in is what ends up under +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): \`\`\`