From 6db4e87a534e1ec09f95c84819eb3c436bf7fcc6 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:59:02 -0700 Subject: [PATCH 01/19] feat(ENG-967): add arkor dev --agent mode for coding agents Follow-up to PR #141 (CLAUDECODE strict mode for the scaffolders): let a coding agent operate Arkor over direct HTTP without the Studio browser UI. - New --agent flag on arkor dev. The same Studio server runs headlessly; the per-launch CSRF token is additionally written as JSON ({ token, url, port, pid }) to a unique per-session file at /.arkor/agent/session--.json (dir 0700, file 0600, atomic temp+rename), and its path is printed to stdout as a stable greppable line. Unlike the home token this write is fail-hard: the session file is the agent's only token channel. The same shutdown handlers unlink it (no ownership check needed; the name is unique). - New GET /api/status endpoint (token-guarded, secrets-free, never runs user code): status, server discriminator, version, mode, url, pid, cwd, and the endpoint list, so an agent can confirm the server and discover the API surface. - Strict gate: under CLAUDECODE=1 a plain arkor dev exits 1 via the ClaudeCodeStrictExit sentinel asking for --agent. The flag itself needs no env var, so other coding agents opt in the same way. - Port collision: normal mode now probes the occupant via /api/status with the home token and, on confirming a running Arkor Studio (often an agent session), prints the URL, honors --open, and exits 0 instead of EADDRINUSE-failing. Agent mode keeps the hard error. - Both e2e harnesses strip CLAUDECODE from spawned children so the gate cannot leak into tests from a Claude Code session. - Tests: cli-internal formatter, /api/status, dev.ts session file + probe paths, main.ts gate wiring, e2e/cli run-to-exit gate cases, and a new e2e/studio agent.spec.ts against real processes. - Docs: EN+JA dev reference (agent mode, strict mode, port-collision rewrite, errors, examples), guides, CLI overview, and the AGENTS.md security section. --- AGENTS.md | 1 + docs/cli/dev.mdx | 79 ++++- docs/cli/overview.mdx | 4 +- docs/guides/cli/dev.mdx | 4 + docs/ja/cli/dev.mdx | 79 ++++- docs/ja/cli/overview.mdx | 4 +- docs/ja/guides/cli/dev.mdx | 4 + e2e/cli/src/arkor-dev.test.ts | 54 ++++ e2e/studio/src/harness/fixture.ts | 15 + e2e/studio/src/harness/studioServer.ts | 70 ++++- e2e/studio/src/specs/agent.spec.ts | 156 ++++++++++ packages/arkor/src/cli/commands/dev.test.ts | 293 ++++++++++++++++++ packages/arkor/src/cli/commands/dev.ts | 215 ++++++++++++- packages/arkor/src/cli/main.test.ts | 63 +++- packages/arkor/src/cli/main.ts | 18 +- packages/arkor/src/studio/server.test.ts | 87 ++++++ packages/arkor/src/studio/server.ts | 50 +++ packages/cli-internal/src/claude-code.test.ts | 21 ++ packages/cli-internal/src/claude-code.ts | 22 ++ packages/cli-internal/src/index.ts | 1 + 20 files changed, 1207 insertions(+), 33 deletions(-) create mode 100644 e2e/cli/src/arkor-dev.test.ts create mode 100644 e2e/studio/src/specs/agent.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 01635ce7..2a7707e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ cd my-arkor-app && pnpm dev # Studio at http://127.0. 1. Passes it to `buildStudioApp({ studioToken })`. The Hono server validates every `/api/*` request via `X-Arkor-Studio-Token` header (or `?studioToken=` query for `EventSource`, which can't set headers). Comparison uses `timingSafeEqual`. 2. Persists it to `~/.arkor/studio-token` (mode 0600) so the SPA dev workflow (`pnpm --filter @arkor/studio-app dev`) can read it via the `arkor-studio-token` Vite plugin in [packages/studio-app/vite.config.ts](packages/studio-app/vite.config.ts), which injects `` into `index.html` on each request. Persistence failure must NOT block server start (read-only `$HOME` on Docker, etc.); just warn. 3. Cleans up on `exit`/SIGINT/SIGTERM/SIGHUP via `unlinkSync`, after verifying the file still holds this instance's token (a second `arkor dev` on another port may have overwritten it; last writer wins and the previous owner must not delete it). Signal handlers exit with the conventional `128 + signal` code (SIGINT -> 130, SIGTERM -> 143, SIGHUP -> 129) so supervisors can distinguish signal termination from a clean exit. +4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (dir 0700, file 0600, atomic temp+rename) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel); the same shutdown handlers unlink it WITHOUT a content check (unique name = unambiguous ownership). Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. `GET /api/status` is the token-guarded, secrets-free probe (`server: "arkor-studio"` discriminator, never executes user code); normal-mode `arkor dev` on a busy port probes it with the home token and, on confirmation, prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. `/api/*` middleware also enforces a host-header allow-list (`127.0.0.1`/`localhost`) for DNS-rebinding defence. **CORS is intentionally NOT configured**: the SPA is same-origin so reflecting `*` would let "simple" cross-origin POSTs reach handlers. The token check rejects those; cross-origin tabs cannot read the SPA's ``. diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index b3172940..ce644d60 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -41,6 +41,7 @@ bun dev | --- | --- | --- | | `-p, --port ` | `4000` | Port to bind. The displayed URL uses `localhost`, but the listener binds `127.0.0.1` directly so it cannot end up IPv6-only on hosts where `/etc/hosts` lists `::1` before `127.0.0.1`. The CLI parses the value as `Number(opts.port) || 4000`, so falsy results (`0`, non-numeric) normalize to `4000`. **Truthy invalid values pass through unsanitized**: a negative port or a port above `65535` reaches the listener as-is and surfaces as a `serve()` failure (e.g. `RangeError`). Stick to the standard 1 to 65535 range yourself. | | `--open` | off | Open the Studio URL in a browser after the server is up. | +| `--agent` | off | Agent mode: run the same Studio server headlessly for coding agents. Writes a per-session JSON token file under `.arkor/agent/` in the project and prints its path to stdout (see "Agent mode" below). Never opens a browser on its own; `--open` still works if you want one. | ## Behavior @@ -53,6 +54,43 @@ bun dev When the process exits (normal exit, `SIGINT`, `SIGTERM`, or `SIGHUP`) the studio-token file is removed on a best-effort basis. A crash can leave the file on disk; the next `arkor dev` rotates it. +### Agent mode (`--agent`) + +`arkor dev --agent` runs the exact same Studio server headlessly for coding agents (Claude Code and others) that operate Arkor through direct HTTP calls instead of the browser UI. The flag is a plain opt-in: no environment variable is required. + +Everything in the launch sequence above happens unchanged, plus: + +1. **Project-local session file.** The per-launch CSRF token is written to a unique JSON file inside the project: `/.arkor/agent/session--.json`. The `.arkor/agent/` directory is created with mode `0700` and the file with mode `0600`; `.arkor/` is already gitignored by the scaffolder. The content is `{ "token": "", "url": "http://localhost:", "port": , "pid": }`. Unlike the home token, a failure to write this file **aborts startup** with exit code 1: the session file is the agent's only token channel, so a server without it would be silently unusable. +2. **stdout contract.** Three lines, in this order; the prefixes of the first two are stable and safe to grep: + + ``` + Arkor Studio running on http://localhost: + Arkor Studio agent session file: + Read the token from that file and send it as the X-Arkor-Studio-Token header on /api/* requests. + ``` + +3. **Status probe.** `GET /api/status` (token required, like every `/api/*` route) returns safe metadata only: `status`, `server: "arkor-studio"`, `version`, `mode` (`"agent"` here), `url`, `pid`, `cwd`, and the list of available `endpoints`. It never includes credentials or the CSRF token and never executes project code, so it is the right first call to confirm the server and discover the API surface. + +The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP`. A crash (`SIGKILL`, power loss) can leave `session-*.json` files behind; they are inert (the token died with the server) and safe to delete. + +The Studio SPA is still served in agent mode: a human can open the printed URL in a browser while the agent works, and every guard from "Loopback and CSRF model" below applies unchanged. + +### Claude Code (`CLAUDECODE=1`) strict mode + +Claude Code sets `CLAUDECODE=1` in every spawned shell. Under it, a plain `arkor dev` refuses to start: the long-running browser-oriented server would hang the agent's shell without giving it a way to call the API. The CLI prints the block below to stderr and exits `1` before binding a port or writing any file: + +``` +arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled. +Re-run with the --agent flag: + --agent + Runs the same Studio server headlessly and writes a JSON session + file ({ token, url, port, pid }) under .arkor/agent/ in the + project. Read the token from that file and send it as the + X-Arkor-Studio-Token header on /api/* requests. +``` + +`--agent` works without the environment variable, so other coding agents opt in with the same flag. The scaffolders follow the same convention: see [`arkor init` strict mode](/cli/init#claude-code-claudecode=1-strict-mode). + ### Loopback and CSRF model The Studio server enforces three checks on every `/api/*` request: @@ -65,7 +103,10 @@ This means `arkor dev` is safe on a shared dev machine: another tab cannot read ### Port collision -`arkor dev` does not auto-pick a free port. If the chosen port is already taken (another `arkor dev` left running, an unrelated dev server, etc.), the listener's `EADDRINUSE` is caught and `arkor dev` exits non-zero with ``Port is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` Pick a different port with `-p `, or stop whatever else is bound to it. +`arkor dev` does not auto-pick a free port. What happens on a busy port depends on the mode: + +- **Normal mode connects to a running Studio.** Before failing, the CLI probes the occupant: it reads `~/.arkor/studio-token` and calls `GET /api/status` with it (1.5 s timeout). If the response confirms an Arkor Studio (`server: "arkor-studio"`), the CLI prints `Arkor Studio already running on http://localhost:`, honors `--open` against that URL, and exits `0` without starting a second server. The typical case: an `arkor dev --agent` session owns the port and you want the browser UI; the running server already serves the SPA, so connecting is enough. If the probe fails for any reason (no token file, stale token, timeout, or the occupant is not a Studio), the CLI exits non-zero with ``Port is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` +- **Agent mode always errors.** `arkor dev --agent` on a busy port fails with the same port-in-use message without probing: an agent session needs its own server process and its own session file, so adopting an existing instance would hand the agent a token it does not control. Pass `--port` to start a second session. The token file (`~/.arkor/studio-token`) is written only **after** the port binds successfully, so a failed second launch on a busy port never overwrites or deletes the token a healthy first instance depends on. @@ -73,7 +114,10 @@ The token file (`~/.arkor/studio-token`) is written only **after** the port bind | Symptom | What it means | Fix | | --- | --- | --- | -| ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` | Another process holds the port (the underlying error is `EADDRINUSE`). | Stop it, or use `--port `. | +| ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` | Another process holds the port (the underlying error is `EADDRINUSE`) and, in normal mode, the running-Studio probe did not confirm an Arkor Studio. In agent mode this fires without probing. | Stop it, or use `--port `. | +| `Arkor Studio already running on http://localhost:` (exit `0`) | Normal mode found a healthy Arkor Studio on the port (often an `arkor dev --agent` session) and connected to it instead of failing. Informational, not an error. | Nothing required. Open the printed URL; pass `--open` to have the CLI do it. | +| `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.` (exit `1`) | The Claude Code strict gate: `CLAUDECODE=1` is set and `--agent` was not passed. Nothing was started and nothing was written. | Re-run as `arkor dev --agent` and use the printed session file. | +| `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.` (exit `1`) | Agent mode could not create `.arkor/agent/` or the session file (read-only project dir, permission problem). Unlike the home token this is fatal: the file is the agent's only token channel. | Make the project directory writable (check `.arkor/` permissions) and re-run. | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.` | `/v1/auth/cli/config` already succeeded, but the follow-up `/v1/auth/anonymous` hit a transport error. The Studio server starts and will retry. | Bring connectivity back; the SPA recovers on its next `/api/credentials` poll without restarting `arkor dev`. | | `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. | @@ -131,8 +175,39 @@ bun dev --port 5000 --open +Agent mode for a coding agent: + + + +```bash pnpm +pnpm dev --agent +``` + +```bash npm +npm run dev -- --agent +``` + +```bash yarn +yarn dev --agent +``` + +```bash bun +bun dev --agent +``` + + + +Then, from the agent's shell, read the token out of the session file and confirm the server. Prefer the exact path printed on stdout; the glob below works for a single-session project: + +```bash +SESSION_FILE=$(ls .arkor/agent/session-*.json | head -n 1) +TOKEN=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token' "$SESSION_FILE") +curl -H "X-Arkor-Studio-Token: $TOKEN" http://localhost:4000/api/status +``` + ## See also - [Studio concept](/concepts/studio) for what the UI actually does - [`arkor login`](/cli/auth) for upgrading from an anonymous session to OAuth - [Project structure](/concepts/project-structure) for the `~/.arkor/` and `.arkor/` layout +- [`arkor init` strict mode](/cli/init#claude-code-claudecode=1-strict-mode) for the same `CLAUDECODE=1` convention on the scaffolder diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 2e8999b4..d6ff5269 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -10,7 +10,7 @@ The `arkor` CLI is the command surface around an Arkor project. The [scaffolder] | Command | What it does | | --- | --- | | [`arkor init`](/cli/init) | Scaffold `src/arkor/index.ts` + `arkor.config.ts` in the current directory. | -| [`arkor dev`](/cli/dev) | Launch [Studio](/concepts/studio) on `http://localhost:4000` (configurable). | +| [`arkor dev`](/cli/dev) | Launch [Studio](/concepts/studio) on `http://localhost:4000` (configurable). `--agent` runs it headlessly for coding agents. | | [`arkor build`](/cli/build-and-start) | Bundle `src/arkor/index.ts` to `.arkor/build/index.mjs`. | | [`arkor start`](/cli/build-and-start) | Run the build artifact (auto-builds when missing). | | [`arkor login`](/cli/auth) | Sign in. With no flags, prompts interactively (default selection: `Anonymous`). Pass `--oauth` for the loopback Arkor Cloud OAuth (PKCE) flow or `--anonymous` to skip the picker. | @@ -33,7 +33,7 @@ The `arkor` CLI is the command surface around an Arkor project. The [scaffolder] | `ARKOR_TELEMETRY_DISABLED=1` | Same as `DO_NOT_TRACK`. Provided as an Arkor-specific alias for setups that already gate other tools on `DO_NOT_TRACK` and want a separate switch. | | `ARKOR_TELEMETRY_DEBUG=1` | Enable verbose internal logging from the telemetry module (init failures, identity-file read/write errors, capture failures, shutdown errors). Telemetry **is still sent** when this flag is on; this is a diagnostic aid, not a dry-run switch. To stop sending events, use `DO_NOT_TRACK=1` or `ARKOR_TELEMETRY_DISABLED=1`. | | `CI=1` (or any non-TTY stdout) | Force non-interactive mode. `arkor init` skips its prompts and uses the values you passed via flags (or built-in defaults under `--yes`); git init runs without asking when `--git` or `--yes` is set, and is skipped silently otherwise. See [`arkor init` § CI / non-interactive shells](/cli/init#ci-/-non-interactive-shells). | -| `CLAUDECODE=1` | Strict mode for AI agents that spawn the CLI without being able to answer interactive prompts. `arkor init` (and `create-arkor`) refuse to run unless every curated flag is present, printing a multi-line stderr block with the suggested re-invocation; pass `-y`/`--yes` to opt back into "accept all defaults". See [`arkor init` § Claude Code (`CLAUDECODE=1`) strict mode](/cli/init#claude-code-claudecode=1-strict-mode). | +| `CLAUDECODE=1` | Strict mode for AI agents that spawn the CLI without being able to answer interactive prompts. `arkor init` (and `create-arkor`) refuse to run unless every curated flag is present, printing a multi-line stderr block with the suggested re-invocation; pass `-y`/`--yes` to opt back into "accept all defaults". See [`arkor init` § Claude Code (`CLAUDECODE=1`) strict mode](/cli/init#claude-code-claudecode=1-strict-mode). `arkor dev` also refuses to start under it without `--agent`; see [`arkor dev` § Claude Code (`CLAUDECODE=1`) strict mode](/cli/dev#claude-code-claudecode=1-strict-mode). | ### On the roadmap diff --git a/docs/guides/cli/dev.mdx b/docs/guides/cli/dev.mdx index 9e67f309..b601559b 100644 --- a/docs/guides/cli/dev.mdx +++ b/docs/guides/cli/dev.mdx @@ -59,6 +59,10 @@ bun dev --port 5000 Add `--open` to open the browser automatically. +## Working with a coding agent? + +`arkor dev --agent` runs the same server headlessly for coding agents (Claude Code and others): it writes a per-session JSON token file to `.arkor/agent/` inside the project, prints its path to stdout, and the agent drives Arkor through the `/api/*` endpoints directly (start with `GET /api/status`). Under `CLAUDECODE=1`, a plain `arkor dev` refuses to start and asks for `--agent` explicitly. The Studio UI stays available in a browser the whole time, and a second plain `arkor dev` on the same port simply connects to the running instance and exits. Details in the "Agent mode" section of the [`arkor dev` reference](/cli/dev). + ## Reference For the full flag list, the loopback / CSRF security model, and the troubleshooting catalog (port collisions, anonymous bootstrap, token expiry, 403 reasons), see the [`arkor dev` reference](/cli/dev). diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 1b8a2c68..af26604c 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -41,6 +41,7 @@ bun dev | --- | --- | --- | | `-p, --port ` | `4000` | バインドするポート。表示 URL は `localhost` ですが、リスナーは `127.0.0.1` を直接バインドします。これにより `/etc/hosts` で `::1` が `127.0.0.1` より先に並ぶホストでも IPv6 専用に終わりません。CLI は値を `Number(opts.port) || 4000` でパースするので、falsy な結果(`0`、非数)は `4000` に正規化されます。**truthy な不正値はそのまま透過します**。負のポートや `65535` を超えるポートはリスナーにそのまま到達し `serve()` の失敗(例: `RangeError`)として現れます。1 から 65535 の標準範囲を自分で守ってください。 | | `--open` | off | サーバー起動後にブラウザーで Studio URL を開く。 | +| `--agent` | off | エージェントモード。同じ Studio サーバーをコーディングエージェント向けにヘッドレスで起動する。プロジェクト内の `.arkor/agent/` にセッションごとの JSON トークンファイルを書き、そのパスを stdout に出力する(下の「エージェントモード」参照)。単体でブラウザーを開くことはなく、開きたい場合は従来どおり `--open` を併用する。 | ## 振る舞い @@ -53,6 +54,43 @@ bun dev プロセス終了時(通常終了、`SIGINT`、`SIGTERM`、`SIGHUP`)に studio-token ファイルはベストエフォートで削除されます。クラッシュするとファイルがディスク上に残ることがあり、その場合は次回 `arkor dev` がローテートします。 +### エージェントモード(`--agent`) + +`arkor dev --agent` は、ブラウザー UI ではなく HTTP を直接叩いて Arkor を操作するコーディングエージェント(Claude Code など)向けに、まったく同じ Studio サーバーをヘッドレスで起動します。フラグは純粋なオプトインで、環境変数は不要です。 + +上記の起動シーケンスはすべてそのまま実行され、加えて次が行われます。 + +1. **プロジェクト内セッションファイル。** この起動用の CSRF トークンを、プロジェクト内の固有な JSON ファイル `/.arkor/agent/session--.json` に書き込みます。`.arkor/agent/` ディレクトリーはモード `0700`、ファイルはモード `0600` で作成されます。`.arkor/` は scaffold の時点で gitignore 済みです。内容は `{ "token": "", "url": "http://localhost:", "port": , "pid": }` です。home のトークンと異なり、このファイルの書き込みに失敗すると **起動は中止され** exit code 1 になります。セッションファイルはエージェント唯一のトークン受け渡し経路であり、これが無いサーバーは呼び出し元から静かに使用不能になるためです。 +2. **stdout の契約。** 次の 3 行がこの順で出力されます。先頭 2 行のプレフィックスは安定しており grep して安全です。 + + ``` + Arkor Studio running on http://localhost: + Arkor Studio agent session file: <絶対パス> + Read the token from that file and send it as the X-Arkor-Studio-Token header on /api/* requests. + ``` + +3. **ステータスプローブ。** `GET /api/status`(他の `/api/*` ルートと同様にトークン必須)は安全なメタデータのみを返します。`status`、`server: "arkor-studio"`、`version`、`mode`(ここでは `"agent"`)、`url`、`pid`、`cwd`、利用可能な `endpoints` の一覧です。認証情報や CSRF トークンを含むことはなく、プロジェクトのコードを実行することもないため、サーバーの確認と API サーフェスの発見に最初に呼ぶべきエンドポイントです。 + +セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます。クラッシュ(`SIGKILL`、電源断)では `session-*.json` が残ることがありますが、トークンはサーバーとともに死んでいるため無害で、削除して安全です。 + +エージェントモードでも Studio SPA は引き続き配信されます。エージェントが作業している間、人間は出力された URL をブラウザーで開けますし、下の「ループバックと CSRF モデル」のガードもすべてそのまま適用されます。 + +### Claude Code(`CLAUDECODE=1`)厳格モード + +Claude Code はすべてのシェルに `CLAUDECODE=1` を設定します。この環境で素の `arkor dev` は起動を拒否します。ブラウザー前提の常駐サーバーは、API を叩く手段をエージェントに与えないままシェルをハングさせてしまうためです。CLI はポートのバインドやファイル書き込みの前に次のブロックを stderr に出力し、exit code `1` で終了します。 + +``` +arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled. +Re-run with the --agent flag: + --agent + Runs the same Studio server headlessly and writes a JSON session + file ({ token, url, port, pid }) under .arkor/agent/ in the + project. Read the token from that file and send it as the + X-Arkor-Studio-Token header on /api/* requests. +``` + +`--agent` は環境変数なしでも動作するので、Claude Code 以外のコーディングエージェントも同じフラグでオプトインできます。scaffolder も同じ規約に従います。[`arkor init` の厳格モード](/ja/cli/init#claude-code(claudecode=1)厳格モード)を参照してください。 + ### ループバックと CSRF モデル Studio サーバーはすべての `/api/*` リクエストに 3 つのチェックを課します。 @@ -65,7 +103,10 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ ### ポート競合 -`arkor dev` は空きポートの自動採用はしません。指定ポートが既に使われている(前回の `arkor dev` が残っている、無関係な dev サーバー、など)と、リスナーの `EADDRINUSE` は捕捉され、`arkor dev` は ``Port is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート `` は既に使用中です。別の `arkor dev` が動いているかもしれません。`--port` で別のポートを選んでください)という明確なメッセージとともに非ゼロで終了します。`-p ` で別のポートを選ぶか、占有しているプロセスを止めてください。 +`arkor dev` は空きポートの自動採用はしません。使用中ポートでの挙動はモードで異なります。 + +- **通常モードは起動中の Studio に接続します。** 失敗させる前に、CLI は占有者をプローブします。`~/.arkor/studio-token` を読み、それを付けて `GET /api/status` を呼びます(タイムアウト 1.5 秒)。レスポンスが Arkor Studio であること(`server: "arkor-studio"`)を確認できたら、CLI は `Arkor Studio already running on http://localhost:` を出力し、その URL に対して `--open` を尊重し、2 つ目のサーバーを起動せずに exit code `0` で終了します。典型例は `arkor dev --agent` セッションがポートを保持していて、人間がブラウザー UI を使いたい場合です。起動中のサーバーは SPA も配信しているので、接続するだけで十分です。プローブが何らかの理由で失敗した場合(トークンファイルなし、古いトークン、タイムアウト、占有者が Studio ではない)は、従来どおり ``Port is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート `` は既に使用中です。別の `arkor dev` が動いているかもしれません。`--port` で別のポートを選んでください)で非ゼロ終了します。 +- **エージェントモードは常にエラーです。** `arkor dev --agent` は使用中ポートに対してプローブせず、同じ port-in-use メッセージで失敗します。エージェントセッションには固有のサーバープロセスと固有のセッションファイルが必要で、既存インスタンスに相乗りするとエージェントが管理していないトークンを渡すことになるためです。`--port` で 2 つ目のセッションを起動してください。 トークンファイル(`~/.arkor/studio-token`)はポートのバインド成功 **後** にのみ書き込まれるため、使用中ポートへの 2 つ目の起動が失敗しても、正常に動いている 1 つ目のインスタンスが依存するトークンを上書き・削除することはありません。 @@ -73,7 +114,10 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | 症状 | 意味 | 対処 | | --- | --- | --- | -| ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート 4000 は既に使用中。根本のエラーは `EADDRINUSE`) | 別プロセスがポートを保持。 | 止めるか `--port `。 | +| ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート 4000 は既に使用中。根本のエラーは `EADDRINUSE`) | 別プロセスがポートを保持していて、通常モードでは起動中 Studio プローブが Arkor Studio を確認できなかった。エージェントモードではプローブなしでこのエラーになる。 | 止めるか `--port `。 | +| `Arkor Studio already running on http://localhost:`(exit `0`) | 通常モードがポート上で健全な Arkor Studio(多くは `arkor dev --agent` セッション)を発見し、失敗ではなく接続扱いにした。エラーではなく情報行。 | 何もしなくてよい。出力された URL を開く。CLI に開かせたいなら `--open`。 | +| `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.`(exit `1`) | Claude Code の厳格ゲート。`CLAUDECODE=1` が設定されていて `--agent` が渡されていない。何も起動されず、何も書き込まれていない。 | `arkor dev --agent` で再実行し、出力されたセッションファイルを使う。 | +| `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.`(exit `1`) | エージェントモードが `.arkor/agent/` またはセッションファイルを作成できなかった(読み取り専用のプロジェクトディレクトリー、権限の問題)。home のトークンと異なりこれは致命的で、ファイルはエージェント唯一のトークン経路。 | プロジェクトディレクトリーを書き込み可能にして(`.arkor/` の権限を確認)再実行。 | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.`(`` に到達できませんでした(fetch 失敗)。Studio は起動を続け、初回の `/api/credentials` 受信時に再試行します) | `/v1/auth/cli/config` は成功したが、続く `/v1/auth/anonymous` がトランスポート障害で失敗。Studio サーバーは起動して再試行する。 | 接続を回復させれば `arkor dev` を再起動せずに次の `/api/credentials` ポーリングで SPA が回復。 | | `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 をリロード。 | @@ -131,8 +175,39 @@ bun dev --port 5000 --open +コーディングエージェント向けのエージェントモード。 + + + +```bash pnpm +pnpm dev --agent +``` + +```bash npm +npm run dev -- --agent +``` + +```bash yarn +yarn dev --agent +``` + +```bash bun +bun dev --agent +``` + + + +続いてエージェントのシェルから、セッションファイルのトークンを読んでサーバーを確認します。stdout に出力された正確なパスを使うのが第一候補で、下の glob はセッションが 1 つだけのプロジェクトで機能します。 + +```bash +SESSION_FILE=$(ls .arkor/agent/session-*.json | head -n 1) +TOKEN=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token' "$SESSION_FILE") +curl -H "X-Arkor-Studio-Token: $TOKEN" http://localhost:4000/api/status +``` + ## 関連項目 - [Studio コンセプト](/ja/concepts/studio): UI で実際に何が起きるか - [`arkor login`](/ja/cli/auth): 匿名セッションを OAuth にアップグレード - [プロジェクト構成](/ja/concepts/project-structure): `~/.arkor/` と `.arkor/` のレイアウト +- [`arkor init` の厳格モード](/ja/cli/init#claude-code(claudecode=1)厳格モード): scaffolder 側の同じ `CLAUDECODE=1` 規約 diff --git a/docs/ja/cli/overview.mdx b/docs/ja/cli/overview.mdx index 3097a4e1..5b63cc9e 100644 --- a/docs/ja/cli/overview.mdx +++ b/docs/ja/cli/overview.mdx @@ -10,7 +10,7 @@ description: "arkor CLI: 全コマンドリファレンス。" | コマンド | 役割 | | --- | --- | | [`arkor init`](/ja/cli/init) | カレントディレクトリーに `src/arkor/index.ts` + `arkor.config.ts` を生成。 | -| [`arkor dev`](/ja/cli/dev) | [Studio](/ja/concepts/studio) を `http://localhost:4000` で起動(設定可)。 | +| [`arkor dev`](/ja/cli/dev) | [Studio](/ja/concepts/studio) を `http://localhost:4000` で起動(設定可)。`--agent` でコーディングエージェント向けにヘッドレス起動。 | | [`arkor build`](/ja/cli/build-and-start) | `src/arkor/index.ts` を `.arkor/build/index.mjs` にバンドル。 | | [`arkor start`](/ja/cli/build-and-start) | ビルド成果物を実行(無いときは自動ビルド)。 | | [`arkor login`](/ja/cli/auth) | サインイン。フラグなしでは対話ピッカー(初期選択: `Anonymous`)を表示。`--oauth` でループバック上の Arkor Cloud OAuth(PKCE)フロー、`--anonymous` でピッカーをスキップして使い捨てトークン。 | @@ -33,7 +33,7 @@ description: "arkor CLI: 全コマンドリファレンス。" | `ARKOR_TELEMETRY_DISABLED=1` | `DO_NOT_TRACK` と同じ。`DO_NOT_TRACK` で他ツールをゲートしているがスイッチを分けたい構成のための Arkor 専用エイリアス。 | | `ARKOR_TELEMETRY_DEBUG=1` | テレメトリーモジュール内部の冗長ログを有効化(init 失敗、ID ファイルの read/write エラー、capture 失敗、shutdown エラー)。このフラグが ON でも **テレメトリーは送信されます**。診断補助で、dry-run スイッチではありません。送信を止めるには `DO_NOT_TRACK=1` か `ARKOR_TELEMETRY_DISABLED=1` を使ってください。 | | `CI=1`(または stdout が非 TTY) | 非対話モードを強制。`arkor init` はプロンプトをスキップしてフラグで渡された値(`--yes` 時は組み込みのデフォルト)を使い、`--git` か `--yes` 指定時は git init が確認なしで走り、それ以外では黙ってスキップされます。[`arkor init` § CI / 非対話シェル](/ja/cli/init#ci-/-非対話シェル) を参照。 | -| `CLAUDECODE=1` | 対話プロンプトに応答できない AI エージェント(Claude Code 等)向けの厳格モード。`arkor init`(および `create-arkor`)は curated フラグセットがすべて揃わない限り実行を拒否し、再実行用コマンドを stderr に複数行で出力します。「すべてデフォルトで進める」に戻すには `-y`/`--yes` を渡します。[`arkor init` § Claude Code(`CLAUDECODE=1`)厳格モード](/ja/cli/init#claude-code(claudecode=1)厳格モード) を参照。 | +| `CLAUDECODE=1` | 対話プロンプトに応答できない AI エージェント(Claude Code 等)向けの厳格モード。`arkor init`(および `create-arkor`)は curated フラグセットがすべて揃わない限り実行を拒否し、再実行用コマンドを stderr に複数行で出力します。「すべてデフォルトで進める」に戻すには `-y`/`--yes` を渡します。[`arkor init` § Claude Code(`CLAUDECODE=1`)厳格モード](/ja/cli/init#claude-code(claudecode=1)厳格モード) を参照。`arkor dev` もこの環境では `--agent` なしの起動を拒否します。[`arkor dev` § Claude Code(`CLAUDECODE=1`)厳格モード](/ja/cli/dev#claude-code(claudecode=1)厳格モード) を参照。 | ### ロードマップ上 diff --git a/docs/ja/guides/cli/dev.mdx b/docs/ja/guides/cli/dev.mdx index 472f6bd4..0b7f4fff 100644 --- a/docs/ja/guides/cli/dev.mdx +++ b/docs/ja/guides/cli/dev.mdx @@ -59,6 +59,10 @@ bun dev --port 5000 `--open` を加えるとブラウザーが自動で開きます。 +## コーディングエージェントと使う + +`arkor dev --agent` は同じサーバーをコーディングエージェント(Claude Code など)向けにヘッドレスで起動します。プロジェクト内の `.arkor/agent/` にセッションごとの JSON トークンファイルを書き、そのパスを stdout に出力し、エージェントは `/api/*` エンドポイントを直接叩いて Arkor を操作します(最初に呼ぶのは `GET /api/status`)。`CLAUDECODE=1` の下では素の `arkor dev` は起動を拒否し、明示的な `--agent` を求めます。その間も Studio UI はブラウザーで利用でき、同じポートへの 2 つ目の素の `arkor dev` は起動中のインスタンスに接続してそのまま終了します。詳細は [`arkor dev` リファレンス](/ja/cli/dev)の「エージェントモード」を参照してください。 + ## リファレンス 全フラグ、ループバックと CSRF のセキュリティモデル、トラブルシューティング(ポート競合、匿名初期化、トークン期限、403 の理由)は [`arkor dev` リファレンス](/ja/cli/dev) を参照してください。 diff --git a/e2e/cli/src/arkor-dev.test.ts b/e2e/cli/src/arkor-dev.test.ts new file mode 100644 index 00000000..9fc9009e --- /dev/null +++ b/e2e/cli/src/arkor-dev.test.ts @@ -0,0 +1,54 @@ +// E2E coverage for `arkor dev`'s run-to-exit paths only. The long-running +// server itself is covered by e2e/studio (Playwright spawns a real +// `arkor dev` and drives the SPA); `runCli` waits for process exit, so this +// file sticks to the flows that terminate on their own: +// +// - the CLAUDECODE=1 strict gate (exit 1 before any server bind or HOME +// write; the gate throws inside the Commander action, so nothing is +// persisted and no port is opened), and +// - `dev --help` advertising the --agent opt-in. +// +// `runCli` strips CLAUDECODE from the inherited env by default, so tests +// opt INTO strict mode via `extraEnv`, mirroring arkor-init.test.ts. +import { describe, expect, it } from "vitest"; + +import { ARKOR_BIN } from "./bins"; +import { cleanup, makeTempDir, runCli } from "./spawn-cli"; + +describe("arkor dev × CLAUDECODE strict gate (E2E)", () => { + it("exits 1 with the --agent re-invocation block under CLAUDECODE=1 without --agent", async () => { + const dir = makeTempDir("arkor-dev-e2e-"); + try { + const result = await runCli(ARKOR_BIN, ["dev"], dir, { + HOME: dir, + CLAUDECODE: "1", + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain( + "arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.", + ); + expect(result.stderr).toContain("Re-run with the --agent flag:"); + expect(result.stderr).toContain("--agent"); + expect(result.stderr).toContain("X-Arkor-Studio-Token"); + // The gate fires before the server starts: no ready line, no token + // file side effects under the isolated HOME. + expect(result.stdout).not.toContain("Arkor Studio running on"); + } finally { + cleanup(dir); + } + }); + + it("advertises --agent in `dev --help`", async () => { + const dir = makeTempDir("arkor-dev-help-e2e-"); + try { + const result = await runCli(ARKOR_BIN, ["dev", "--help"], dir, { + HOME: dir, + }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("--agent"); + expect(result.stdout).toContain("coding agents"); + } finally { + cleanup(dir); + } + }); +}); diff --git a/e2e/studio/src/harness/fixture.ts b/e2e/studio/src/harness/fixture.ts index c8dae95a..98152be8 100644 --- a/e2e/studio/src/harness/fixture.ts +++ b/e2e/studio/src/harness/fixture.ts @@ -7,6 +7,8 @@ import { startStudio, type StudioHandle } from "./studioServer"; interface StudioFixtures { /** Live `arkor dev` instance for this test (URL + CSRF token). */ studio: StudioHandle; + /** Live `arkor dev --agent` instance (adds `sessionFile`). */ + agentStudio: StudioHandle; /** In-process fake cloud-api the Studio server proxies through. */ cloudApi: CloudApiMock; /** tmp HOME + project dir, cleaned up automatically. */ @@ -58,6 +60,19 @@ export const test = base.extend({ await studio.kill(); } }, + agentStudio: async ({ cloudApi, fixturePaths }, use) => { + const studio = await startStudio({ + home: fixturePaths.home, + projectDir: fixturePaths.projectDir, + cloudApiUrl: cloudApi.baseUrl, + agent: true, + }); + try { + await use(studio); + } finally { + await studio.kill(); + } + }, }); export { expect } from "@playwright/test"; diff --git a/e2e/studio/src/harness/studioServer.ts b/e2e/studio/src/harness/studioServer.ts index 899ef63a..3f0691fa 100644 --- a/e2e/studio/src/harness/studioServer.ts +++ b/e2e/studio/src/harness/studioServer.ts @@ -11,6 +11,8 @@ export interface StartStudioOptions { projectDir: string; /** Fake cloud-api base URL (`http://127.0.0.1:`). */ cloudApiUrl: string; + /** Spawn with `--agent` (agent mode: session file under `.arkor/agent/`). */ + agent?: boolean; } export interface StudioHandle { @@ -18,11 +20,23 @@ export interface StudioHandle { url: string; /** Per-launch CSRF token, parsed out of the served `index.html`. */ token: string; + /** + * Absolute path of the agent session file, parsed from the stdout + * contract line (`Arkor Studio agent session file: `). Only set + * when the studio was started with `agent: true`. + */ + sessionFile?: string; /** Send SIGINT and await child exit. Idempotent. */ kill: () => Promise; } const READY_LINE_PATTERN = /Arkor Studio running on/; +// Agent mode's stable stdout contract (documented in docs/cli/dev.mdx). In +// agent mode readiness waits for THIS line instead: it is written by the +// same async block right after the ready line, so matching it both proves +// the server is up and hands us the session-file path without a second +// wait-and-parse pass racing the child's stdout flush. +const AGENT_SESSION_LINE_PATTERN = /^Arkor Studio agent session file: (.+)$/m; const READY_TIMEOUT_MS = 30_000; const PORT_POLL_INTERVAL_MS = 50; const PORT_POLL_TIMEOUT_MS = 10_000; @@ -187,6 +201,8 @@ interface SpawnedStudio { kill: () => Promise; stderr: TailBuffer; stdout: TailBuffer; + /** Agent-mode session file path (see `StudioHandle.sessionFile`). */ + sessionFile?: string; } async function spawnStudio(opts: StartStudioOptions): Promise { @@ -201,11 +217,26 @@ async function spawnStudio(opts: StartStudioOptions): Promise { if (lower.startsWith("npm_config_") || lower.startsWith("pnpm_config_")) { continue; } + // `arkor dev` refuses to start under CLAUDECODE=1 without --agent + // (strict gate, ENG-967). The harness spawns both modes explicitly, + // so an inherited CLAUDECODE (vitest/playwright launched from a + // Claude Code session) must not leak into the child and flip the + // gate on tests that spawn plain `arkor dev`. Same policy as + // e2e/cli/src/spawn-cli.ts. + if (lower === "claudecode") { + continue; + } cleanEnv[k] = v; } const child = spawn( process.execPath, - [ARKOR_BIN, "dev", "--port", String(port)], + [ + ARKOR_BIN, + "dev", + "--port", + String(port), + ...(opts.agent ? ["--agent"] : []), + ], { cwd: opts.projectDir, stdio: ["ignore", "pipe", "pipe"], @@ -276,17 +307,19 @@ async function spawnStudio(opts: StartStudioOptions): Promise { // both via `tail()` is O(cap), not O(total bytes), so this stays // cheap regardless of how chatty the child has been. const errorTail = (): string => `${stderr.tail(1000)}${stdout.tail(1000)}`; + // Agent mode waits for the session-file line (which follows the ready + // line from the same writer) so the path is guaranteed to be in the + // buffer once we settle; plain mode keeps the classic ready line. + const readyPattern = opts.agent + ? AGENT_SESSION_LINE_PATTERN + : READY_LINE_PATTERN; try { await new Promise((resolve, reject) => { const onData = (chunk: string) => { // Test the chunk first (handles the line landing in one read) // and fall back to the rolling buffer (handles the rare split // where "Arkor Studio" and "running on" arrive in two chunks). - // Use the shared regex constant so the substring doesn't drift; - // `prefer-includes` would push us back to a hardcoded literal. - // oxfmt-ignore - // eslint-disable-next-line @typescript-eslint/prefer-includes - if (READY_LINE_PATTERN.test(chunk) || READY_LINE_PATTERN.test(stdout.toString())) { + if (readyPattern.test(chunk) || readyPattern.test(stdout.toString())) { settle(resolve); } }; @@ -319,7 +352,7 @@ async function spawnStudio(opts: StartStudioOptions): Promise { settle(() => reject( new Error( - `Timed out waiting for "${READY_LINE_PATTERN.source}" on stdout from arkor dev.\n--- last output ---\n${errorTail()}`, + `Timed out waiting for "${readyPattern.source}" on stdout from arkor dev.\n--- last output ---\n${errorTail()}`, ), ), ); @@ -346,9 +379,7 @@ async function spawnStudio(opts: StartStudioOptions): Promise { // `onData` would never fire and we'd hang until // `READY_TIMEOUT_MS`. Probe the rolling buffer once after // attaching listeners to catch that pre-buffered line. - // Same shared-constant rationale as the `onData` callback above. - // eslint-disable-next-line @typescript-eslint/prefer-includes - if (READY_LINE_PATTERN.test(stdout.toString())) { + if (readyPattern.test(stdout.toString())) { settle(resolve); } }); @@ -357,7 +388,20 @@ async function spawnStudio(opts: StartStudioOptions): Promise { throw err; } - return { child, url, kill, stderr, stdout }; + // In agent mode the wait above settled on the session-file line, so the + // rolling buffer is guaranteed to still contain it (the cap is far above + // the three-line startup output). + const sessionFile = opts.agent + ? AGENT_SESSION_LINE_PATTERN.exec(stdout.toString())?.[1] + : undefined; + if (opts.agent && sessionFile === undefined) { + await kill(); + throw new Error( + `Agent mode was requested but no session-file line was found on stdout.\n--- last output ---\n${errorTail()}`, + ); + } + + return { child, url, kill, stderr, stdout, sessionFile }; } /** @@ -391,7 +435,7 @@ async function readMetaToken(url: string): Promise { export async function startStudio( opts: StartStudioOptions, ): Promise { - const { url, kill } = await spawnStudio(opts); + const { url, kill, sessionFile } = await spawnStudio(opts); let token: string; try { // `arkor dev` writes the ready line before `http.Server.listen()` @@ -408,5 +452,5 @@ export async function startStudio( throw err; } - return { url, token, kill }; + return { url, token, sessionFile, kill }; } diff --git a/e2e/studio/src/specs/agent.spec.ts b/e2e/studio/src/specs/agent.spec.ts new file mode 100644 index 00000000..6d9e7b63 --- /dev/null +++ b/e2e/studio/src/specs/agent.spec.ts @@ -0,0 +1,156 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { ARKOR_BIN } from "../harness/bins"; +import { expect, test } from "../harness/fixture"; + +// Agent mode (`arkor dev --agent`, ENG-967): the browser-free contract a +// coding agent relies on. Covers the stdout session-file line, the JSON +// session file itself (shape + POSIX modes), the token-guarded +// GET /api/status probe, the SPA staying reachable for humans, SIGINT +// cleanup, and a second plain `arkor dev` connecting to the running +// instance instead of failing on the busy port. + +interface SessionPayload { + token: string; + url: string; + port: number; + pid: number; +} + +function readSession(sessionFile: string): SessionPayload { + return JSON.parse(readFileSync(sessionFile, "utf8")) as SessionPayload; +} + +test.describe("Agent mode contract", () => { + test("writes the JSON session file under .arkor/agent/ with tight modes", async ({ + agentStudio, + fixturePaths, + }) => { + const sessionFile = agentStudio.sessionFile; + expect(sessionFile).toBeDefined(); + const agentDir = join(fixturePaths.projectDir, ".arkor", "agent"); + expect(sessionFile!.startsWith(agentDir)).toBe(true); + const payload = readSession(sessionFile!); + // The CLI prints/binds 127.0.0.1 but displays localhost; the session + // file carries the displayed URL. + const port = new URL(agentStudio.url).port; + expect(payload.url).toBe(`http://localhost:${port}`); + expect(String(payload.port)).toBe(port); + expect(Number.isInteger(payload.pid)).toBe(true); + // The file token is the same per-launch CSRF token the SPA reads from + // its meta tag. + expect(payload.token).toBe(agentStudio.token); + if (process.platform !== "win32") { + expect(statSync(agentDir).mode & 0o777).toBe(0o700); + expect(statSync(sessionFile!).mode & 0o777).toBe(0o600); + } + }); + + test("GET /api/status answers the session-file token with safe agent metadata", async ({ + agentStudio, + }) => { + const payload = readSession(agentStudio.sessionFile!); + const res = await fetch(`${agentStudio.url}/api/status`, { + headers: { "X-Arkor-Studio-Token": payload.token }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.status).toBe("ok"); + expect(body.server).toBe("arkor-studio"); + expect(body.mode).toBe("agent"); + expect(body.endpoints).toContain("POST /api/train"); + // Safe by contract: the CSRF token must never round-trip in the body. + expect(JSON.stringify(body)).not.toContain(payload.token); + }); + + test("GET /api/status still 403s without the token header", async ({ + agentStudio, + }) => { + const res = await fetch(`${agentStudio.url}/api/status`); + expect(res.status).toBe(403); + }); + + test("the Studio SPA stays reachable in agent mode (humans keep browser access)", async ({ + agentStudio, + page, + }) => { + await page.goto(agentStudio.url); + await expect(page).toHaveTitle(/Arkor Studio/); + const tokens = page.locator('meta[name="arkor-studio-token"]'); + await expect(tokens).toHaveCount(1); + }); + + test("SIGINT removes the session file", async ({ agentStudio }) => { + const sessionFile = agentStudio.sessionFile!; + expect(existsSync(sessionFile)).toBe(true); + await agentStudio.kill(); + expect(existsSync(sessionFile)).toBe(false); + }); + + test("a second plain `arkor dev` on the same port connects and exits 0", async ({ + agentStudio, + fixturePaths, + cloudApi, + }) => { + // The agent session owns the port and wrote ~/.arkor/studio-token + // (fixture HOME); the plain launch probes /api/status with it, + // confirms the occupant is an Arkor Studio, prints the URL, and + // exits 0 instead of EADDRINUSE-failing. + const port = new URL(agentStudio.url).port; + const env: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) { + const lower = k.toLowerCase(); + if ( + lower.startsWith("npm_config_") || + lower.startsWith("pnpm_config_") || + lower === "claudecode" + ) { + continue; + } + env[k] = v; + } + const result = await new Promise<{ code: number | null; stdout: string }>( + (resolve, reject) => { + const child = spawn( + process.execPath, + [ARKOR_BIN, "dev", "--port", port], + { + cwd: fixturePaths.projectDir, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...env, + CI: "1", + HOME: fixturePaths.home, + USERPROFILE: fixturePaths.home, + ARKOR_CLOUD_API_URL: cloudApi.baseUrl, + ARKOR_TELEMETRY_DISABLED: "1", + npm_config_user_agent: "", + }, + }, + ); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (d: string) => { + stdout += d; + }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ code, stdout }); + }); + }, + ); + expect(result.code).toBe(0); + expect(result.stdout).toContain( + `Arkor Studio already running on http://localhost:${port}`, + ); + // The running agent session is untouched. + const res = await fetch(`${agentStudio.url}/api/status`, { + headers: { + "X-Arkor-Studio-Token": readSession(agentStudio.sessionFile!).token, + }, + }); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index bc479e31..608a355b 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -879,4 +879,297 @@ describe("runDev", () => { // doesn't throw on the now-missing file. expect(() => cleanup()).not.toThrow(); }); + + describe("agent mode", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "arkor-dev-agent-proj-")); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + /** Run runDev with a captured stdout and return the joined output. */ + async function runDevCapturingStdout( + options: Parameters[0], + ): Promise { + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + try { + await runDev(options); + return stdoutSpy.mock.calls.map((c) => String(c[0])).join(""); + } finally { + stdoutSpy.mockRestore(); + } + } + + function sessionPathFrom(stdout: string): string { + const match = /^Arkor Studio agent session file: (.+)$/m.exec(stdout); + expect(match).not.toBeNull(); + return match![1]; + } + + it("writes the session file, prints the three-line contract, and still persists the home token", async () => { + const stdout = await runDevCapturingStdout({ + port: 4310, + agent: true, + cwd: projectDir, + }); + expect(stdout).toContain("Arkor Studio running on http://localhost:4310"); + const sessionPath = sessionPathFrom(stdout); + expect(stdout).toContain( + "Read the token from that file and send it as the X-Arkor-Studio-Token header on /api/* requests.", + ); + // Path shape: /.arkor/agent/session--.json. + expect(sessionPath.startsWith(join(projectDir, ".arkor", "agent"))).toBe( + true, + ); + expect(sessionPath).toMatch(/session-\d+-[0-9a-f-]+\.json$/); + const payload = JSON.parse(readFileSync(sessionPath, "utf8")) as { + token: string; + url: string; + port: number; + pid: number; + }; + expect(payload.url).toBe("http://localhost:4310"); + expect(payload.port).toBe(4310); + expect(payload.pid).toBe(process.pid); + expect(payload.token).toMatch(/^[\w-]+$/); + // The home token is still written (user-facing contract: the Vite SPA + // workflow and the port-collision probe keep working in agent mode). + expect(readFileSync(studioTokenPath(), "utf8")).toBe(payload.token); + // Agent mode never opens a browser implicitly. + expect(open).not.toHaveBeenCalled(); + }); + + it("creates the directory 0700 and the file 0600 with no .tmp strays", async () => { + if (process.platform === "win32") { + // POSIX modes are a no-op on Windows. + return; + } + const stdout = await runDevCapturingStdout({ + port: 4311, + agent: true, + cwd: projectDir, + }); + const sessionPath = sessionPathFrom(stdout); + const { statSync } = await import("node:fs"); + const dir = join(projectDir, ".arkor", "agent"); + expect(statSync(dir).mode & 0o777).toBe(0o700); + expect(statSync(sessionPath).mode & 0o777).toBe(0o600); + const strays = readdirSync(dir).filter((f) => f.includes(".tmp")); + expect(strays).toEqual([]); + }); + + it("unlinks the session file from the exit cleanup and on signals", async () => { + const stdout = await runDevCapturingStdout({ + port: 4312, + agent: true, + cwd: projectDir, + }); + const sessionPath = sessionPathFrom(stdout); + expect(existsSync(sessionPath)).toBe(true); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation( + ((_code?: number) => undefined as never) as typeof process.exit, + ); + try { + const handler = process.listeners("SIGINT").at(-1) as () => void; + handler(); + expect(exitSpy).toHaveBeenCalledWith(130); + } finally { + exitSpy.mockRestore(); + } + expect(existsSync(sessionPath)).toBe(false); + // The home token was ours too, so the same cleanup removed it. + expect(existsSync(studioTokenPath())).toBe(false); + }); + + it("aborts startup (hard-fail) when the session file cannot be written", async () => { + if (typeof process.getuid === "function" && process.getuid() === 0) { + // Root bypasses chmod permission checks; skip on root containers. + return; + } + // A read-only /.arkor makes `mkdir .arkor/agent` fail. Unlike + // the home token (warn-and-continue), agent mode must reject: the + // session file is the agent's only token channel. + mkdirSync(join(projectDir, ".arkor"), { recursive: true }); + chmodSync(join(projectDir, ".arkor"), 0o555); + const closeSpy = vi.fn(); + vi.mocked(serve).mockImplementationOnce((( + _opts: unknown, + onListen?: () => void, + ) => { + queueMicrotask(() => onListen?.()); + return { on: vi.fn(), close: closeSpy }; + }) as unknown as typeof serve); + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + try { + await expect( + runDev({ port: 4313, agent: true, cwd: projectDir }), + ).rejects.toThrow(); + } finally { + stdoutSpy.mockRestore(); + chmodSync(join(projectDir, ".arkor"), 0o755); + } + expect(closeSpy).toHaveBeenCalled(); + }); + + it("does not create .arkor/agent in normal (non-agent) mode", async () => { + await runDevCapturingStdout({ port: 4314, cwd: projectDir }); + expect(existsSync(join(projectDir, ".arkor"))).toBe(false); + }); + }); + + describe("port-collision connect (EADDRINUSE probe)", () => { + /** serve stub that skips the listening callback and fires EADDRINUSE. */ + function mockServeAddrInUse(closeSpy?: () => void): void { + vi.mocked(serve).mockImplementationOnce((() => { + const server = { + close: closeSpy ?? vi.fn(), + on: (event: string, cb: (err: NodeJS.ErrnoException) => void) => { + if (event === "error") { + queueMicrotask(() => + cb( + Object.assign(new Error("bind failed"), { + code: "EADDRINUSE", + }), + ), + ); + } + return server; + }, + }; + return server; + }) as unknown as typeof serve); + } + + function seedHomeToken(token: string): void { + mkdirSync(join(fakeHome, ".arkor"), { recursive: true }); + writeFileSync(studioTokenPath(), token); + } + + it("connects to a confirmed running Studio: resolves, prints the URL, registers no cleanup", async () => { + mockServeAddrInUse(); + seedHomeToken("running-instance-token"); + const fetchSpy = vi.fn(async () => + Response.json({ status: "ok", server: "arkor-studio" }), + ); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + const exitBefore = process.listeners("exit").length; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + try { + await expect(runDev({ port: 4320 })).resolves.toBeUndefined(); + const stdout = stdoutSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(stdout).toContain( + "Arkor Studio already running on http://localhost:4320", + ); + } finally { + stdoutSpy.mockRestore(); + } + // Probe hit /api/status with the home token in the header. + const [reqUrl, reqInit] = fetchSpy.mock.calls[0] as unknown as [ + URL, + RequestInit, + ]; + expect(String(reqUrl)).toBe("http://localhost:4320/api/status"); + expect( + (reqInit.headers as Record)["x-arkor-studio-token"], + ).toBe("running-instance-token"); + // The running instance's token is untouched and no cleanup handler was + // registered by this launch. + expect(readFileSync(studioTokenPath(), "utf8")).toBe( + "running-instance-token", + ); + expect(process.listeners("exit").length).toBe(exitBefore); + }); + + it("honors --open against the existing instance", async () => { + mockServeAddrInUse(); + seedHomeToken("running-instance-token"); + globalThis.fetch = vi.fn(async () => + Response.json({ status: "ok", server: "arkor-studio" }), + ) as unknown as typeof fetch; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + try { + await expect( + runDev({ port: 4321, open: true }), + ).resolves.toBeUndefined(); + } finally { + stdoutSpy.mockRestore(); + } + expect(open).toHaveBeenCalledWith("http://localhost:4321"); + }); + + it("falls back to the port-in-use error when no home token exists (probe skipped)", async () => { + mockServeAddrInUse(); + const fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + await expect(runDev({ port: 4322 })).rejects.toThrow( + /Port 4322 is already in use/, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("falls back when the occupant rejects the token (403)", async () => { + mockServeAddrInUse(); + seedHomeToken("stale-token"); + globalThis.fetch = vi.fn(async () => + Response.json( + { error: "Missing or invalid studio token" }, + { + status: 403, + }, + ), + ) as unknown as typeof fetch; + await expect(runDev({ port: 4323 })).rejects.toThrow( + /Port 4323 is already in use/, + ); + }); + + it("falls back when the probe request itself fails (timeout / refused)", async () => { + mockServeAddrInUse(); + seedHomeToken("some-token"); + globalThis.fetch = vi.fn(async () => { + throw new Error("The operation was aborted due to timeout"); + }) as unknown as typeof fetch; + await expect(runDev({ port: 4324 })).rejects.toThrow( + /Port 4324 is already in use/, + ); + }); + + it("falls back when the occupant is not an Arkor Studio (missing discriminator)", async () => { + mockServeAddrInUse(); + seedHomeToken("some-token"); + globalThis.fetch = vi.fn(async () => + Response.json({ status: "ok", server: "something-else" }), + ) as unknown as typeof fetch; + await expect(runDev({ port: 4325 })).rejects.toThrow( + /Port 4325 is already in use/, + ); + }); + + it("agent mode never probes: EADDRINUSE stays a hard error", async () => { + mockServeAddrInUse(); + seedHomeToken("running-instance-token"); + const fetchSpy = vi.fn(async () => + Response.json({ status: "ok", server: "arkor-studio" }), + ); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + await expect(runDev({ port: 4326, agent: true })).rejects.toThrow( + /Port 4326 is already in use/, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index 0ad076ad..77e0107c 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { readFileSync, unlinkSync } from "node:fs"; import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises"; import { constants as osConstants } from "node:os"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; import { serve } from "@hono/node-server"; import open from "open"; @@ -25,6 +25,20 @@ import { ui } from "../prompts"; export interface DevOptions { port?: number; open?: boolean; + /** + * Agent mode (`arkor dev --agent`): run the same Studio server headlessly + * for a coding agent. Writes a per-session JSON token file under + * `/.arkor/agent/` and prints its path to stdout; the browser still + * only opens on an explicit `--open`. + */ + agent?: boolean; + /** + * Project root the agent session file is written under + * (`/.arkor/agent/`). Defaults to `process.cwd()`. Not exposed as a + * CLI flag; exists so tests can pin a temp project dir without `chdir`-ing + * the vitest worker. + */ + cwd?: string; } /** @@ -202,6 +216,100 @@ async function persistStudioToken(token: string): Promise { return path; } +interface AgentSessionPayload { + token: string; + url: string; + port: number; + pid: number; +} + +/** + * Write the agent-mode session file: + * `/.arkor/agent/session--.json` (directory 0700, + * file 0600). The filename is per-launch unique so parallel sessions in the + * same project coexist and shutdown can unlink its own path without the + * content ownership check the shared home token needs. Uses the same + * atomic temp+rename staging as `persistStudioToken` so a crash mid-write + * never leaves a truncated JSON body at the final path. + */ +async function writeAgentSessionFile( + projectRoot: string, + payload: AgentSessionPayload, +): Promise { + const dir = join(projectRoot, ".arkor", "agent"); + await mkdir(dir, { recursive: true, mode: 0o700 }); + try { + // `mkdir`'s `mode` only applies to directories it creates; tighten a + // pre-existing dir too. Best-effort: on exotic mounts where chmod fails, + // the 0600 file mode below is the last line of defence for the token. + await chmod(dir, 0o700); + } catch (err) { + ui.log.warn( + `Could not set permissions on ${dir}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + const path = join(dir, `session-${process.pid}-${randomUUID()}.json`); + const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { + mode: 0o600, + }); + try { + // Same belt-and-suspenders policy as `persistStudioToken`. + await chmod(tmp, 0o600); + } catch (err) { + ui.log.warn( + `Could not set permissions on ${path}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + await rename(tmp, path); + } catch (err) { + await rm(tmp, { force: true }).catch(() => undefined); + throw err; + } + return path; +} + +/** + * True when `url` is answered by a running Arkor Studio instance. Used by + * the normal-mode EADDRINUSE path so a plain `arkor dev` connects to an + * already-running Studio (typically an `arkor dev --agent` session that owns + * the port) instead of failing. Reads `~/.arkor/studio-token` (agent mode + * still writes it; last-writer-wins) and calls the token-guarded + * `GET /api/status`, keying on its `server: "arkor-studio"` discriminator so + * an unrelated occupant that happens to 200 on that path is not adopted. + * Best-effort by design: any failure (no token file, stale token, timeout, + * non-Studio occupant) reports false and the caller falls back to the + * existing port-in-use error. + */ +async function probeExistingStudio(url: string): Promise { + let token: string; + try { + token = readFileSync(studioTokenPath(), "utf8"); + } catch { + return false; + } + try { + const res = await fetch(new URL("/api/status", url), { + headers: { "x-arkor-studio-token": token }, + signal: AbortSignal.timeout(1500), + }); + if (!res.ok) return false; + const body: unknown = await res.json(); + return ( + typeof body === "object" && + body !== null && + (body as { server?: unknown }).server === "arkor-studio" + ); + } catch { + return false; + } +} + /** * Install the process-lifetime shutdown handlers, running `cleanup` (once) * on normal exit and on SIGINT/SIGTERM/SIGHUP before re-exiting. @@ -239,15 +347,13 @@ export async function runDev(options: DevOptions = {}): Promise { await ensureCredentialsForStudio(); const port = options.port ?? 4000; + const agent = options.agent === true; + const projectRoot = options.cwd ?? process.cwd(); // Per-launch CSRF token: injected into index.html as , required on // every /api/* request. Prevents another tab on the same machine from // hitting `arkor start` (and therefore RCE via dynamic import). const studioToken = randomBytes(32).toString("base64url"); - // `autoAnonymous: true` (the default) lets the Hono server retry the - // anonymous bootstrap on first `/api/credentials` hit if the up-front - // attempt above failed (e.g. cloud-api was unreachable at launch). - const app = buildStudioApp({ studioToken }); // Bind to 127.0.0.1 (not "localhost") so the listener can't end up on `::1` // only: `@hono/node-server` passes hostname to `net.Server.listen`, which // calls `dns.lookup`. On hosts where `/etc/hosts` orders `::1 localhost` @@ -258,6 +364,15 @@ export async function runDev(options: DevOptions = {}): Promise { // can still be `localhost`. const url = `http://localhost:${port}`; + // `autoAnonymous: true` (the default) lets the Hono server retry the + // anonymous bootstrap on first `/api/credentials` hit if the up-front + // attempt above failed (e.g. cloud-api was unreachable at launch). + const app = buildStudioApp({ + studioToken, + mode: agent ? "agent" : "studio", + url, + }); + await new Promise((resolve, reject) => { // Tracks whether the listener has BOUND (the `listening` callback fired) // so the persistent 'error' listener below can tell a pre-bind failure @@ -266,6 +381,11 @@ export async function runDev(options: DevOptions = {}): Promise { // token is still being persisted hits an already-serving server, so // treating it as a startup failure would kill a healthy instance. let bound = false; + // Absolute path of the agent-mode session file, assigned only after the + // atomic rename landed it. A signal mid-write therefore abandons only + // the disposable .tmp staging file, never a half-written session file, + // and the cleanup below can unlink unconditionally. + let agentSessionPath: string | undefined; // Bind FIRST, then persist the studio token and register its cleanup // in the `listening` callback (after a successful bind). The token // file (`~/.arkor/studio-token`) is a single shared path, so a second @@ -307,6 +427,15 @@ export async function runDev(options: DevOptions = {}): Promise { } catch { // best-effort } + // The agent session file needs no ownership check: its name is + // per-launch unique (pid + uuid), so this path is always ours. + if (agentSessionPath !== undefined) { + try { + unlinkSync(agentSessionPath); + } catch { + // best-effort + } + } }); // Persisting the token to disk is *only* needed for the Vite SPA // dev workflow. The bundled `:port` flow injects the meta tag at @@ -314,6 +443,38 @@ export async function runDev(options: DevOptions = {}): Promise { // $HOME on Docker / locked-down CI / restrictive umask) must not // block the server. void (async () => { + if (agent) { + // The session file is the agent's only realistic token channel + // (an agent does not scrape the tag the SPA reads), so + // unlike the home token below a write failure aborts startup + // instead of degrading: a warn-and-continue server would be + // silently unusable to its own caller. + try { + agentSessionPath = await writeAgentSessionFile(projectRoot, { + token: studioToken, + url, + port, + pid: process.pid, + }); + } catch (err) { + ui.log.error( + `Could not write the agent session file under ${join( + projectRoot, + ".arkor", + "agent", + )} (${ + err instanceof Error ? err.message : String(err) + }). Agent mode requires it; aborting.`, + ); + try { + server.close(); + } catch { + // best-effort + } + reject(err instanceof Error ? err : new Error(String(err))); + return; + } + } try { await persistStudioToken(studioToken); } catch (err) { @@ -324,6 +485,16 @@ export async function runDev(options: DevOptions = {}): Promise { ); } process.stdout.write(`Arkor Studio running on ${url}\n`); + if (agentSessionPath !== undefined) { + // Stable, greppable contract for coding agents (documented in + // docs/cli/dev.mdx): the path line prefix must not change. + process.stdout.write( + `Arkor Studio agent session file: ${agentSessionPath}\n`, + ); + process.stdout.write( + "Read the token from that file and send it as the X-Arkor-Studio-Token header on /api/* requests.\n", + ); + } resolve(); })(); }, @@ -351,11 +522,37 @@ export async function runDev(options: DevOptions = {}): Promise { err instanceof Error && (err as NodeJS.ErrnoException).code === "EADDRINUSE" ) { - reject( - new Error( - `Port ${port} is already in use. Another \`arkor dev\` may be running; pass --port to choose a different one.`, - ), + const portInUse = new Error( + `Port ${port} is already in use. Another \`arkor dev\` may be running; pass --port to choose a different one.`, ); + if (agent) { + // An agent session needs its own server process and session-file + // lifecycle, so never adopt an existing instance; ask for --port. + reject(portInUse); + return; + } + // Normal mode: the occupant may be a healthy Studio, typically an + // `arkor dev --agent` session that owns the port. Probe the token- + // guarded /api/status with the home token (agent mode still writes + // it) and, when confirmed, "connect": print the URL and resolve so + // the caller can honor --open against the existing instance and + // exit 0. Bind-first ordering guarantees this launch wrote no files + // and registered no shutdown handlers, so resolving here cannot + // disturb the running instance. + void (async () => { + const ok = await probeExistingStudio(url); + if (ok) { + try { + server.close(); + } catch { + // best-effort + } + process.stdout.write(`Arkor Studio already running on ${url}\n`); + resolve(); + return; + } + reject(portInUse); + })(); return; } reject(err instanceof Error ? err : new Error(message)); diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index 24bac45d..967523dc 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -196,14 +196,73 @@ describe("main (CLI Commander wiring)", () => { }); it("dispatches `dev` with the parsed port + --open flag", async () => { + // The worker may inherit CLAUDECODE=1 (vitest spawned from a Claude + // Code session), which would trip the new dev gate; this test is about + // plain flag dispatch, so pin the non-agent environment. afterEach + // restores the original value. + delete process.env.CLAUDECODE; await main(["dev", "--port", "4500", "--open"]); - expect(runDev).toHaveBeenCalledWith({ port: 4500, open: true }); + expect(runDev).toHaveBeenCalledWith({ + port: 4500, + open: true, + agent: false, + }); }); it("falls back to port 4000 when --port is non-numeric", async () => { // Branch coverage for the `Number(opts.port) || 4000` defaulting. + delete process.env.CLAUDECODE; await main(["dev", "--port", "not-a-number"]); - expect(runDev).toHaveBeenCalledWith({ port: 4000, open: false }); + expect(runDev).toHaveBeenCalledWith({ + port: 4000, + open: false, + agent: false, + }); + }); + + it("under CLAUDECODE=1, `dev` without --agent throws ClaudeCodeStrictExit and never starts the server", async () => { + process.env.CLAUDECODE = "1"; + const stderrChunks: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((( + c: unknown, + ) => { + stderrChunks.push(String(c)); + return true; + }) as typeof process.stderr.write); + try { + await expect(main(["dev"])).rejects.toMatchObject({ + name: "ClaudeCodeStrictExit", + }); + } finally { + spy.mockRestore(); + } + // Same sentinel contract as the init gate: the finally block still runs. + expect(shutdownTelemetry).toHaveBeenCalledOnce(); + const buf = stderrChunks.join(""); + expect(buf).toContain("arkor dev: CLAUDECODE=1 detected"); + expect(buf).toContain("--agent"); + expect(buf).toContain("X-Arkor-Studio-Token"); + expect(runDev).not.toHaveBeenCalled(); + }); + + it("under CLAUDECODE=1, `dev --agent` passes the gate and delegates with agent: true", async () => { + process.env.CLAUDECODE = "1"; + await main(["dev", "--agent"]); + expect(runDev).toHaveBeenCalledWith({ + port: 4000, + open: false, + agent: true, + }); + }); + + it("without CLAUDECODE, `dev --agent` still opts into agent mode (other coding agents)", async () => { + delete process.env.CLAUDECODE; + await main(["dev", "--agent", "--port", "4501"]); + expect(runDev).toHaveBeenCalledWith({ + port: 4501, + open: false, + agent: true, + }); }); it("flushes a recorded deprecation warning after parse and awaits shutdownTelemetry", async () => { diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index 14c390a3..240bc378 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -1,5 +1,6 @@ import { ClaudeCodeStrictExit, + formatClaudeCodeAgentModeMessage, formatClaudeCodeMissingMessage, isClaudeCode, missingClaudeCodeFlags, @@ -241,13 +242,28 @@ export async function main(argv: string[]): Promise { .description("Launch Arkor Studio locally") .option("-p, --port ", "Port to bind (default: 4000)", "4000") .option("--open", "Open the Studio URL in a browser after starting") + .option( + "--agent", + "Run headlessly for coding agents: write a JSON session token file to .arkor/agent/ and print its path", + ) .action( withTelemetry( "dev", - async (opts: { port: string; open?: boolean }) => { + async (opts: { port: string; open?: boolean; agent?: boolean }) => { + // Under CLAUDECODE=1 a Studio launch without --agent is almost + // always a mistake: the agent cannot drive a browser UI and the + // long-running server would hang its shell. Require the explicit + // --agent opt-in (same sentinel pattern as the `init` gate above). + // The flag itself does NOT require the env var: other coding + // agents opt in with a plain `arkor dev --agent`. + if (isClaudeCode() && opts.agent !== true) { + process.stderr.write(formatClaudeCodeAgentModeMessage("arkor dev")); + throw new ClaudeCodeStrictExit(); + } await runDev({ port: Number(opts.port) || 4000, open: opts.open === true, + agent: opts.agent === true, }); }, { longRunning: true }, diff --git a/packages/arkor/src/studio/server.test.ts b/packages/arkor/src/studio/server.test.ts index d58c8f9d..b818ddc3 100644 --- a/packages/arkor/src/studio/server.test.ts +++ b/packages/arkor/src/studio/server.test.ts @@ -263,6 +263,93 @@ describe("Studio server", () => { expect(res.status).toBe(403); }); + describe("GET /api/status", () => { + it("rejects requests without the studio token", async () => { + const app = build(); + const res = await app.request("/api/status", { + headers: { host: "127.0.0.1:4000" }, + }); + expect(res.status).toBe(403); + }); + + it("rejects non-loopback hosts", async () => { + const app = build(); + const res = await app.request("/api/status", { + headers: { + host: "evil.example:4000", + "x-arkor-studio-token": STUDIO_TOKEN, + }, + }); + expect(res.status).toBe(403); + }); + + it("returns safe metadata and the endpoint list with a valid token", async () => { + // No credentials are written and `autoAnonymous` is false: if the + // handler ever touched the credential path, `getCredentials` would + // throw and this request would 500. Passing proves the endpoint is + // credentials-free. + const app = build(); + const res = await app.request("/api/status", { + headers: { + host: "127.0.0.1:4000", + "x-arkor-studio-token": STUDIO_TOKEN, + }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + status: "ok", + server: "arkor-studio", + mode: "studio", + url: null, + pid: process.pid, + cwd: trainCwd, + }); + expect(typeof body.version).toBe("string"); + expect(body.endpoints).toContain("GET /api/status"); + expect(body.endpoints).toContain("POST /api/train"); + expect(body.endpoints).toContain("GET /api/jobs/:id/events"); + }); + + it("echoes mode and url when launched as an agent session", async () => { + const app = buildStudioApp({ + baseUrl: "http://mock", + assetsDir, + autoAnonymous: false, + studioToken: STUDIO_TOKEN, + cwd: trainCwd, + mode: "agent", + url: "http://localhost:4123", + }); + const res = await app.request("/api/status", { + headers: { + host: "127.0.0.1:4000", + "x-arkor-studio-token": STUDIO_TOKEN, + }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.mode).toBe("agent"); + expect(body.url).toBe("http://localhost:4123"); + }); + + it("never includes the studio token or credential material in the body", async () => { + await writeCredentials(ANON_CREDS); + const app = build(); + const res = await app.request("/api/status", { + headers: { + host: "127.0.0.1:4000", + "x-arkor-studio-token": STUDIO_TOKEN, + }, + }); + expect(res.status).toBe(200); + const raw = await res.text(); + expect(raw).not.toContain(STUDIO_TOKEN); + expect(raw).not.toContain(ANON_CREDS.token); + expect(raw).not.toContain(ANON_CREDS.anonymousId); + }); + }); + it("returns project state from the Studio training cwd", async () => { await writeCredentials(ANON_CREDS); await writeState( diff --git a/packages/arkor/src/studio/server.ts b/packages/arkor/src/studio/server.ts index 6cc32782..8cf82ae6 100644 --- a/packages/arkor/src/studio/server.ts +++ b/packages/arkor/src/studio/server.ts @@ -73,6 +73,17 @@ export interface StudioServerOptions { * here points at the bin itself). Override in tests. */ binPath?: string; + /** + * How this server was launched: `"agent"` for `arkor dev --agent`, + * `"studio"` otherwise. Echoed by `GET /api/status` so a coding agent can + * confirm it is talking to its own session. Defaults to `"studio"`. + */ + mode?: "agent" | "studio"; + /** + * Public URL the CLI printed (e.g. `http://localhost:4000`). Echoed by + * `GET /api/status`; `null` there when omitted (e.g. app-only tests). + */ + url?: string; } function tokensMatch(provided: string, expected: string): boolean { @@ -242,6 +253,45 @@ export function buildStudioApp(options: StudioServerOptions) { // ---- API routes --------------------------------------------------------- + // Safe status probe for coding agents and for the `arkor dev` port-collision + // check. Deliberately synchronous data only: no credentials read, no cloud + // round-trip, and above all no user-code execution (`readManifestSummary` + // rebuilds and imports the project entry point; that must never hang off a + // status poll). Must never include secrets: the token guard still applies + // (it is under `/api/*`), but the response body is written as if it were + // public. `server: "arkor-studio"` is the discriminator the port-collision + // probe keys on to distinguish a Studio instance from an unrelated occupant + // that happens to 200 on this path. + app.get("/api/status", (c) => + c.json({ + status: "ok", + server: "arkor-studio", + version: SDK_VERSION, + mode: options.mode ?? "studio", + url: options.url ?? null, + pid: process.pid, + cwd: trainCwd, + endpoints: [ + "GET /api/status", + "GET /api/credentials", + "GET /api/me", + "GET /api/manifest", + "GET /api/jobs", + "GET /api/jobs/:id/events", + "POST /api/train", + "POST /api/inference/chat", + "GET /api/deployments", + "POST /api/deployments", + "GET /api/deployments/:id", + "PATCH /api/deployments/:id", + "DELETE /api/deployments/:id", + "GET /api/deployments/:id/keys", + "POST /api/deployments/:id/keys", + "DELETE /api/deployments/:id/keys/:keyId", + ], + }), + ); + app.get("/api/credentials", async (c) => { const { credentials: creds, diff --git a/packages/cli-internal/src/claude-code.test.ts b/packages/cli-internal/src/claude-code.test.ts index 8354f7bf..00acb677 100644 --- a/packages/cli-internal/src/claude-code.test.ts +++ b/packages/cli-internal/src/claude-code.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + formatClaudeCodeAgentModeMessage, formatClaudeCodeMissingMessage, isClaudeCode, missingClaudeCodeFlags, @@ -421,3 +422,23 @@ describe("formatClaudeCodeMissingMessage", () => { expect(out.endsWith("\n")).toBe(true); }); }); + +describe("formatClaudeCodeAgentModeMessage", () => { + it("renders the command name, the --agent flag, and the header contract", () => { + const out = formatClaudeCodeAgentModeMessage("arkor dev"); + expect(out).toContain( + "arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.", + ); + expect(out).toContain("Re-run with the --agent flag:"); + expect(out).toContain(" --agent"); + expect(out).toContain("X-Arkor-Studio-Token"); + expect(out).toContain(".arkor/agent/"); + expect(out.endsWith("\n")).toBe(true); + }); + + it("does not offer the -y/--yes escape hatch (dev has no defaults mode)", () => { + const out = formatClaudeCodeAgentModeMessage("arkor dev"); + expect(out).not.toContain("--yes"); + expect(out).not.toContain("-y"); + }); +}); diff --git a/packages/cli-internal/src/claude-code.ts b/packages/cli-internal/src/claude-code.ts index f5354e17..505e152b 100644 --- a/packages/cli-internal/src/claude-code.ts +++ b/packages/cli-internal/src/claude-code.ts @@ -275,3 +275,25 @@ export function formatClaudeCodeMissingMessage( lines.push("Or pass -y/--yes to accept all defaults."); return `${lines.join("\n")}\n`; } + +/** + * Render the stderr block printed when `arkor dev` runs under CLAUDECODE=1 + * without `--agent`. A separate helper (rather than an optional footer on + * `formatClaudeCodeMissingMessage`) because the shape differs: `dev` has no + * missing-flags list and no `-y/--yes` escape hatch; the only way forward is + * the explicit `--agent` opt-in. Kept pure so main.ts and tests share the + * wording. The `command` parameter mirrors the scaffolder formatter for + * symmetry and testability. + */ +export function formatClaudeCodeAgentModeMessage(command: string): string { + const lines = [ + `${command}: CLAUDECODE=1 detected. Interactive Studio use is disabled.`, + "Re-run with the --agent flag:", + " --agent", + " Runs the same Studio server headlessly and writes a JSON session", + " file ({ token, url, port, pid }) under .arkor/agent/ in the", + " project. Read the token from that file and send it as the", + " X-Arkor-Studio-Token header on /api/* requests.", + ]; + return `${lines.join("\n")}\n`; +} diff --git a/packages/cli-internal/src/index.ts b/packages/cli-internal/src/index.ts index 7fd486a7..a323589d 100644 --- a/packages/cli-internal/src/index.ts +++ b/packages/cli-internal/src/index.ts @@ -32,6 +32,7 @@ export { ClaudeCodeStrictExit, isClaudeCode, missingClaudeCodeFlags, + formatClaudeCodeAgentModeMessage, formatClaudeCodeMissingMessage, type ClaudeCodeOptionsCheck, type MissingClaudeCodeFlag, From ca19a4ef9c747b78753ffde9de68b45f38a18581 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:44:48 -0700 Subject: [PATCH 02/19] fix(ENG-967): harden agent-mode cleanup and Windows test guards (self-review) Findings from an adversarial self-review of 6db4e87: - Windows CI: skip the SIGINT-cleanup e2e spec on win32 (Node's Windows signal emulation terminates the child forcefully, so the dev.ts handler that unlinks the session file never runs) and guard the hard-fail unit test the same way (chmod maps to the read-only attribute there and does not block mkdir inside the directory, so the injected failure never happens). Both would have failed deterministically on the windows lanes. - Session-file cleanup now sweeps every session--* entry in .arkor/agent/ instead of unlinking a captured path variable. This closes the window where a signal lands after the atomic rename placed the file but before the path assignment ran, and also reaps abandoned .tmp staging files; parallel sessions under other pids are untouched. New unit test covers the sweep and the other-pid exclusion. - dev.ts now passes cwd to buildStudioApp so the server's project root (/api/train resolution, /api/status cwd) always matches where the session file lands when tests pin options.cwd; identical in production. - Docs EN+JA: the strict-gate wording no longer claims "nothing was written" (first-run telemetry may create its id file); scoped to "no token or session file". Relocated a stale comment that predated the agent-mode block and updated the AGENTS.md description of the cleanup. --- AGENTS.md | 2 +- docs/cli/dev.mdx | 4 +- docs/ja/cli/dev.mdx | 4 +- e2e/studio/src/specs/agent.spec.ts | 6 +++ packages/arkor/src/cli/commands/dev.test.ts | 36 ++++++++++++++ packages/arkor/src/cli/commands/dev.ts | 54 +++++++++++++++------ 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2a7707e1..66ad389c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ cd my-arkor-app && pnpm dev # Studio at http://127.0. 1. Passes it to `buildStudioApp({ studioToken })`. The Hono server validates every `/api/*` request via `X-Arkor-Studio-Token` header (or `?studioToken=` query for `EventSource`, which can't set headers). Comparison uses `timingSafeEqual`. 2. Persists it to `~/.arkor/studio-token` (mode 0600) so the SPA dev workflow (`pnpm --filter @arkor/studio-app dev`) can read it via the `arkor-studio-token` Vite plugin in [packages/studio-app/vite.config.ts](packages/studio-app/vite.config.ts), which injects `` into `index.html` on each request. Persistence failure must NOT block server start (read-only `$HOME` on Docker, etc.); just warn. 3. Cleans up on `exit`/SIGINT/SIGTERM/SIGHUP via `unlinkSync`, after verifying the file still holds this instance's token (a second `arkor dev` on another port may have overwritten it; last writer wins and the previous owner must not delete it). Signal handlers exit with the conventional `128 + signal` code (SIGINT -> 130, SIGTERM -> 143, SIGHUP -> 129) so supervisors can distinguish signal termination from a clean exit. -4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (dir 0700, file 0600, atomic temp+rename) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel); the same shutdown handlers unlink it WITHOUT a content check (unique name = unambiguous ownership). Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. `GET /api/status` is the token-guarded, secrets-free probe (`server: "arkor-studio"` discriminator, never executes user code); normal-mode `arkor dev` on a busy port probes it with the home token and, on confirmation, prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. +4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (dir 0700, file 0600, atomic temp+rename) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel); the same shutdown handlers remove it WITHOUT a content check by sweeping every `session--*` entry in the agent dir (pid prefix = unambiguous ownership, and the sweep also catches a signal landing between the rename and the path-variable assignment, plus abandoned `.tmp` staging files). Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. `GET /api/status` is the token-guarded, secrets-free probe (`server: "arkor-studio"` discriminator, never executes user code); normal-mode `arkor dev` on a busy port probes it with the home token and, on confirmation, prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. `/api/*` middleware also enforces a host-header allow-list (`127.0.0.1`/`localhost`) for DNS-rebinding defence. **CORS is intentionally NOT configured**: the SPA is same-origin so reflecting `*` would let "simple" cross-origin POSTs reach handlers. The token check rejects those; cross-origin tabs cannot read the SPA's ``. diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index ce644d60..c52d6daf 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -77,7 +77,7 @@ The Studio SPA is still served in agent mode: a human can open the printed URL i ### Claude Code (`CLAUDECODE=1`) strict mode -Claude Code sets `CLAUDECODE=1` in every spawned shell. Under it, a plain `arkor dev` refuses to start: the long-running browser-oriented server would hang the agent's shell without giving it a way to call the API. The CLI prints the block below to stderr and exits `1` before binding a port or writing any file: +Claude Code sets `CLAUDECODE=1` in every spawned shell. Under it, a plain `arkor dev` refuses to start: the long-running browser-oriented server would hang the agent's shell without giving it a way to call the API. The CLI prints the block below to stderr and exits `1` without binding a port or writing any token or session file (first-run telemetry may still create its id file under `~/.arkor/`): ``` arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled. @@ -116,7 +116,7 @@ The token file (`~/.arkor/studio-token`) is written only **after** the port bind | --- | --- | --- | | ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` | Another process holds the port (the underlying error is `EADDRINUSE`) and, in normal mode, the running-Studio probe did not confirm an Arkor Studio. In agent mode this fires without probing. | Stop it, or use `--port `. | | `Arkor Studio already running on http://localhost:` (exit `0`) | Normal mode found a healthy Arkor Studio on the port (often an `arkor dev --agent` session) and connected to it instead of failing. Informational, not an error. | Nothing required. Open the printed URL; pass `--open` to have the CLI do it. | -| `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.` (exit `1`) | The Claude Code strict gate: `CLAUDECODE=1` is set and `--agent` was not passed. Nothing was started and nothing was written. | Re-run as `arkor dev --agent` and use the printed session file. | +| `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.` (exit `1`) | The Claude Code strict gate: `CLAUDECODE=1` is set and `--agent` was not passed. No server was started and no token or session file was written. | Re-run as `arkor dev --agent` and use the printed session file. | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.` (exit `1`) | Agent mode could not create `.arkor/agent/` or the session file (read-only project dir, permission problem). Unlike the home token this is fatal: the file is the agent's only token channel. | Make the project directory writable (check `.arkor/` permissions) and re-run. | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.` | `/v1/auth/cli/config` already succeeded, but the follow-up `/v1/auth/anonymous` hit a transport error. The Studio server starts and will retry. | Bring connectivity back; the SPA recovers on its next `/api/credentials` poll without restarting `arkor dev`. | | `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`. | diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index af26604c..866c8a6d 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -77,7 +77,7 @@ bun dev ### Claude Code(`CLAUDECODE=1`)厳格モード -Claude Code はすべてのシェルに `CLAUDECODE=1` を設定します。この環境で素の `arkor dev` は起動を拒否します。ブラウザー前提の常駐サーバーは、API を叩く手段をエージェントに与えないままシェルをハングさせてしまうためです。CLI はポートのバインドやファイル書き込みの前に次のブロックを stderr に出力し、exit code `1` で終了します。 +Claude Code はすべてのシェルに `CLAUDECODE=1` を設定します。この環境で素の `arkor dev` は起動を拒否します。ブラウザー前提の常駐サーバーは、API を叩く手段をエージェントに与えないままシェルをハングさせてしまうためです。CLI はポートをバインドせず、トークンファイルもセッションファイルも書かずに、次のブロックを stderr に出力して exit code `1` で終了します(初回実行時のテレメトリーは `~/.arkor/` に id ファイルを作ることがあります)。 ``` arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled. @@ -116,7 +116,7 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | --- | --- | --- | | ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート 4000 は既に使用中。根本のエラーは `EADDRINUSE`) | 別プロセスがポートを保持していて、通常モードでは起動中 Studio プローブが Arkor Studio を確認できなかった。エージェントモードではプローブなしでこのエラーになる。 | 止めるか `--port `。 | | `Arkor Studio already running on http://localhost:`(exit `0`) | 通常モードがポート上で健全な Arkor Studio(多くは `arkor dev --agent` セッション)を発見し、失敗ではなく接続扱いにした。エラーではなく情報行。 | 何もしなくてよい。出力された URL を開く。CLI に開かせたいなら `--open`。 | -| `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.`(exit `1`) | Claude Code の厳格ゲート。`CLAUDECODE=1` が設定されていて `--agent` が渡されていない。何も起動されず、何も書き込まれていない。 | `arkor dev --agent` で再実行し、出力されたセッションファイルを使う。 | +| `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.`(exit `1`) | Claude Code の厳格ゲート。`CLAUDECODE=1` が設定されていて `--agent` が渡されていない。サーバーは起動されず、トークンファイルもセッションファイルも書き込まれていない。 | `arkor dev --agent` で再実行し、出力されたセッションファイルを使う。 | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.`(exit `1`) | エージェントモードが `.arkor/agent/` またはセッションファイルを作成できなかった(読み取り専用のプロジェクトディレクトリー、権限の問題)。home のトークンと異なりこれは致命的で、ファイルはエージェント唯一のトークン経路。 | プロジェクトディレクトリーを書き込み可能にして(`.arkor/` の権限を確認)再実行。 | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.`(`` に到達できませんでした(fetch 失敗)。Studio は起動を続け、初回の `/api/credentials` 受信時に再試行します) | `/v1/auth/cli/config` は成功したが、続く `/v1/auth/anonymous` がトランスポート障害で失敗。Studio サーバーは起動して再試行する。 | 接続を回復させれば `arkor dev` を再起動せずに次の `/api/credentials` ポーリングで SPA が回復。 | | `TypeError: fetch failed`(または同等のトランスポートエラーで `arkor dev` がそのまま終了する場合) | `/v1/auth/cli/config` 自体に届かなかったため、デプロイモードが特定できず fail-fast。 | 接続を回復してから `arkor dev` を再実行。 | diff --git a/e2e/studio/src/specs/agent.spec.ts b/e2e/studio/src/specs/agent.spec.ts index 6d9e7b63..484e8f6e 100644 --- a/e2e/studio/src/specs/agent.spec.ts +++ b/e2e/studio/src/specs/agent.spec.ts @@ -83,6 +83,12 @@ test.describe("Agent mode contract", () => { }); test("SIGINT removes the session file", async ({ agentStudio }) => { + // Node's Windows signal emulation terminates the child forcefully; + // the dev.ts SIGINT handler never runs, so the unlink cannot happen. + test.skip( + process.platform === "win32", + "Windows signal emulation kills the child without running handlers", + ); const sessionFile = agentStudio.sessionFile!; expect(existsSync(sessionFile)).toBe(true); await agentStudio.kill(); diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 608a355b..d94f82d9 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -990,6 +990,12 @@ describe("runDev", () => { }); it("aborts startup (hard-fail) when the session file cannot be written", async () => { + if (process.platform === "win32") { + // chmod maps to the read-only attribute on Windows, which does not + // block creating entries inside the directory, so the failure this + // test injects never happens there. + return; + } if (typeof process.getuid === "function" && process.getuid() === 0) { // Root bypasses chmod permission checks; skip on root containers. return; @@ -1025,6 +1031,36 @@ describe("runDev", () => { await runDevCapturingStdout({ port: 4314, cwd: projectDir }); expect(existsSync(join(projectDir, ".arkor"))).toBe(false); }); + + it("sweeps every session file for this pid on cleanup but leaves other pids alone", async () => { + const stdout = await runDevCapturingStdout({ + port: 4315, + agent: true, + cwd: projectDir, + }); + const sessionPath = sessionPathFrom(stdout); + const dir = join(projectDir, ".arkor", "agent"); + // Simulate the two windows the pid-prefix sweep exists for: a file + // whose rename landed but whose `agentSessionPath` assignment never + // ran (signal race), and an abandoned .tmp staging file. + const stray = join(dir, `session-${process.pid}-deadbeef.json`); + const strayTmp = join( + dir, + `session-${process.pid}-deadbeef.json.${process.pid}.x.tmp`, + ); + writeFileSync(stray, "{}"); + writeFileSync(strayTmp, "{}"); + // A parallel session under a different pid must survive the sweep. + const otherPid = join(dir, `session-${process.pid + 1}-cafebabe.json`); + writeFileSync(otherPid, "{}"); + + const cleanup = process.listeners("exit").at(-1) as () => void; + cleanup(); + expect(existsSync(sessionPath)).toBe(false); + expect(existsSync(stray)).toBe(false); + expect(existsSync(strayTmp)).toBe(false); + expect(existsSync(otherPid)).toBe(true); + }); }); describe("port-collision connect (EADDRINUSE probe)", () => { diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index 77e0107c..f2490ea5 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -1,5 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { readFileSync, unlinkSync } from "node:fs"; +import { readdirSync, readFileSync, unlinkSync } from "node:fs"; import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises"; import { constants as osConstants } from "node:os"; import { dirname, join } from "node:path"; @@ -367,10 +367,15 @@ export async function runDev(options: DevOptions = {}): Promise { // `autoAnonymous: true` (the default) lets the Hono server retry the // anonymous bootstrap on first `/api/credentials` hit if the up-front // attempt above failed (e.g. cloud-api was unreachable at launch). + // `cwd` keeps the server's project root (/api/train file resolution, + // /api/status `cwd`) aligned with where the agent session file lands + // when tests pin `options.cwd`; in production both default to + // `process.cwd()` either way. const app = buildStudioApp({ studioToken, mode: agent ? "agent" : "studio", url, + cwd: projectRoot, }); await new Promise((resolve, reject) => { @@ -381,10 +386,12 @@ export async function runDev(options: DevOptions = {}): Promise { // token is still being persisted hits an already-serving server, so // treating it as a startup failure would kill a healthy instance. let bound = false; - // Absolute path of the agent-mode session file, assigned only after the - // atomic rename landed it. A signal mid-write therefore abandons only - // the disposable .tmp staging file, never a half-written session file, - // and the cleanup below can unlink unconditionally. + // Absolute path of the agent-mode session file, assigned after the + // atomic rename landed it; used for the stdout contract lines. The + // cleanup below does NOT depend on it: a signal can land after the + // rename placed the file but before this assignment runs, so the + // shutdown sweep matches on the pid-prefixed filename instead (same + // reasoning as the home token's direct path resolution above it). let agentSessionPath: string | undefined; // Bind FIRST, then persist the studio token and register its cleanup // in the `listening` callback (after a successful bind). The token @@ -427,21 +434,32 @@ export async function runDev(options: DevOptions = {}): Promise { } catch { // best-effort } - // The agent session file needs no ownership check: its name is - // per-launch unique (pid + uuid), so this path is always ours. - if (agentSessionPath !== undefined) { + // Agent session files need no ownership check: filenames embed + // this process's pid (plus a uuid), so every `session--…` + // entry in the agent dir is ours. Sweeping by pid prefix (rather + // than unlinking `agentSessionPath`) also covers the two windows + // a path variable cannot: a signal landing after the rename + // placed the file but before the assignment ran, and an + // abandoned `.tmp` staging file (its name carries the same + // prefix). Parallel sessions are unaffected: they run under + // different pids. + if (agent) { try { - unlinkSync(agentSessionPath); + const agentDir = join(projectRoot, ".arkor", "agent"); + for (const name of readdirSync(agentDir)) { + if (name.startsWith(`session-${process.pid}-`)) { + try { + unlinkSync(join(agentDir, name)); + } catch { + // best-effort + } + } + } } catch { - // best-effort + // best-effort (the dir may not exist yet) } } }); - // Persisting the token to disk is *only* needed for the Vite SPA - // dev workflow. The bundled `:port` flow injects the meta tag at - // request time via `buildStudioApp`, so a failure here (read-only - // $HOME on Docker / locked-down CI / restrictive umask) must not - // block the server. void (async () => { if (agent) { // The session file is the agent's only realistic token channel @@ -475,6 +493,12 @@ export async function runDev(options: DevOptions = {}): Promise { return; } } + // Persisting the token to `~/.arkor/studio-token` is needed for + // the Vite SPA dev workflow and the port-collision probe. The + // bundled `:port` flow injects the meta tag at request time via + // `buildStudioApp`, so a failure here (read-only $HOME on Docker + // / locked-down CI / restrictive umask) must not block the + // server. try { await persistStudioToken(studioToken); } catch (err) { From 1692adbc9076084e2bb5a261d82e30eb2249c432 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:00:51 -0700 Subject: [PATCH 03/19] fix(ENG-967): address code-review findings for arkor dev --agent Follow-up hardening from an adversarial multi-angle review of the agent-mode work (ca19a4e). Findings verified by refutation before fixing. Probe / security (GET /api/status is now the one token-EXEMPT route, still behind the loopback + Host guard; it is secrets-free and never runs user code): - The port-collision probe no longer transmits the CSRF token to the port occupant before confirming it is Arkor Studio (token-disclosure regression). It hits the token-free /api/status over 127.0.0.1 (matching the bind, not localhost, avoiding the ::1-first hazard). - Adoption now requires the occupant to serve THIS project (its /api/status cwd, realpath-compared, equals the launch project root), so a plain `arkor dev` in project B never silently attaches to project A's Studio on the same default port. - Dropping the shared-home-token dependency also fixes the last-writer-wins multi-session failure. Session-file lifecycle: - Compute the session path up front and unlink exactly that file (+ its deterministic .tmp) on shutdown, instead of a pid-prefix sweep that would delete a co-located live session sharing the pid (two containers bind-mounting one project, both pid 1). - Create the parent .arkor with the default mode; only the agent leaf is 0700 (a recursive mkdir mode:0700 had tightened .arkor itself). - Reap dead-pid session files on startup so a crashed prior session does not strand a stale token; docs recommend `ls -t` (newest) over head-1. Test infra / telemetry: - seedFixture realpaths the tmp dirs so the child-derived session path matches on macOS (/var -> /private/var) and Windows 8.3 short names. - `dev` telemetry longRunning is now a predicate: the connect-and-exit-0 outcome emits cli_command_completed; the serving outcome stays long. - Harness env sanitiser + spawnDevToExit helper de-duplicate the spawn scaffolding; agent.spec uses them and adds a different-project case. Also folds in the dev-command error-output cleanup this depends on: ExpectedCliError (bin.ts prints it stack-free) backing both parseDevPort (reject an invalid --port instead of coercing to 4000) and the agent session-file hard-fail. Docs (EN + JA) and AGENTS.md updated. --- AGENTS.md | 4 +- docs/cli/dev.mdx | 15 +- docs/ja/cli/dev.mdx | 15 +- e2e/studio/src/harness/seedFixture.ts | 15 +- e2e/studio/src/harness/studioServer.ts | 100 +++++--- e2e/studio/src/specs/agent.spec.ts | 105 ++++---- packages/arkor/src/bin.ts | 8 +- packages/arkor/src/cli/commands/dev.test.ts | 185 +++++++------- packages/arkor/src/cli/commands/dev.ts | 262 +++++++++++++------- packages/arkor/src/cli/main.test.ts | 17 +- packages/arkor/src/cli/main.ts | 46 +++- packages/arkor/src/core/telemetry.test.ts | 34 ++- packages/arkor/src/core/telemetry.ts | 13 +- packages/arkor/src/studio/server.test.ts | 31 ++- packages/arkor/src/studio/server.ts | 12 + packages/cli-internal/src/errors.ts | 15 ++ packages/cli-internal/src/index.ts | 1 + 17 files changed, 559 insertions(+), 319 deletions(-) create mode 100644 packages/cli-internal/src/errors.ts diff --git a/AGENTS.md b/AGENTS.md index 66ad389c..416dd114 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,10 +67,10 @@ cd my-arkor-app && pnpm dev # Studio at http://127.0. `arkor dev` generates a 32-byte base64url token per launch ([packages/arkor/src/cli/commands/dev.ts](packages/arkor/src/cli/commands/dev.ts)) and: -1. Passes it to `buildStudioApp({ studioToken })`. The Hono server validates every `/api/*` request via `X-Arkor-Studio-Token` header (or `?studioToken=` query for `EventSource`, which can't set headers). Comparison uses `timingSafeEqual`. +1. Passes it to `buildStudioApp({ studioToken })`. The Hono server validates every `/api/*` request via `X-Arkor-Studio-Token` header (or `?studioToken=` query for `EventSource`, which can't set headers). Comparison uses `timingSafeEqual`. **One carve-out:** `GET /api/status` is token-exempt (still behind the loopback + Host guard) because it is secrets-free and never executes user code; this lets the port-collision probe confirm an occupant without transmitting the token to an unverified peer (see item 4). 2. Persists it to `~/.arkor/studio-token` (mode 0600) so the SPA dev workflow (`pnpm --filter @arkor/studio-app dev`) can read it via the `arkor-studio-token` Vite plugin in [packages/studio-app/vite.config.ts](packages/studio-app/vite.config.ts), which injects `` into `index.html` on each request. Persistence failure must NOT block server start (read-only `$HOME` on Docker, etc.); just warn. 3. Cleans up on `exit`/SIGINT/SIGTERM/SIGHUP via `unlinkSync`, after verifying the file still holds this instance's token (a second `arkor dev` on another port may have overwritten it; last writer wins and the previous owner must not delete it). Signal handlers exit with the conventional `128 + signal` code (SIGINT -> 130, SIGTERM -> 143, SIGHUP -> 129) so supervisors can distinguish signal termination from a clean exit. -4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (dir 0700, file 0600, atomic temp+rename) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel); the same shutdown handlers remove it WITHOUT a content check by sweeping every `session--*` entry in the agent dir (pid prefix = unambiguous ownership, and the sweep also catches a signal landing between the rename and the path-variable assignment, plus abandoned `.tmp` staging files). Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. `GET /api/status` is the token-guarded, secrets-free probe (`server: "arkor-studio"` discriminator, never executes user code); normal-mode `arkor dev` on a busy port probes it with the home token and, on confirmation, prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. +4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). On startup, agent mode best-effort reaps `session--*.json` files whose pid is dead (`process.kill(pid, 0)` -> ESRCH) so a crashed prior session does not strand a stale token; live/foreign pids (EPERM) are left alone. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` (127.0.0.1, matching the bind, not `localhost`) WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. `/api/*` middleware also enforces a host-header allow-list (`127.0.0.1`/`localhost`) for DNS-rebinding defence. **CORS is intentionally NOT configured**: the SPA is same-origin so reflecting `*` would let "simple" cross-origin POSTs reach handlers. The token check rejects those; cross-origin tabs cannot read the SPA's ``. diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index c52d6daf..7060d8e5 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -69,9 +69,9 @@ Everything in the launch sequence above happens unchanged, plus: Read the token from that file and send it as the X-Arkor-Studio-Token header on /api/* requests. ``` -3. **Status probe.** `GET /api/status` (token required, like every `/api/*` route) returns safe metadata only: `status`, `server: "arkor-studio"`, `version`, `mode` (`"agent"` here), `url`, `pid`, `cwd`, and the list of available `endpoints`. It never includes credentials or the CSRF token and never executes project code, so it is the right first call to confirm the server and discover the API surface. +3. **Status probe.** `GET /api/status` is the one **token-exempt** `/api/*` route (still behind the loopback + Host guard): it is secrets-free and never executes project code, so a coding agent (and the port-collision probe) can read it without the token. It returns `status`, `server: "arkor-studio"`, `version`, `mode` (`"agent"` here), `url`, `pid`, `cwd`, and the list of available `endpoints`. It never includes credentials or the CSRF token, so it is the right first call to confirm the server and discover the API surface. Every OTHER `/api/*` route still requires the `X-Arkor-Studio-Token` header. -The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP`. A crash (`SIGKILL`, power loss) can leave `session-*.json` files behind; they are inert (the token died with the server) and safe to delete. +The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP` (exactly this launch's file, by its known unique path, so a co-located session that happens to share the pid is never touched). A crash (`SIGKILL`, power loss) can leave a `session-*.json` behind; it is inert (the token died with the server), and the next `arkor dev --agent` reaps dead-pid session files on startup. The Studio SPA is still served in agent mode: a human can open the printed URL in a browser while the agent works, and every guard from "Loopback and CSRF model" below applies unchanged. @@ -105,7 +105,7 @@ This means `arkor dev` is safe on a shared dev machine: another tab cannot read `arkor dev` does not auto-pick a free port. What happens on a busy port depends on the mode: -- **Normal mode connects to a running Studio.** Before failing, the CLI probes the occupant: it reads `~/.arkor/studio-token` and calls `GET /api/status` with it (1.5 s timeout). If the response confirms an Arkor Studio (`server: "arkor-studio"`), the CLI prints `Arkor Studio already running on http://localhost:`, honors `--open` against that URL, and exits `0` without starting a second server. The typical case: an `arkor dev --agent` session owns the port and you want the browser UI; the running server already serves the SPA, so connecting is enough. If the probe fails for any reason (no token file, stale token, timeout, or the occupant is not a Studio), the CLI exits non-zero with ``Port is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` +- **Normal mode connects to a running Studio.** Before failing, the CLI probes the occupant: it calls the token-exempt `GET http://127.0.0.1:/api/status` (127.0.0.1, matching the bind, not `localhost`; 1.5 s timeout; no token is sent, so nothing is disclosed to the port occupant). It adopts the instance only when the response confirms an Arkor Studio (`server: "arkor-studio"`) **serving the same project** (the reported `cwd`, resolved through symlinks, equals this launch's project root). On a match the CLI prints `Arkor Studio already running on http://localhost:`, honors `--open` against that URL, and exits `0` without starting a second server. The typical case: an `arkor dev --agent` session owns the port and you want the browser UI; the running server already serves the SPA, so connecting is enough. If the occupant serves a **different** project, is not a Studio, or the probe times out, the CLI exits non-zero with ``Port is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` - **Agent mode always errors.** `arkor dev --agent` on a busy port fails with the same port-in-use message without probing: an agent session needs its own server process and its own session file, so adopting an existing instance would hand the agent a token it does not control. Pass `--port` to start a second session. The token file (`~/.arkor/studio-token`) is written only **after** the port binds successfully, so a failed second launch on a busy port never overwrites or deletes the token a healthy first instance depends on. @@ -115,7 +115,7 @@ The token file (`~/.arkor/studio-token`) is written only **after** the port bind | Symptom | What it means | Fix | | --- | --- | --- | | ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` | Another process holds the port (the underlying error is `EADDRINUSE`) and, in normal mode, the running-Studio probe did not confirm an Arkor Studio. In agent mode this fires without probing. | Stop it, or use `--port `. | -| `Arkor Studio already running on http://localhost:` (exit `0`) | Normal mode found a healthy Arkor Studio on the port (often an `arkor dev --agent` session) and connected to it instead of failing. Informational, not an error. | Nothing required. Open the printed URL; pass `--open` to have the CLI do it. | +| `Arkor Studio already running on http://localhost:` (exit `0`) | Normal mode found a healthy Arkor Studio serving THIS project on the port (often an `arkor dev --agent` session) and connected to it instead of failing. Informational, not an error. | Nothing required. Open the printed URL; pass `--open` to have the CLI do it. | | `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.` (exit `1`) | The Claude Code strict gate: `CLAUDECODE=1` is set and `--agent` was not passed. No server was started and no token or session file was written. | Re-run as `arkor dev --agent` and use the printed session file. | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.` (exit `1`) | Agent mode could not create `.arkor/agent/` or the session file (read-only project dir, permission problem). Unlike the home token this is fatal: the file is the agent's only token channel. | Make the project directory writable (check `.arkor/` permissions) and re-run. | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.` | `/v1/auth/cli/config` already succeeded, but the follow-up `/v1/auth/anonymous` hit a transport error. The Studio server starts and will retry. | Bring connectivity back; the SPA recovers on its next `/api/credentials` poll without restarting `arkor dev`. | @@ -197,12 +197,13 @@ bun dev --agent -Then, from the agent's shell, read the token out of the session file and confirm the server. Prefer the exact path printed on stdout; the glob below works for a single-session project: +Then, from the agent's shell, read the token out of the session file and confirm the server. Prefer the exact path printed on stdout. If you must glob, select the **newest** file (`ls -t`), never the lexically first: a crashed prior session can leave an older `session-*.json` behind, and picking it would hand you a dead token. (`arkor dev --agent` also reaps dead-pid session files on startup, so stale files are cleared on the next launch.) ```bash -SESSION_FILE=$(ls .arkor/agent/session-*.json | head -n 1) +SESSION_FILE=$(ls -t .arkor/agent/session-*.json | head -n 1) TOKEN=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token' "$SESSION_FILE") -curl -H "X-Arkor-Studio-Token: $TOKEN" http://localhost:4000/api/status +curl http://localhost:4000/api/status # token-free health probe +curl -H "X-Arkor-Studio-Token: $TOKEN" http://localhost:4000/api/jobs # token-guarded routes ``` ## See also diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 866c8a6d..0c9d16be 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -69,9 +69,9 @@ bun dev Read the token from that file and send it as the X-Arkor-Studio-Token header on /api/* requests. ``` -3. **ステータスプローブ。** `GET /api/status`(他の `/api/*` ルートと同様にトークン必須)は安全なメタデータのみを返します。`status`、`server: "arkor-studio"`、`version`、`mode`(ここでは `"agent"`)、`url`、`pid`、`cwd`、利用可能な `endpoints` の一覧です。認証情報や CSRF トークンを含むことはなく、プロジェクトのコードを実行することもないため、サーバーの確認と API サーフェスの発見に最初に呼ぶべきエンドポイントです。 +3. **ステータスプローブ。** `GET /api/status` は唯一の**トークン不要**な `/api/*` ルートです(ループバック + Host ガードは適用)。機密ゼロでプロジェクトのコードを実行しないため、コーディングエージェント(およびポート競合プローブ)はトークンなしで読めます。`status`、`server: "arkor-studio"`、`version`、`mode`(ここでは `"agent"`)、`url`、`pid`、`cwd`、利用可能な `endpoints` の一覧を返します。認証情報や CSRF トークンを含むことはないため、サーバーの確認と API サーフェスの発見に最初に呼ぶべきエンドポイントです。他の `/api/*` ルートは引き続き `X-Arkor-Studio-Token` ヘッダーが必須です。 -セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます。クラッシュ(`SIGKILL`、電源断)では `session-*.json` が残ることがありますが、トークンはサーバーとともに死んでいるため無害で、削除して安全です。 +セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます(この起動の固有パスのファイルだけを消すので、pid をたまたま共有する別セッションには触れません)。クラッシュ(`SIGKILL`、電源断)では `session-*.json` が残ることがありますが、トークンはサーバーとともに死んでいるため無害で、次回の `arkor dev --agent` が起動時に pid の死んだセッションファイルを回収します。 エージェントモードでも Studio SPA は引き続き配信されます。エージェントが作業している間、人間は出力された URL をブラウザーで開けますし、下の「ループバックと CSRF モデル」のガードもすべてそのまま適用されます。 @@ -105,7 +105,7 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ `arkor dev` は空きポートの自動採用はしません。使用中ポートでの挙動はモードで異なります。 -- **通常モードは起動中の Studio に接続します。** 失敗させる前に、CLI は占有者をプローブします。`~/.arkor/studio-token` を読み、それを付けて `GET /api/status` を呼びます(タイムアウト 1.5 秒)。レスポンスが Arkor Studio であること(`server: "arkor-studio"`)を確認できたら、CLI は `Arkor Studio already running on http://localhost:` を出力し、その URL に対して `--open` を尊重し、2 つ目のサーバーを起動せずに exit code `0` で終了します。典型例は `arkor dev --agent` セッションがポートを保持していて、人間がブラウザー UI を使いたい場合です。起動中のサーバーは SPA も配信しているので、接続するだけで十分です。プローブが何らかの理由で失敗した場合(トークンファイルなし、古いトークン、タイムアウト、占有者が Studio ではない)は、従来どおり ``Port is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート `` は既に使用中です。別の `arkor dev` が動いているかもしれません。`--port` で別のポートを選んでください)で非ゼロ終了します。 +- **通常モードは起動中の Studio に接続します。** 失敗させる前に、CLI は占有者をプローブします。トークン不要の `GET http://127.0.0.1:/api/status` を呼びます(バインドに合わせ `localhost` でなく 127.0.0.1、タイムアウト 1.5 秒、トークンは送らないので占有者に何も漏れません)。レスポンスが Arkor Studio であること(`server: "arkor-studio"`)**かつ同一プロジェクトを提供していること**(返される `cwd` をシンボリックリンク解決した結果が、この起動のプロジェクトルートと一致)を確認できたときだけ採用します。一致すれば CLI は `Arkor Studio already running on http://localhost:` を出力し、その URL に対して `--open` を尊重し、2 つ目のサーバーを起動せずに exit code `0` で終了します。典型例は `arkor dev --agent` セッションがポートを保持していて、人間がブラウザー UI を使いたい場合です。起動中のサーバーは SPA も配信しているので、接続するだけで十分です。占有者が**別プロジェクト**を提供している、Studio でない、あるいはプローブがタイムアウトした場合は、``Port is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート `` は既に使用中です。別の `arkor dev` が動いているかもしれません。`--port` で別のポートを選んでください)で非ゼロ終了します。 - **エージェントモードは常にエラーです。** `arkor dev --agent` は使用中ポートに対してプローブせず、同じ port-in-use メッセージで失敗します。エージェントセッションには固有のサーバープロセスと固有のセッションファイルが必要で、既存インスタンスに相乗りするとエージェントが管理していないトークンを渡すことになるためです。`--port` で 2 つ目のセッションを起動してください。 トークンファイル(`~/.arkor/studio-token`)はポートのバインド成功 **後** にのみ書き込まれるため、使用中ポートへの 2 つ目の起動が失敗しても、正常に動いている 1 つ目のインスタンスが依存するトークンを上書き・削除することはありません。 @@ -115,7 +115,7 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | 症状 | 意味 | 対処 | | --- | --- | --- | | ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート 4000 は既に使用中。根本のエラーは `EADDRINUSE`) | 別プロセスがポートを保持していて、通常モードでは起動中 Studio プローブが Arkor Studio を確認できなかった。エージェントモードではプローブなしでこのエラーになる。 | 止めるか `--port `。 | -| `Arkor Studio already running on http://localhost:`(exit `0`) | 通常モードがポート上で健全な Arkor Studio(多くは `arkor dev --agent` セッション)を発見し、失敗ではなく接続扱いにした。エラーではなく情報行。 | 何もしなくてよい。出力された URL を開く。CLI に開かせたいなら `--open`。 | +| `Arkor Studio already running on http://localhost:`(exit `0`) | 通常モードがポート上でこのプロジェクトを提供する健全な Arkor Studio(多くは `arkor dev --agent` セッション)を発見し、失敗ではなく接続扱いにした。エラーではなく情報行。 | 何もしなくてよい。出力された URL を開く。CLI に開かせたいなら `--open`。 | | `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.`(exit `1`) | Claude Code の厳格ゲート。`CLAUDECODE=1` が設定されていて `--agent` が渡されていない。サーバーは起動されず、トークンファイルもセッションファイルも書き込まれていない。 | `arkor dev --agent` で再実行し、出力されたセッションファイルを使う。 | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.`(exit `1`) | エージェントモードが `.arkor/agent/` またはセッションファイルを作成できなかった(読み取り専用のプロジェクトディレクトリー、権限の問題)。home のトークンと異なりこれは致命的で、ファイルはエージェント唯一のトークン経路。 | プロジェクトディレクトリーを書き込み可能にして(`.arkor/` の権限を確認)再実行。 | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.`(`` に到達できませんでした(fetch 失敗)。Studio は起動を続け、初回の `/api/credentials` 受信時に再試行します) | `/v1/auth/cli/config` は成功したが、続く `/v1/auth/anonymous` がトランスポート障害で失敗。Studio サーバーは起動して再試行する。 | 接続を回復させれば `arkor dev` を再起動せずに次の `/api/credentials` ポーリングで SPA が回復。 | @@ -197,12 +197,13 @@ bun dev --agent -続いてエージェントのシェルから、セッションファイルのトークンを読んでサーバーを確認します。stdout に出力された正確なパスを使うのが第一候補で、下の glob はセッションが 1 つだけのプロジェクトで機能します。 +続いてエージェントのシェルから、セッションファイルのトークンを読んでサーバーを確認します。stdout に出力された正確なパスを使うのが第一候補です。glob する場合は辞書順の先頭ではなく **最新**(`ls -t`)を選んでください。クラッシュした前回セッションが古い `session-*.json` を残していることがあり、それを拾うと死んだトークンを掴みます。(`arkor dev --agent` は起動時に pid が死んでいるセッションファイルを回収するので、古いファイルは次回起動で消えます。) ```bash -SESSION_FILE=$(ls .arkor/agent/session-*.json | head -n 1) +SESSION_FILE=$(ls -t .arkor/agent/session-*.json | head -n 1) TOKEN=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token' "$SESSION_FILE") -curl -H "X-Arkor-Studio-Token: $TOKEN" http://localhost:4000/api/status +curl http://localhost:4000/api/status # トークン不要のヘルスプローブ +curl -H "X-Arkor-Studio-Token: $TOKEN" http://localhost:4000/api/jobs # トークン必須のルート ``` ## 関連項目 diff --git a/e2e/studio/src/harness/seedFixture.ts b/e2e/studio/src/harness/seedFixture.ts index d730c63e..f301dccd 100644 --- a/e2e/studio/src/harness/seedFixture.ts +++ b/e2e/studio/src/harness/seedFixture.ts @@ -1,4 +1,10 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,7 +16,12 @@ export interface FixturePaths { } export function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)); + // realpathSync canonicalizes the tmp path so it matches what a spawned + // child's `process.cwd()` (getcwd) reports. Without this, macOS + // (`/var` -> `/private/var`) and the Windows 8.3 short-name form make the + // parent's `mkdtemp` path and the child-derived session path diverge, so + // any `startsWith`/equality check across the process boundary fails. + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); } export function cleanup(dir: string): void { diff --git a/e2e/studio/src/harness/studioServer.ts b/e2e/studio/src/harness/studioServer.ts index 3f0691fa..ffa6a35b 100644 --- a/e2e/studio/src/harness/studioServer.ts +++ b/e2e/studio/src/harness/studioServer.ts @@ -205,29 +205,81 @@ interface SpawnedStudio { sessionFile?: string; } -async function spawnStudio(opts: StartStudioOptions): Promise { - const port = await getEphemeralPort(); - // `runCli` in e2e/cli also strips parent's `npm_config_*` to keep the - // child's pm detection deterministic; for `arkor dev` it doesn't - // matter (it never installs anything), but mirroring the policy - // avoids a class of surprises if the CLI later starts reading those. +/** + * Build the child env for a spawned `arkor dev`, shared by `spawnStudio` + * (long-running) and `spawnDevToExit` (run-to-exit). Strips the parent's + * `npm_config_*`/`pnpm_config_*` (keep the child's pm detection hermetic) and + * `CLAUDECODE` (case-insensitively: `arkor dev` refuses to start under + * `CLAUDECODE=1` without `--agent`, so an inherited value from a Claude Code + * session must not leak into the child and flip the gate; same policy as + * `e2e/cli/src/spawn-cli.ts`), then layers the hermetic-E2E overrides. + */ +function studioChildEnv(opts: StartStudioOptions): NodeJS.ProcessEnv { const cleanEnv: NodeJS.ProcessEnv = {}; for (const [k, v] of Object.entries(process.env)) { const lower = k.toLowerCase(); - if (lower.startsWith("npm_config_") || lower.startsWith("pnpm_config_")) { - continue; - } - // `arkor dev` refuses to start under CLAUDECODE=1 without --agent - // (strict gate, ENG-967). The harness spawns both modes explicitly, - // so an inherited CLAUDECODE (vitest/playwright launched from a - // Claude Code session) must not leak into the child and flip the - // gate on tests that spawn plain `arkor dev`. Same policy as - // e2e/cli/src/spawn-cli.ts. - if (lower === "claudecode") { + if ( + lower.startsWith("npm_config_") || + lower.startsWith("pnpm_config_") || + lower === "claudecode" + ) { continue; } cleanEnv[k] = v; } + return { + ...cleanEnv, + CI: "1", + HOME: opts.home, + // Mirror HOME onto USERPROFILE so the spawned CLI's `os.homedir()` + // resolves to the test temp dir on Windows too (see the matching + // comment in e2e/cli/src/spawn-cli.ts). + USERPROFILE: opts.home, + ARKOR_CLOUD_API_URL: opts.cloudApiUrl, + // Defence in depth: if anything anonymous-bootstrapped slips through + // credential pre-seeding, telemetry posts must not hit the real PostHog + // endpoint from CI. + ARKOR_TELEMETRY_DISABLED: "1", + npm_config_user_agent: "", + }; +} + +/** + * Spawn `arkor dev` with the shared hermetic env and the given extra args, + * and resolve once the child exits. For run-to-exit flows (e.g. a plain + * `arkor dev` on a busy port that connects to a running Studio and exits 0), + * which `startStudio` cannot model because it waits for a ready line and + * returns a live handle. + */ +export function spawnDevToExit( + opts: StartStudioOptions, + extraArgs: string[] = [], +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ARKOR_BIN, "dev", ...extraArgs], { + cwd: opts.projectDir, + stdio: ["ignore", "pipe", "pipe"], + env: studioChildEnv(opts), + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (d: string) => { + stdout += d; + }); + child.stderr.on("data", (d: string) => { + stderr += d; + }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +async function spawnStudio(opts: StartStudioOptions): Promise { + const port = await getEphemeralPort(); const child = spawn( process.execPath, [ @@ -240,21 +292,7 @@ async function spawnStudio(opts: StartStudioOptions): Promise { { cwd: opts.projectDir, stdio: ["ignore", "pipe", "pipe"], - env: { - ...cleanEnv, - CI: "1", - HOME: opts.home, - // Mirror HOME onto USERPROFILE so the spawned CLI's `os.homedir()` - // resolves to the test temp dir on Windows too (see the matching - // comment in e2e/cli/src/spawn-cli.ts). - USERPROFILE: opts.home, - ARKOR_CLOUD_API_URL: opts.cloudApiUrl, - // Defence in depth: if anything anonymous-bootstrapped slips - // through credential pre-seeding, telemetry posts must not hit - // the real PostHog endpoint from CI. - ARKOR_TELEMETRY_DISABLED: "1", - npm_config_user_agent: "", - }, + env: studioChildEnv(opts), }, ); diff --git a/e2e/studio/src/specs/agent.spec.ts b/e2e/studio/src/specs/agent.spec.ts index 484e8f6e..093e5919 100644 --- a/e2e/studio/src/specs/agent.spec.ts +++ b/e2e/studio/src/specs/agent.spec.ts @@ -1,13 +1,12 @@ -import { spawn } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { ARKOR_BIN } from "../harness/bins"; import { expect, test } from "../harness/fixture"; +import { spawnDevToExit } from "../harness/studioServer"; // Agent mode (`arkor dev --agent`, ENG-967): the browser-free contract a // coding agent relies on. Covers the stdout session-file line, the JSON -// session file itself (shape + POSIX modes), the token-guarded +// session file itself (shape + POSIX modes), the token-exempt // GET /api/status probe, the SPA staying reachable for humans, SIGINT // cleanup, and a second plain `arkor dev` connecting to the running // instance instead of failing on the busy port. @@ -48,13 +47,13 @@ test.describe("Agent mode contract", () => { } }); - test("GET /api/status answers the session-file token with safe agent metadata", async ({ + test("GET /api/status is token-exempt and returns safe agent metadata", async ({ agentStudio, }) => { - const payload = readSession(agentStudio.sessionFile!); - const res = await fetch(`${agentStudio.url}/api/status`, { - headers: { "X-Arkor-Studio-Token": payload.token }, - }); + // No token header: /api/status is the one token-exempt route (so the + // port-collision probe can confirm the occupant without disclosing the + // CSRF token). It stays secrets-free. + const res = await fetch(`${agentStudio.url}/api/status`); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.status).toBe("ok"); @@ -62,13 +61,17 @@ test.describe("Agent mode contract", () => { expect(body.mode).toBe("agent"); expect(body.endpoints).toContain("POST /api/train"); // Safe by contract: the CSRF token must never round-trip in the body. - expect(JSON.stringify(body)).not.toContain(payload.token); + expect(JSON.stringify(body)).not.toContain( + readSession(agentStudio.sessionFile!).token, + ); }); - test("GET /api/status still 403s without the token header", async ({ + test("a token-guarded route still 403s without the token header", async ({ agentStudio, }) => { - const res = await fetch(`${agentStudio.url}/api/status`); + // The token exemption is scoped to /api/status only; the rest of /api/* + // still requires the header. + const res = await fetch(`${agentStudio.url}/api/credentials`); expect(res.status).toBe(403); }); @@ -100,63 +103,47 @@ test.describe("Agent mode contract", () => { fixturePaths, cloudApi, }) => { - // The agent session owns the port and wrote ~/.arkor/studio-token - // (fixture HOME); the plain launch probes /api/status with it, - // confirms the occupant is an Arkor Studio, prints the URL, and + // The agent session owns the port and serves this project; the plain + // launch probes 127.0.0.1/api/status (no token), confirms the occupant + // is an Arkor Studio serving the SAME project, prints the URL, and // exits 0 instead of EADDRINUSE-failing. const port = new URL(agentStudio.url).port; - const env: NodeJS.ProcessEnv = {}; - for (const [k, v] of Object.entries(process.env)) { - const lower = k.toLowerCase(); - if ( - lower.startsWith("npm_config_") || - lower.startsWith("pnpm_config_") || - lower === "claudecode" - ) { - continue; - } - env[k] = v; - } - const result = await new Promise<{ code: number | null; stdout: string }>( - (resolve, reject) => { - const child = spawn( - process.execPath, - [ARKOR_BIN, "dev", "--port", port], - { - cwd: fixturePaths.projectDir, - stdio: ["ignore", "pipe", "pipe"], - env: { - ...env, - CI: "1", - HOME: fixturePaths.home, - USERPROFILE: fixturePaths.home, - ARKOR_CLOUD_API_URL: cloudApi.baseUrl, - ARKOR_TELEMETRY_DISABLED: "1", - npm_config_user_agent: "", - }, - }, - ); - let stdout = ""; - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (d: string) => { - stdout += d; - }); - child.on("error", reject); - child.on("close", (code) => { - resolve({ code, stdout }); - }); + const result = await spawnDevToExit( + { + home: fixturePaths.home, + projectDir: fixturePaths.projectDir, + cloudApiUrl: cloudApi.baseUrl, }, + ["--port", port], ); expect(result.code).toBe(0); expect(result.stdout).toContain( `Arkor Studio already running on http://localhost:${port}`, ); // The running agent session is untouched. - const res = await fetch(`${agentStudio.url}/api/status`, { - headers: { - "X-Arkor-Studio-Token": readSession(agentStudio.sessionFile!).token, - }, - }); + const res = await fetch(`${agentStudio.url}/api/status`); expect(res.status).toBe(200); }); + + test("a plain `arkor dev` for a DIFFERENT project does not adopt this port", async ({ + agentStudio, + fixturePaths, + cloudApi, + }) => { + // Project-match guard: an occupant serving another project must NOT be + // adopted. Spawn from a different cwd (the fixture HOME dir, which is not + // the project root) targeting the busy port; the probe's cwd check fails, + // so the launch falls back to the hard port-in-use error. + const port = new URL(agentStudio.url).port; + const result = await spawnDevToExit( + { + home: fixturePaths.home, + projectDir: fixturePaths.home, + cloudApiUrl: cloudApi.baseUrl, + }, + ["--port", port], + ); + expect(result.code).not.toBe(0); + expect(result.stderr).toContain(`Port ${port} is already in use`); + }); }); diff --git a/packages/arkor/src/bin.ts b/packages/arkor/src/bin.ts index 6399a4a0..3c31f2d7 100644 --- a/packages/arkor/src/bin.ts +++ b/packages/arkor/src/bin.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from "node:process"; -import { ClaudeCodeStrictExit } from "@arkor/cli-internal"; +import { ClaudeCodeStrictExit, ExpectedCliError } from "@arkor/cli-internal"; import { main } from "./cli/main"; @@ -21,6 +21,12 @@ try { // just set the exit code without re-printing the message or its stack. if (err instanceof ClaudeCodeStrictExit) { process.exitCode = 1; + } else if (err instanceof ExpectedCliError) { + // The message is already user-facing, actionable copy; print it alone + // (no stack) so the minified bundle never dumps a code-frame for a + // routine failure such as an invalid `--port`. + console.error(err.message); + process.exitCode = 1; } else { console.error( err instanceof Error ? (err.stack ?? err.message) : String(err), diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index d94f82d9..c983944b 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -664,7 +664,9 @@ describe("runDev", () => { .spyOn(process.stdout, "write") .mockImplementation((() => true) as typeof process.stdout.write); try { - await expect(runDev({ port: 4202, open: true })).resolves.toBeUndefined(); + await expect(runDev({ port: 4202, open: true })).resolves.toEqual({ + adopted: false, + }); } finally { stdoutSpy.mockRestore(); } @@ -684,7 +686,9 @@ describe("runDev", () => { .mockImplementation((() => true) as typeof process.stdout.write); try { const sigtermBefore = process.listeners("SIGTERM").length; - await expect(runDev({ port: 4203 })).resolves.toBeUndefined(); + await expect(runDev({ port: 4203 })).resolves.toEqual({ + adopted: false, + }); expect(serve).toHaveBeenCalledTimes(1); // Regression (ENG-933 self-review): shutdown handlers must be installed // even when token persistence fails, so a SIGTERM (`docker stop`) still @@ -777,7 +781,9 @@ describe("runDev", () => { .spyOn(process.stdout, "write") .mockImplementation((() => true) as typeof process.stdout.write); try { - await expect(runDev({ port: 4208 })).resolves.toBeUndefined(); + await expect(runDev({ port: 4208 })).resolves.toEqual({ + adopted: false, + }); } finally { stdoutSpy.mockRestore(); } @@ -945,7 +951,7 @@ describe("runDev", () => { expect(open).not.toHaveBeenCalled(); }); - it("creates the directory 0700 and the file 0600 with no .tmp strays", async () => { + it("tightens only the agent leaf to 0700 (not the parent .arkor) and writes the file 0600 with no .tmp strays", async () => { if (process.platform === "win32") { // POSIX modes are a no-op on Windows. return; @@ -957,10 +963,18 @@ describe("runDev", () => { }); const sessionPath = sessionPathFrom(stdout); const { statSync } = await import("node:fs"); - const dir = join(projectDir, ".arkor", "agent"); - expect(statSync(dir).mode & 0o777).toBe(0o700); + const agentDir = join(projectDir, ".arkor", "agent"); + const dotArkor = join(projectDir, ".arkor"); + expect(statSync(agentDir).mode & 0o777).toBe(0o700); expect(statSync(sessionPath).mode & 0o777).toBe(0o600); - const strays = readdirSync(dir).filter((f) => f.includes(".tmp")); + // The parent .arkor must NOT be forced 0700: it uses the default mode, + // matching `arkor build`/state.ts. Compare against a control dir created + // the same way so the assertion is umask-independent. + mkdirSync(join(projectDir, "control-dir"), { recursive: true }); + expect(statSync(dotArkor).mode & 0o777).toBe( + statSync(join(projectDir, "control-dir")).mode & 0o777, + ); + const strays = readdirSync(agentDir).filter((f) => f.includes(".tmp")); expect(strays).toEqual([]); }); @@ -1032,7 +1046,7 @@ describe("runDev", () => { expect(existsSync(join(projectDir, ".arkor"))).toBe(false); }); - it("sweeps every session file for this pid on cleanup but leaves other pids alone", async () => { + it("cleanup removes only this launch's session file (+ its .tmp), never a co-located session sharing this pid", async () => { const stdout = await runDevCapturingStdout({ port: 4315, agent: true, @@ -1040,30 +1054,49 @@ describe("runDev", () => { }); const sessionPath = sessionPathFrom(stdout); const dir = join(projectDir, ".arkor", "agent"); - // Simulate the two windows the pid-prefix sweep exists for: a file - // whose rename landed but whose `agentSessionPath` assignment never - // ran (signal race), and an abandoned .tmp staging file. - const stray = join(dir, `session-${process.pid}-deadbeef.json`); - const strayTmp = join( - dir, - `session-${process.pid}-deadbeef.json.${process.pid}.x.tmp`, - ); - writeFileSync(stray, "{}"); - writeFileSync(strayTmp, "{}"); - // A parallel session under a different pid must survive the sweep. - const otherPid = join(dir, `session-${process.pid + 1}-cafebabe.json`); - writeFileSync(otherPid, "{}"); + // A co-located session file that happens to share this pid but a + // different uuid: this is a DIFFERENT live session (real when two + // containers bind-mount the same project, both running as pid 1). The + // old pid-prefix sweep deleted it; the exact-path cleanup must not. + const otherSameP = join(dir, `session-${process.pid}-otheruuid.json`); + writeFileSync(otherSameP, "{}"); + // A stray .tmp for OUR session path (a signal-mid-write remnant) is ours. + writeFileSync(`${sessionPath}.tmp`, "{}"); const cleanup = process.listeners("exit").at(-1) as () => void; cleanup(); expect(existsSync(sessionPath)).toBe(false); - expect(existsSync(stray)).toBe(false); - expect(existsSync(strayTmp)).toBe(false); - expect(existsSync(otherPid)).toBe(true); + expect(existsSync(`${sessionPath}.tmp`)).toBe(false); + // The co-located same-pid session is untouched. + expect(existsSync(otherSameP)).toBe(true); + }); + + it("reaps dead-pid session files on startup but keeps live ones", async () => { + const dir = join(projectDir, ".arkor", "agent"); + mkdirSync(dir, { recursive: true }); + // A pid above any real max_pid -> process.kill(pid, 0) throws ESRCH. + const dead = join(dir, `session-999999999-dead.json`); + // The parent process is alive -> process.kill(ppid, 0) succeeds. + const live = join(dir, `session-${process.ppid}-live.json`); + writeFileSync(dead, "{}"); + writeFileSync(live, "{}"); + await runDevCapturingStdout({ port: 4316, agent: true, cwd: projectDir }); + expect(existsSync(dead)).toBe(false); + expect(existsSync(live)).toBe(true); }); }); describe("port-collision connect (EADDRINUSE probe)", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "arkor-dev-probe-proj-")); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + /** serve stub that skips the listening callback and fires EADDRINUSE. */ function mockServeAddrInUse(closeSpy?: () => void): void { vi.mocked(serve).mockImplementationOnce((() => { @@ -1086,24 +1119,28 @@ describe("runDev", () => { }) as unknown as typeof serve); } - function seedHomeToken(token: string): void { - mkdirSync(join(fakeHome, ".arkor"), { recursive: true }); - writeFileSync(studioTokenPath(), token); + /** Stub the /api/status probe response (JSON body). */ + function mockProbe( + body: unknown, + init?: ResponseInit, + ): ReturnType { + const fetchSpy = vi.fn(async () => Response.json(body, init)); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + return fetchSpy; } - it("connects to a confirmed running Studio: resolves, prints the URL, registers no cleanup", async () => { + it("connects to a running Studio serving THIS project: resolves adopted, prints the URL, registers no cleanup, sends no token", async () => { mockServeAddrInUse(); - seedHomeToken("running-instance-token"); - const fetchSpy = vi.fn(async () => - Response.json({ status: "ok", server: "arkor-studio" }), - ); - globalThis.fetch = fetchSpy as unknown as typeof fetch; + // Occupant reports it is an Arkor Studio serving the same project root. + const fetchSpy = mockProbe({ server: "arkor-studio", cwd: projectDir }); const exitBefore = process.listeners("exit").length; const stdoutSpy = vi .spyOn(process.stdout, "write") .mockImplementation((() => true) as typeof process.stdout.write); try { - await expect(runDev({ port: 4320 })).resolves.toBeUndefined(); + await expect(runDev({ port: 4320, cwd: projectDir })).resolves.toEqual({ + adopted: true, + }); const stdout = stdoutSpy.mock.calls.map((c) => String(c[0])).join(""); expect(stdout).toContain( "Arkor Studio already running on http://localhost:4320", @@ -1111,100 +1148,80 @@ describe("runDev", () => { } finally { stdoutSpy.mockRestore(); } - // Probe hit /api/status with the home token in the header. + // Probe hit /api/status over 127.0.0.1 (not localhost) with NO token + // header (the endpoint is token-exempt; nothing is disclosed). const [reqUrl, reqInit] = fetchSpy.mock.calls[0] as unknown as [ URL, - RequestInit, + RequestInit | undefined, ]; - expect(String(reqUrl)).toBe("http://localhost:4320/api/status"); - expect( - (reqInit.headers as Record)["x-arkor-studio-token"], - ).toBe("running-instance-token"); - // The running instance's token is untouched and no cleanup handler was - // registered by this launch. - expect(readFileSync(studioTokenPath(), "utf8")).toBe( - "running-instance-token", - ); + expect(String(reqUrl)).toBe("http://127.0.0.1:4320/api/status"); + expect(reqInit?.headers).toBeUndefined(); + // No shutdown handler was registered by this doomed launch. expect(process.listeners("exit").length).toBe(exitBefore); }); it("honors --open against the existing instance", async () => { mockServeAddrInUse(); - seedHomeToken("running-instance-token"); - globalThis.fetch = vi.fn(async () => - Response.json({ status: "ok", server: "arkor-studio" }), - ) as unknown as typeof fetch; + mockProbe({ server: "arkor-studio", cwd: projectDir }); const stdoutSpy = vi .spyOn(process.stdout, "write") .mockImplementation((() => true) as typeof process.stdout.write); try { await expect( - runDev({ port: 4321, open: true }), - ).resolves.toBeUndefined(); + runDev({ port: 4321, open: true, cwd: projectDir }), + ).resolves.toEqual({ adopted: true }); } finally { stdoutSpy.mockRestore(); } expect(open).toHaveBeenCalledWith("http://localhost:4321"); }); - it("falls back to the port-in-use error when no home token exists (probe skipped)", async () => { + it("falls back to the port-in-use error when the occupant serves a DIFFERENT project", async () => { mockServeAddrInUse(); - const fetchSpy = vi.fn(); - globalThis.fetch = fetchSpy as unknown as typeof fetch; - await expect(runDev({ port: 4322 })).rejects.toThrow( - /Port 4322 is already in use/, - ); - expect(fetchSpy).not.toHaveBeenCalled(); + // Same-machine Arkor Studio, but a different project root -> do NOT adopt. + const otherProject = mkdtempSync(join(tmpdir(), "arkor-dev-other-")); + try { + mockProbe({ server: "arkor-studio", cwd: otherProject }); + await expect(runDev({ port: 4322, cwd: projectDir })).rejects.toThrow( + /Port 4322 is already in use/, + ); + } finally { + rmSync(otherProject, { recursive: true, force: true }); + } }); - it("falls back when the occupant rejects the token (403)", async () => { + it("falls back when the probe gets a non-200 (e.g. a non-Studio occupant)", async () => { mockServeAddrInUse(); - seedHomeToken("stale-token"); - globalThis.fetch = vi.fn(async () => - Response.json( - { error: "Missing or invalid studio token" }, - { - status: 403, - }, - ), - ) as unknown as typeof fetch; - await expect(runDev({ port: 4323 })).rejects.toThrow( + mockProbe({ error: "nope" }, { status: 404 }); + await expect(runDev({ port: 4323, cwd: projectDir })).rejects.toThrow( /Port 4323 is already in use/, ); }); it("falls back when the probe request itself fails (timeout / refused)", async () => { mockServeAddrInUse(); - seedHomeToken("some-token"); globalThis.fetch = vi.fn(async () => { throw new Error("The operation was aborted due to timeout"); }) as unknown as typeof fetch; - await expect(runDev({ port: 4324 })).rejects.toThrow( + await expect(runDev({ port: 4324, cwd: projectDir })).rejects.toThrow( /Port 4324 is already in use/, ); }); it("falls back when the occupant is not an Arkor Studio (missing discriminator)", async () => { mockServeAddrInUse(); - seedHomeToken("some-token"); - globalThis.fetch = vi.fn(async () => - Response.json({ status: "ok", server: "something-else" }), - ) as unknown as typeof fetch; - await expect(runDev({ port: 4325 })).rejects.toThrow( + mockProbe({ status: "ok", server: "something-else", cwd: projectDir }); + await expect(runDev({ port: 4325, cwd: projectDir })).rejects.toThrow( /Port 4325 is already in use/, ); }); it("agent mode never probes: EADDRINUSE stays a hard error", async () => { mockServeAddrInUse(); - seedHomeToken("running-instance-token"); - const fetchSpy = vi.fn(async () => - Response.json({ status: "ok", server: "arkor-studio" }), - ); - globalThis.fetch = fetchSpy as unknown as typeof fetch; - await expect(runDev({ port: 4326, agent: true })).rejects.toThrow( - /Port 4326 is already in use/, - ); + const fetchSpy = mockProbe({ server: "arkor-studio", cwd: projectDir }); + await expect( + runDev({ port: 4326, agent: true, cwd: projectDir }), + ).rejects.toThrow(/Port 4326 is already in use/); expect(fetchSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index f2490ea5..6e59fd00 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -1,9 +1,10 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { readdirSync, readFileSync, unlinkSync } from "node:fs"; +import { readdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs"; import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises"; import { constants as osConstants } from "node:os"; import { dirname, join } from "node:path"; +import { ExpectedCliError } from "@arkor/cli-internal"; import { serve } from "@hono/node-server"; import open from "open"; @@ -223,25 +224,25 @@ interface AgentSessionPayload { pid: number; } +/** `/.arkor/agent`. */ +function agentDirFor(projectRoot: string): string { + return join(projectRoot, ".arkor", "agent"); +} + /** - * Write the agent-mode session file: - * `/.arkor/agent/session--.json` (directory 0700, - * file 0600). The filename is per-launch unique so parallel sessions in the - * same project coexist and shutdown can unlink its own path without the - * content ownership check the shared home token needs. Uses the same - * atomic temp+rename staging as `persistStudioToken` so a crash mid-write - * never leaves a truncated JSON body at the final path. + * Create the agent session directory. The intermediate `.arkor` is created + * with the DEFAULT mode (matching `arkor build`'s `.arkor/build` and + * `state.ts`), and only the `agent` leaf is tightened to 0700. A single + * `mkdir(agentDir, { recursive: true, mode: 0o700 })` would also apply 0700 + * to a freshly-created `.arkor`, diverging from every other creator and + * breaking cross-uid traversal of `.arkor/build` when agent mode happens to + * be the first command to create `.arkor`. */ -async function writeAgentSessionFile( - projectRoot: string, - payload: AgentSessionPayload, -): Promise { - const dir = join(projectRoot, ".arkor", "agent"); - await mkdir(dir, { recursive: true, mode: 0o700 }); +async function ensureAgentDir(projectRoot: string): Promise { + const dir = agentDirFor(projectRoot); + await mkdir(join(projectRoot, ".arkor"), { recursive: true }); + await mkdir(dir, { recursive: true }); try { - // `mkdir`'s `mode` only applies to directories it creates; tighten a - // pre-existing dir too. Best-effort: on exotic mounts where chmod fails, - // the 0600 file mode below is the last line of defence for the token. await chmod(dir, 0o700); } catch (err) { ui.log.warn( @@ -250,8 +251,58 @@ async function writeAgentSessionFile( }`, ); } - const path = join(dir, `session-${process.pid}-${randomUUID()}.json`); - const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`; + return dir; +} + +/** + * Best-effort: remove session files whose owning pid is dead so a crashed + * prior `arkor dev --agent` does not strand a stale-token file that the docs' + * `ls session-*.json` glob could pick up. `process.kill(pid, 0)` probes + * liveness: ESRCH -> dead -> reap; EPERM -> alive under another uid -> keep; + * our own pid and unparseable names are skipped. Never touches a live + * session, so it is safe to run on every agent launch. + */ +function reapDeadAgentSessions(dir: string): void { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return; + } + for (const name of names) { + const match = /^session-(\d+)-.*\.json$/.exec(name); + if (!match) continue; + const pid = Number(match[1]); + if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue; + try { + process.kill(pid, 0); + // No error -> the pid is alive; leave its session file alone. + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ESRCH") { + try { + unlinkSync(join(dir, name)); + } catch { + // best-effort + } + } + // EPERM (or anything else) -> assume alive/uncertain; keep the file. + } + } +} + +/** + * Atomically write the agent-mode session file to `path` (a pre-computed, + * per-launch-unique `session--.json`, mode 0600). The caller owns + * `path` up front (see `runDev`) so the shutdown handler can unlink exactly + * this path even if a signal lands mid-write; that is why the temp name is + * the deterministic `${path}.tmp` rather than a uuid sibling (the path itself + * already carries a unique uuid, so no cross-launch collision is possible). + */ +async function writeAgentSessionFile( + path: string, + payload: AgentSessionPayload, +): Promise { + const tmp = `${path}.tmp`; try { await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600, @@ -271,40 +322,44 @@ async function writeAgentSessionFile( await rm(tmp, { force: true }).catch(() => undefined); throw err; } - return path; } /** - * True when `url` is answered by a running Arkor Studio instance. Used by - * the normal-mode EADDRINUSE path so a plain `arkor dev` connects to an - * already-running Studio (typically an `arkor dev --agent` session that owns - * the port) instead of failing. Reads `~/.arkor/studio-token` (agent mode - * still writes it; last-writer-wins) and calls the token-guarded - * `GET /api/status`, keying on its `server: "arkor-studio"` discriminator so - * an unrelated occupant that happens to 200 on that path is not adopted. - * Best-effort by design: any failure (no token file, stale token, timeout, - * non-Studio occupant) reports false and the caller falls back to the - * existing port-in-use error. + * True when the busy `port` is held by a running Arkor Studio that serves + * THIS project. Used by the normal-mode EADDRINUSE path so a plain + * `arkor dev` connects to an already-running Studio (typically an + * `arkor dev --agent` session that owns the port) instead of failing. + * + * The probe hits the token-EXEMPT `GET /api/status` over `127.0.0.1` (not + * `localhost`, matching the server's IPv4 bind and avoiding the `::1`-first + * resolution the module goes out of its way to dodge). No token is sent: the + * endpoint is secrets-free, so we never disclose the CSRF token to an + * unverified port occupant. Adoption requires both the `server: + * "arkor-studio"` discriminator AND that the instance's `cwd` (realpath) + * equals this launch's project root, so a plain `arkor dev` in project B + * never silently attaches to project A's Studio on the same default port. + * Best-effort: any failure (timeout, non-Studio occupant, different project, + * unreadable cwd) reports false and the caller falls back to the existing + * port-in-use error. */ -async function probeExistingStudio(url: string): Promise { - let token: string; - try { - token = readFileSync(studioTokenPath(), "utf8"); - } catch { - return false; - } +async function probeExistingStudio( + port: number, + projectRoot: string, +): Promise { try { - const res = await fetch(new URL("/api/status", url), { - headers: { "x-arkor-studio-token": token }, - signal: AbortSignal.timeout(1500), - }); + const res = await fetch( + new URL("/api/status", `http://127.0.0.1:${port}`), + { signal: AbortSignal.timeout(1500) }, + ); if (!res.ok) return false; const body: unknown = await res.json(); - return ( - typeof body === "object" && - body !== null && - (body as { server?: unknown }).server === "arkor-studio" - ); + if (typeof body !== "object" || body === null) return false; + const b = body as { server?: unknown; cwd?: unknown }; + if (b.server !== "arkor-studio") return false; + if (typeof b.cwd !== "string") return false; + // Compare realpaths so a symlinked project root (e.g. macOS + // `/var` -> `/private/var`) still matches the same project. + return realpathSync(b.cwd) === realpathSync(projectRoot); } catch { return false; } @@ -343,12 +398,35 @@ function installShutdownHandlers(cleanup: () => void): void { } } -export async function runDev(options: DevOptions = {}): Promise { +export interface RunDevResult { + /** + * True when this launch found a healthy Studio already serving the project + * on the requested port and connected to it (printed "already running" and + * will exit) instead of starting its own server. False when this process + * bound the port and is now serving. Lets the telemetry wrapper emit a + * `cli_command_completed` for the short-lived connect outcome while still + * treating the serving outcome as long-running. + */ + adopted: boolean; +} + +export async function runDev(options: DevOptions = {}): Promise { await ensureCredentialsForStudio(); const port = options.port ?? 4000; const agent = options.agent === true; const projectRoot = options.cwd ?? process.cwd(); + const agentDir = agentDirFor(projectRoot); + // Compute the session-file path UP FRONT (per-launch-unique via uuid) so the + // shutdown handler can unlink exactly this path even if a signal lands + // mid-write, and so cleanup never has to sweep the dir by pid prefix (which + // would delete a co-located live session sharing this pid, e.g. two + // containers bind-mounting the same project both running as pid 1). + const agentSessionPath = agent + ? join(agentDir, `session-${process.pid}-${randomUUID()}.json`) + : undefined; + // Set true only on the normal-mode "connect to a running Studio" path. + let adopted = false; // Per-launch CSRF token: injected into index.html as , required on // every /api/* request. Prevents another tab on the same machine from // hitting `arkor start` (and therefore RCE via dynamic import). @@ -386,13 +464,6 @@ export async function runDev(options: DevOptions = {}): Promise { // token is still being persisted hits an already-serving server, so // treating it as a startup failure would kill a healthy instance. let bound = false; - // Absolute path of the agent-mode session file, assigned after the - // atomic rename landed it; used for the stdout contract lines. The - // cleanup below does NOT depend on it: a signal can land after the - // rename placed the file but before this assignment runs, so the - // shutdown sweep matches on the pid-prefixed filename instead (same - // reasoning as the home token's direct path resolution above it). - let agentSessionPath: string | undefined; // Bind FIRST, then persist the studio token and register its cleanup // in the `listening` callback (after a successful bind). The token // file (`~/.arkor/studio-token`) is a single shared path, so a second @@ -434,62 +505,60 @@ export async function runDev(options: DevOptions = {}): Promise { } catch { // best-effort } - // Agent session files need no ownership check: filenames embed - // this process's pid (plus a uuid), so every `session--…` - // entry in the agent dir is ours. Sweeping by pid prefix (rather - // than unlinking `agentSessionPath`) also covers the two windows - // a path variable cannot: a signal landing after the rename - // placed the file but before the assignment ran, and an - // abandoned `.tmp` staging file (its name carries the same - // prefix). Parallel sessions are unaffected: they run under - // different pids. - if (agent) { - try { - const agentDir = join(projectRoot, ".arkor", "agent"); - for (const name of readdirSync(agentDir)) { - if (name.startsWith(`session-${process.pid}-`)) { - try { - unlinkSync(join(agentDir, name)); - } catch { - // best-effort - } - } + // Remove exactly this launch's session file (and its deterministic + // `.tmp` staging sibling, in case a signal landed mid-write). The + // path is per-launch-unique and known up front, so no pid-prefix + // sweep is needed: sweeping would delete a co-located LIVE session + // that happens to share this pid (two containers bind-mounting the + // same project, both pid 1). ENOENT (never written / already gone) + // is swallowed. + if (agentSessionPath !== undefined) { + for (const p of [agentSessionPath, `${agentSessionPath}.tmp`]) { + try { + unlinkSync(p); + } catch { + // best-effort } - } catch { - // best-effort (the dir may not exist yet) } } }); void (async () => { - if (agent) { + if (agent && agentSessionPath !== undefined) { // The session file is the agent's only realistic token channel // (an agent does not scrape the tag the SPA reads), so // unlike the home token below a write failure aborts startup // instead of degrading: a warn-and-continue server would be // silently unusable to its own caller. try { - agentSessionPath = await writeAgentSessionFile(projectRoot, { + await ensureAgentDir(projectRoot); + // Best-effort: clear session files left by crashed prior + // launches (dead pid) so the docs' `ls session-*.json` glob + // never hands an agent a stale, already-403ing token. + reapDeadAgentSessions(agentDir); + await writeAgentSessionFile(agentSessionPath, { token: studioToken, url, port, pid: process.pid, }); } catch (err) { - ui.log.error( - `Could not write the agent session file under ${join( - projectRoot, - ".arkor", - "agent", - )} (${ - err instanceof Error ? err.message : String(err) - }). Agent mode requires it; aborting.`, - ); try { server.close(); } catch { // best-effort } - reject(err instanceof Error ? err : new Error(String(err))); + // Reject with an ExpectedCliError so bin.ts prints this + // actionable line ALONE (no minified stack). The previous flow + // logged the message via ui.log.error AND rejected with the raw + // fs Error, so bin.ts also dumped a code-frame: a routine + // read-only-`.arkor` failure printed twice, once as noise. + reject( + new ExpectedCliError( + `Could not write the agent session file under ${agentDir} (${ + err instanceof Error ? err.message : String(err) + }). Agent mode requires it; aborting.`, + ), + ); return; } } @@ -557,20 +626,21 @@ export async function runDev(options: DevOptions = {}): Promise { } // Normal mode: the occupant may be a healthy Studio, typically an // `arkor dev --agent` session that owns the port. Probe the token- - // guarded /api/status with the home token (agent mode still writes - // it) and, when confirmed, "connect": print the URL and resolve so - // the caller can honor --open against the existing instance and - // exit 0. Bind-first ordering guarantees this launch wrote no files - // and registered no shutdown handlers, so resolving here cannot - // disturb the running instance. + // exempt /api/status over 127.0.0.1 (no token disclosed) and adopt it + // only when it is an Arkor Studio serving THIS project; then "connect": + // print the URL and resolve so the caller can honor --open against the + // existing instance and exit 0. Bind-first ordering guarantees this + // launch wrote no files and registered no shutdown handlers, so + // resolving here cannot disturb the running instance. void (async () => { - const ok = await probeExistingStudio(url); + const ok = await probeExistingStudio(port, projectRoot); if (ok) { try { server.close(); } catch { // best-effort } + adopted = true; process.stdout.write(`Arkor Studio already running on ${url}\n`); resolve(); return; @@ -590,4 +660,6 @@ export async function runDev(options: DevOptions = {}): Promise { // fall through } } + + return { adopted }; } diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index 967523dc..75917a78 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -63,6 +63,8 @@ beforeEach(() => { vi.mocked(runBuild).mockReset(); vi.mocked(runStart).mockReset(); vi.mocked(runDev).mockReset(); + // runDev resolves to a RunDevResult; default to the serving outcome. + vi.mocked(runDev).mockResolvedValue({ adopted: false }); vi.mocked(shutdownTelemetry).mockReset(); vi.mocked(shutdownTelemetry).mockResolvedValue(undefined); mockDeprecation.value = null; @@ -209,15 +211,14 @@ describe("main (CLI Commander wiring)", () => { }); }); - it("falls back to port 4000 when --port is non-numeric", async () => { - // Branch coverage for the `Number(opts.port) || 4000` defaulting. + it("rejects a non-numeric --port instead of silently starting the server", async () => { + // `parseDevPort` validates the value before `runDev`, so a typo surfaces + // as a clear error rather than coercing to 4000 or reaching the listener. delete process.env.CLAUDECODE; - await main(["dev", "--port", "not-a-number"]); - expect(runDev).toHaveBeenCalledWith({ - port: 4000, - open: false, - agent: false, - }); + await expect(main(["dev", "--port", "not-a-number"])).rejects.toThrow( + /port/i, + ); + expect(runDev).not.toHaveBeenCalled(); }); it("under CLAUDECODE=1, `dev` without --agent throws ClaudeCodeStrictExit and never starts the server", async () => { diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index 240bc378..1df8da29 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -1,5 +1,6 @@ import { ClaudeCodeStrictExit, + ExpectedCliError, formatClaudeCodeAgentModeMessage, formatClaudeCodeMissingMessage, isClaudeCode, @@ -23,6 +24,28 @@ import { runStart } from "./commands/start"; import { runWhoami } from "./commands/whoami"; import { ui } from "./prompts"; +/** + * Parse and validate `arkor dev --port`. The previous `Number(opts.port) || + * 4000` silently coerced a typo (`--port abc` -> NaN -> 4000) and a legitimate + * ephemeral request (`--port 0` -> 4000), and let an out-of-range value reach + * `net.Server.listen`, which throws `ERR_SOCKET_BAD_PORT` synchronously before + * the friendly EADDRINUSE handler is even registered (surfacing a raw minified + * stack). Validate here so a bad value is a clear, actionable message. Port 0 + * (OS-assigned) is rejected on purpose: `arkor dev` prints and probes a fixed + * `http://localhost:` URL, so an ephemeral port has no supported flow. + */ +function parseDevPort(raw: string): number { + const n = Number(raw); + if (!Number.isInteger(n) || n < 1 || n > 65_535) { + throw new ExpectedCliError( + `--port must be an integer between 1 and 65535 (got ${JSON.stringify( + raw, + )}).`, + ); + } + return n; +} + export async function main(argv: string[]): Promise { const program = new Command(); program.name("arkor").description("Arkor CLI").version(SDK_VERSION); @@ -246,10 +269,16 @@ export async function main(argv: string[]): Promise { "--agent", "Run headlessly for coding agents: write a JSON session token file to .arkor/agent/ and print its path", ) - .action( - withTelemetry( + .action((opts: { port: string; open?: boolean; agent?: boolean }) => { + // `dev` is long-running when it serves, but a short-lived completion + // when it connects to an already-running Studio and exits. Track the + // outcome so telemetry emits `cli_command_completed` for the connect + // case (the `longRunning` predicate is evaluated after the handler + // resolves). Wrapped per-invocation so the closure flag is fresh. + let adopted = false; + return withTelemetry( "dev", - async (opts: { port: string; open?: boolean; agent?: boolean }) => { + async () => { // Under CLAUDECODE=1 a Studio launch without --agent is almost // always a mistake: the agent cannot drive a browser UI and the // long-running server would hang its shell. Require the explicit @@ -260,15 +289,16 @@ export async function main(argv: string[]): Promise { process.stderr.write(formatClaudeCodeAgentModeMessage("arkor dev")); throw new ClaudeCodeStrictExit(); } - await runDev({ - port: Number(opts.port) || 4000, + const result = await runDev({ + port: parseDevPort(opts.port), open: opts.open === true, agent: opts.agent === true, }); + adopted = result.adopted; }, - { longRunning: true }, - ), - ); + { longRunning: () => !adopted }, + )(); + }); try { await program.parseAsync(argv, { from: "user" }); diff --git a/packages/arkor/src/core/telemetry.test.ts b/packages/arkor/src/core/telemetry.test.ts index 538a1a4d..6e08dac2 100644 --- a/packages/arkor/src/core/telemetry.test.ts +++ b/packages/arkor/src/core/telemetry.test.ts @@ -45,7 +45,7 @@ interface TelemetryModule { withTelemetry: ( command: string, handler: (...args: TArgs) => Promise, - options?: { longRunning?: boolean }, + options?: { longRunning?: boolean | (() => boolean) }, ) => (...args: TArgs) => Promise; shutdownTelemetry: () => Promise; } @@ -402,6 +402,38 @@ describe("withTelemetry", () => { expect(captureMock.mock.calls[0][0].event).toBe("cli_command_started"); }); + it("evaluates a function-form longRunning AFTER the handler (dev connect-and-exit still reports completed)", async () => { + // `arkor dev` is long-running when it serves but a short-lived completion + // when it connects to an already-running Studio and exits. The predicate + // form lets the handler decide per-outcome: here it resolves to the + // adopted/connect outcome, so `cli_command_completed` must fire. + const mod = await loadTelemetry({ key: "phc_test" }); + let adopted = false; + const wrapped = mod.withTelemetry( + "dev", + async () => { + adopted = true; + }, + { longRunning: () => !adopted }, + ); + await wrapped(); + const events = captureMock.mock.calls.map((c) => c[0].event); + expect(events).toEqual(["cli_command_started", "cli_command_completed"]); + }); + + it("evaluates a function-form longRunning that stays true (dev serving skips completed)", async () => { + const mod = await loadTelemetry({ key: "phc_test" }); + // Never reassigned: the handler serves (does not "adopt"), so the + // predicate stays true and `cli_command_completed` is skipped. + const adopted = false; + const wrapped = mod.withTelemetry("dev", async () => {}, { + longRunning: () => !adopted, + }); + await wrapped(); + expect(captureMock).toHaveBeenCalledTimes(1); + expect(captureMock.mock.calls[0][0].event).toBe("cli_command_started"); + }); + it("does not double-initialise the PostHog client across multiple wrapped calls", async () => { // The lazy `getClient` returns the cached instance after the first // safeCapture; later calls (started + completed of a 2nd command) diff --git a/packages/arkor/src/core/telemetry.ts b/packages/arkor/src/core/telemetry.ts index 06f3cf74..6a0f06d5 100644 --- a/packages/arkor/src/core/telemetry.ts +++ b/packages/arkor/src/core/telemetry.ts @@ -174,7 +174,12 @@ export interface TelemetryOptions { // `cli_command_completed` (which would otherwise report a near-zero // duration_ms while the session is still live); `cli_command_started` still // fires, and a failure during bring-up still emits `cli_command_failed`. - longRunning?: boolean; + // + // A function form is evaluated AFTER the handler resolves, so a command + // whose long-running-ness depends on the outcome can decide per-invocation + // (e.g. `arkor dev` is long-running when it serves, but a short-lived + // completion when it connects to an already-running Studio and exits). + longRunning?: boolean | (() => boolean); } export function withTelemetry( @@ -205,7 +210,11 @@ export function withTelemetry( } try { await handler(...args); - if (identity && !options.longRunning) { + const longRunning = + typeof options.longRunning === "function" + ? options.longRunning() + : options.longRunning; + if (identity && !longRunning) { safeCapture(identity.distinctId, "cli_command_completed", { ...baseProps, duration_ms: Date.now() - start, diff --git a/packages/arkor/src/studio/server.test.ts b/packages/arkor/src/studio/server.test.ts index b818ddc3..5ab9f103 100644 --- a/packages/arkor/src/studio/server.test.ts +++ b/packages/arkor/src/studio/server.test.ts @@ -264,36 +264,43 @@ describe("Studio server", () => { }); describe("GET /api/status", () => { - it("rejects requests without the studio token", async () => { + it("is token-exempt: returns 200 WITHOUT a studio token", async () => { + // The one token-exempt /api/* route, so the port-collision probe can + // confirm an occupant without disclosing the CSRF token. Still behind + // the loopback Host guard (asserted below). const app = build(); const res = await app.request("/api/status", { headers: { host: "127.0.0.1:4000" }, }); - expect(res.status).toBe(403); + expect(res.status).toBe(200); }); - it("rejects non-loopback hosts", async () => { + it("still enforces the non-loopback Host guard (no token involved)", async () => { const app = build(); const res = await app.request("/api/status", { - headers: { - host: "evil.example:4000", - "x-arkor-studio-token": STUDIO_TOKEN, - }, + headers: { host: "evil.example:4000" }, }); expect(res.status).toBe(403); }); - it("returns safe metadata and the endpoint list with a valid token", async () => { + it("does not exempt other /api/* routes from the token check", async () => { + // Scope guard: the exemption is /api/status only. A sibling GET still + // 403s without the token. + const app = build(); + const res = await app.request("/api/credentials", { + headers: { host: "127.0.0.1:4000" }, + }); + expect(res.status).toBe(403); + }); + + it("returns safe metadata and the endpoint list", async () => { // No credentials are written and `autoAnonymous` is false: if the // handler ever touched the credential path, `getCredentials` would // throw and this request would 500. Passing proves the endpoint is // credentials-free. const app = build(); const res = await app.request("/api/status", { - headers: { - host: "127.0.0.1:4000", - "x-arkor-studio-token": STUDIO_TOKEN, - }, + headers: { host: "127.0.0.1:4000" }, }); expect(res.status).toBe(200); const body = (await res.json()) as Record; diff --git a/packages/arkor/src/studio/server.ts b/packages/arkor/src/studio/server.ts index 8cf82ae6..41a99bef 100644 --- a/packages/arkor/src/studio/server.ts +++ b/packages/arkor/src/studio/server.ts @@ -188,7 +188,19 @@ export function buildStudioApp(options: StudioServerOptions) { // 2. `?studioToken=` is accepted only on the job-event stream route // because `EventSource` cannot send custom headers. Mutation routes // require the header so a leaked token in a URL is not enough to POST. + // 3. `GET /api/status` is the ONE token-exempt route (still behind the + // loopback + Host guard above). It is secrets-free and never executes + // user code, so it can be read without the token. This lets the + // `arkor dev` port-collision probe confirm an occupant is Arkor Studio + // (and which project it serves) WITHOUT transmitting the CSRF token to + // an unverified port occupant, which would otherwise disclose the + // RCE-guarding token to a same-machine squatter. A cross-origin tab can + // issue the GET but cannot read the response (no CORS), so no metadata + // leaks across origins; the Host guard still blocks DNS rebinding. app.use("/api/*", async (c, next) => { + if (c.req.method === "GET" && c.req.path === "/api/status") { + return next(); + } const queryTokenAllowed = c.req.method === "GET" && jobEventsPathPattern.test(c.req.path); const provided = diff --git a/packages/cli-internal/src/errors.ts b/packages/cli-internal/src/errors.ts new file mode 100644 index 00000000..ad40beab --- /dev/null +++ b/packages/cli-internal/src/errors.ts @@ -0,0 +1,15 @@ +/** + * A CLI failure whose `message` is already user-facing, actionable copy. + * `bin.ts` prints `error.message` alone (no stack) and exits 1, so a routine + * failure (an invalid `--port`, an agent-mode session-file write that failed) + * never dumps the minified `dist/bin.mjs` code-frame Node would otherwise show + * at the throw site. Mirrors the `ClaudeCodeStrictExit` contract, except the + * message is printed by `bin.ts` here rather than pre-written to stderr by the + * caller. + */ +export class ExpectedCliError extends Error { + constructor(message: string) { + super(message); + this.name = "ExpectedCliError"; + } +} diff --git a/packages/cli-internal/src/index.ts b/packages/cli-internal/src/index.ts index a323589d..1314721a 100644 --- a/packages/cli-internal/src/index.ts +++ b/packages/cli-internal/src/index.ts @@ -27,6 +27,7 @@ export { type NodeModulesSnapshot, } from "./install"; export { gitInitialCommit, isInGitRepo, type InitialCommitResult } from "./git"; +export { ExpectedCliError } from "./errors"; export { sanitise } from "./sanitise"; export { ClaudeCodeStrictExit, From 28c64cc12541503623cdc8e0b4db50246165f153 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:28:24 -0700 Subject: [PATCH 04/19] fix(ENG-967): second-review fixes for arkor dev --agent Findings from a second adversarial review of 1692adb, verified by refutation. Correctness: - Remove the startup reapDeadAgentSessions sweep. Its process.kill(pid,0) liveness probe is pid-namespace-local, so when the project dir is a shared bind-mount across containers a LIVE foreign-container session file looks dead (ESRCH) and would be deleted out from under it, exactly the multi-container case the surrounding code claims to support. The documented `ls -t` (newest) recipe already prevents an agent from picking a stale token, so the sweep was redundant and only added risk. - The EADDRINUSE `portInUse` was a bare Error, so bin.ts dumped a minified dist stack for the most common dev failure. Make it an ExpectedCliError like the sibling agent-write hard-fail so bin.ts prints the actionable line alone. - Reject a non-absolute occupant cwd in probeExistingStudio: a hostile local occupant returning `cwd: "."` would otherwise resolve against the prober's own root and bypass the same-project adoption guard. Robustness: - withTelemetry now guards the function-form longRunning predicate: a throwing predicate no longer falls through to the failure path and mislabels a succeeded command. - spawnDevToExit gets a timeout+SIGKILL so a future caller that spawns a bind-and-serve launch cannot hang the suite. Docs/comments: - server.ts /api/status comment no longer claims the token guard applies (it is now token-exempt); notes the body must stay secrets-free. - studio/overview.mdx (+ JA) documents the /api/status token-exempt carve-out; guides/cli/dev.mdx (+ JA) adds the same-project qualifier to the connect summary; cli/dev.mdx (+ JA) and AGENTS.md drop the removed reap claim. Tests: drop the reap test (replaced by a no-sweep assertion), add a relative-cwd probe-rejection case and parseDevPort boundary coverage. --- AGENTS.md | 2 +- docs/cli/dev.mdx | 4 +- docs/guides/cli/dev.mdx | 2 +- docs/ja/cli/dev.mdx | 4 +- docs/ja/guides/cli/dev.mdx | 2 +- docs/ja/studio/overview.mdx | 2 +- docs/studio/overview.mdx | 2 +- e2e/studio/src/harness/studioServer.ts | 22 ++++++- packages/arkor/src/cli/commands/dev.test.ts | 37 ++++++++---- packages/arkor/src/cli/commands/dev.ts | 66 +++++++-------------- packages/arkor/src/cli/main.test.ts | 24 ++++++++ packages/arkor/src/core/telemetry.ts | 18 ++++-- packages/arkor/src/studio/server.ts | 9 ++- 13 files changed, 123 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 416dd114..4ea64b4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ cd my-arkor-app && pnpm dev # Studio at http://127.0. 1. Passes it to `buildStudioApp({ studioToken })`. The Hono server validates every `/api/*` request via `X-Arkor-Studio-Token` header (or `?studioToken=` query for `EventSource`, which can't set headers). Comparison uses `timingSafeEqual`. **One carve-out:** `GET /api/status` is token-exempt (still behind the loopback + Host guard) because it is secrets-free and never executes user code; this lets the port-collision probe confirm an occupant without transmitting the token to an unverified peer (see item 4). 2. Persists it to `~/.arkor/studio-token` (mode 0600) so the SPA dev workflow (`pnpm --filter @arkor/studio-app dev`) can read it via the `arkor-studio-token` Vite plugin in [packages/studio-app/vite.config.ts](packages/studio-app/vite.config.ts), which injects `` into `index.html` on each request. Persistence failure must NOT block server start (read-only `$HOME` on Docker, etc.); just warn. 3. Cleans up on `exit`/SIGINT/SIGTERM/SIGHUP via `unlinkSync`, after verifying the file still holds this instance's token (a second `arkor dev` on another port may have overwritten it; last writer wins and the previous owner must not delete it). Signal handlers exit with the conventional `128 + signal` code (SIGINT -> 130, SIGTERM -> 143, SIGHUP -> 129) so supervisors can distinguish signal termination from a clean exit. -4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). On startup, agent mode best-effort reaps `session--*.json` files whose pid is dead (`process.kill(pid, 0)` -> ESRCH) so a crashed prior session does not strand a stale token; live/foreign pids (EPERM) are left alone. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` (127.0.0.1, matching the bind, not `localhost`) WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. +4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). Agent mode does NOT sweep or reap other session files (an earlier pid-liveness reap was removed: `process.kill(pid, 0)` is namespace-local, so on a shared bind-mount a LIVE foreign-container session looks dead and would be deleted). A crashed session leaves an inert file; the docs recipe selects the newest (`ls -t`), which is always the live launch, so a stale leftover is never picked. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` (127.0.0.1, matching the bind, not `localhost`) WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. `/api/*` middleware also enforces a host-header allow-list (`127.0.0.1`/`localhost`) for DNS-rebinding defence. **CORS is intentionally NOT configured**: the SPA is same-origin so reflecting `*` would let "simple" cross-origin POSTs reach handlers. The token check rejects those; cross-origin tabs cannot read the SPA's ``. diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index 7060d8e5..fc9fda33 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -71,7 +71,7 @@ Everything in the launch sequence above happens unchanged, plus: 3. **Status probe.** `GET /api/status` is the one **token-exempt** `/api/*` route (still behind the loopback + Host guard): it is secrets-free and never executes project code, so a coding agent (and the port-collision probe) can read it without the token. It returns `status`, `server: "arkor-studio"`, `version`, `mode` (`"agent"` here), `url`, `pid`, `cwd`, and the list of available `endpoints`. It never includes credentials or the CSRF token, so it is the right first call to confirm the server and discover the API surface. Every OTHER `/api/*` route still requires the `X-Arkor-Studio-Token` header. -The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP` (exactly this launch's file, by its known unique path, so a co-located session that happens to share the pid is never touched). A crash (`SIGKILL`, power loss) can leave a `session-*.json` behind; it is inert (the token died with the server), and the next `arkor dev --agent` reaps dead-pid session files on startup. +The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP` (exactly this launch's file, by its known unique path, so a co-located session that happens to share the pid is never touched). A crash (`SIGKILL`, power loss) can leave a `session-*.json` behind; it is inert (the token died with the server) and safe to delete. `arkor dev --agent` does not sweep other session files on startup: a liveness check would be unsafe when the project dir is a shared bind-mount across containers (pids are namespace-local). Selecting the newest file (below) means a stale leftover is never picked anyway. The Studio SPA is still served in agent mode: a human can open the printed URL in a browser while the agent works, and every guard from "Loopback and CSRF model" below applies unchanged. @@ -197,7 +197,7 @@ bun dev --agent -Then, from the agent's shell, read the token out of the session file and confirm the server. Prefer the exact path printed on stdout. If you must glob, select the **newest** file (`ls -t`), never the lexically first: a crashed prior session can leave an older `session-*.json` behind, and picking it would hand you a dead token. (`arkor dev --agent` also reaps dead-pid session files on startup, so stale files are cleared on the next launch.) +Then, from the agent's shell, read the token out of the session file and confirm the server. Prefer the exact path printed on stdout. If you must glob, select the **newest** file (`ls -t`), never the lexically first: a crashed prior session can leave an older `session-*.json` behind, and picking it would hand you a dead token. The newest file is always this launch's live session. ```bash SESSION_FILE=$(ls -t .arkor/agent/session-*.json | head -n 1) diff --git a/docs/guides/cli/dev.mdx b/docs/guides/cli/dev.mdx index b601559b..505e5ff3 100644 --- a/docs/guides/cli/dev.mdx +++ b/docs/guides/cli/dev.mdx @@ -61,7 +61,7 @@ Add `--open` to open the browser automatically. ## Working with a coding agent? -`arkor dev --agent` runs the same server headlessly for coding agents (Claude Code and others): it writes a per-session JSON token file to `.arkor/agent/` inside the project, prints its path to stdout, and the agent drives Arkor through the `/api/*` endpoints directly (start with `GET /api/status`). Under `CLAUDECODE=1`, a plain `arkor dev` refuses to start and asks for `--agent` explicitly. The Studio UI stays available in a browser the whole time, and a second plain `arkor dev` on the same port simply connects to the running instance and exits. Details in the "Agent mode" section of the [`arkor dev` reference](/cli/dev). +`arkor dev --agent` runs the same server headlessly for coding agents (Claude Code and others): it writes a per-session JSON token file to `.arkor/agent/` inside the project, prints its path to stdout, and the agent drives Arkor through the `/api/*` endpoints directly (start with `GET /api/status`). Under `CLAUDECODE=1`, a plain `arkor dev` refuses to start and asks for `--agent` explicitly. The Studio UI stays available in a browser the whole time, and a second plain `arkor dev` on the same port connects to the running instance and exits when it is a Studio serving the same project (otherwise you get the usual port-in-use error). Details in the "Agent mode" section of the [`arkor dev` reference](/cli/dev). ## Reference diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 0c9d16be..b841eba6 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -71,7 +71,7 @@ bun dev 3. **ステータスプローブ。** `GET /api/status` は唯一の**トークン不要**な `/api/*` ルートです(ループバック + Host ガードは適用)。機密ゼロでプロジェクトのコードを実行しないため、コーディングエージェント(およびポート競合プローブ)はトークンなしで読めます。`status`、`server: "arkor-studio"`、`version`、`mode`(ここでは `"agent"`)、`url`、`pid`、`cwd`、利用可能な `endpoints` の一覧を返します。認証情報や CSRF トークンを含むことはないため、サーバーの確認と API サーフェスの発見に最初に呼ぶべきエンドポイントです。他の `/api/*` ルートは引き続き `X-Arkor-Studio-Token` ヘッダーが必須です。 -セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます(この起動の固有パスのファイルだけを消すので、pid をたまたま共有する別セッションには触れません)。クラッシュ(`SIGKILL`、電源断)では `session-*.json` が残ることがありますが、トークンはサーバーとともに死んでいるため無害で、次回の `arkor dev --agent` が起動時に pid の死んだセッションファイルを回収します。 +セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます(この起動の固有パスのファイルだけを消すので、pid をたまたま共有する別セッションには触れません)。クラッシュ(`SIGKILL`、電源断)では `session-*.json` が残ることがありますが、トークンはサーバーとともに死んでいるため無害で、削除して安全です。`arkor dev --agent` は起動時に他のセッションファイルを掃きません。プロジェクトディレクトリーが複数コンテナ間の共有 bind-mount の場合、pid は名前空間ローカルなので liveness チェックが安全でないためです。下記のように最新ファイルを選べば、古い残骸を掴むことはありません。 エージェントモードでも Studio SPA は引き続き配信されます。エージェントが作業している間、人間は出力された URL をブラウザーで開けますし、下の「ループバックと CSRF モデル」のガードもすべてそのまま適用されます。 @@ -197,7 +197,7 @@ bun dev --agent -続いてエージェントのシェルから、セッションファイルのトークンを読んでサーバーを確認します。stdout に出力された正確なパスを使うのが第一候補です。glob する場合は辞書順の先頭ではなく **最新**(`ls -t`)を選んでください。クラッシュした前回セッションが古い `session-*.json` を残していることがあり、それを拾うと死んだトークンを掴みます。(`arkor dev --agent` は起動時に pid が死んでいるセッションファイルを回収するので、古いファイルは次回起動で消えます。) +続いてエージェントのシェルから、セッションファイルのトークンを読んでサーバーを確認します。stdout に出力された正確なパスを使うのが第一候補です。glob する場合は辞書順の先頭ではなく **最新**(`ls -t`)を選んでください。クラッシュした前回セッションが古い `session-*.json` を残していることがあり、それを拾うと死んだトークンを掴みます。最新のファイルは常にこの起動のライブセッションです。 ```bash SESSION_FILE=$(ls -t .arkor/agent/session-*.json | head -n 1) diff --git a/docs/ja/guides/cli/dev.mdx b/docs/ja/guides/cli/dev.mdx index 0b7f4fff..ae2182ed 100644 --- a/docs/ja/guides/cli/dev.mdx +++ b/docs/ja/guides/cli/dev.mdx @@ -61,7 +61,7 @@ bun dev --port 5000 ## コーディングエージェントと使う -`arkor dev --agent` は同じサーバーをコーディングエージェント(Claude Code など)向けにヘッドレスで起動します。プロジェクト内の `.arkor/agent/` にセッションごとの JSON トークンファイルを書き、そのパスを stdout に出力し、エージェントは `/api/*` エンドポイントを直接叩いて Arkor を操作します(最初に呼ぶのは `GET /api/status`)。`CLAUDECODE=1` の下では素の `arkor dev` は起動を拒否し、明示的な `--agent` を求めます。その間も Studio UI はブラウザーで利用でき、同じポートへの 2 つ目の素の `arkor dev` は起動中のインスタンスに接続してそのまま終了します。詳細は [`arkor dev` リファレンス](/ja/cli/dev)の「エージェントモード」を参照してください。 +`arkor dev --agent` は同じサーバーをコーディングエージェント(Claude Code など)向けにヘッドレスで起動します。プロジェクト内の `.arkor/agent/` にセッションごとの JSON トークンファイルを書き、そのパスを stdout に出力し、エージェントは `/api/*` エンドポイントを直接叩いて Arkor を操作します(最初に呼ぶのは `GET /api/status`)。`CLAUDECODE=1` の下では素の `arkor dev` は起動を拒否し、明示的な `--agent` を求めます。その間も Studio UI はブラウザーで利用でき、同じポートへの 2 つ目の素の `arkor dev` は、占有者が同一プロジェクトを提供する Studio のときに起動中のインスタンスへ接続してそのまま終了します(そうでなければ通常の port-in-use エラーになります)。詳細は [`arkor dev` リファレンス](/ja/cli/dev)の「エージェントモード」を参照してください。 ## リファレンス diff --git a/docs/ja/studio/overview.mdx b/docs/ja/studio/overview.mdx index 486e9534..3ac5761a 100644 --- a/docs/ja/studio/overview.mdx +++ b/docs/ja/studio/overview.mdx @@ -37,7 +37,7 @@ Arkor マネージドバックエンド `/api/*` リクエストごとに 3 つのチェックが走ります: 1. **Host ヘッダーのガード。** `127.0.0.1` と `localhost` のみ受理。`127.0.0.1` に DNS リバインディングされる悪意あるサイトに誘導された被害者でも、送信されるのは `Host: evil.com` で、ミドルウェアは HTTP 403 で拒否します。 -2. **起動ごとの CSRF トークン。** `arkor dev` は起動ごとに 32 バイトのトークン(base64url)を生成し、`` として `index.html` にインジェクトし、すべての `/api/*` 呼び出しに必須化します(ヘッダー `X-Arkor-Studio-Token`、または `EventSource` リクエスト用に `?studioToken=`)。クロスオリジンタブは meta を読めないので、preflight をスキップする「simple」なクロスオリジン POST も拒否されます。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。 +2. **起動ごとの CSRF トークン。** `arkor dev` は起動ごとに 32 バイトのトークン(base64url)を生成し、`` として `index.html` にインジェクトし、すべての `/api/*` 呼び出しに必須化します(ヘッダー `X-Arkor-Studio-Token`、または `EventSource` リクエスト用に `?studioToken=`)。クロスオリジンタブは meta を読めないので、preflight をスキップする「simple」なクロスオリジン POST も拒否されます。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。唯一の例外は `GET /api/status` で、これはトークン不要です(ループバック + Host ガードは適用)。機密ゼロでユーザーコードを実行しないため、`arkor dev` のポート競合プローブがトークンを送信せずに占有者を確認できます。 3. **CORS なし。** SPA は同一オリジンなので CORS には意味がありません。`*` を反射すると「simple」なクロスオリジン POST(`text/plain`、`urlencoded`)を素通りさせてしまうので、トークンチェックがそれを拒否します。 トークンは `arkor dev` 起動のたびにローテートされるので、前回起動時の古いタブはリロードするまで HTTP 403 で失敗します。 diff --git a/docs/studio/overview.mdx b/docs/studio/overview.mdx index 8c7af2ed..b07f70e8 100644 --- a/docs/studio/overview.mdx +++ b/docs/studio/overview.mdx @@ -37,7 +37,7 @@ Arkor managed backend Three checks run on every `/api/*` request: 1. **Host header guard.** Only `127.0.0.1` and `localhost` are accepted for every request, including static HTML. A victim navigated to a malicious site that DNS-rebinds onto `127.0.0.1` would still send `Host: evil.com`, which the server rejects with HTTP 403 before serving token-bearing HTML. -2. **Per-launch CSRF token.** `arkor dev` generates a 32-byte token (base64url) on every launch, injects it into `index.html` as ``, and requires it on every `/api/*` call as `X-Arkor-Studio-Token`. The job-event stream also accepts `?studioToken=` because `EventSource` cannot send custom headers; mutation routes do not accept query-string tokens. Cross-origin tabs cannot read the meta, so a "simple" cross-origin POST that skips preflight is still rejected. Comparison is `timingSafeEqual`. +2. **Per-launch CSRF token.** `arkor dev` generates a 32-byte token (base64url) on every launch, injects it into `index.html` as ``, and requires it on every `/api/*` call as `X-Arkor-Studio-Token`. The job-event stream also accepts `?studioToken=` because `EventSource` cannot send custom headers; mutation routes do not accept query-string tokens. Cross-origin tabs cannot read the meta, so a "simple" cross-origin POST that skips preflight is still rejected. Comparison is `timingSafeEqual`. The one exception is `GET /api/status`, which is token-exempt (still behind the loopback + Host guard): it is secrets-free and never runs user code, so the `arkor dev` port-collision probe can confirm an occupant without transmitting the token to it. 3. **No CORS.** The SPA is same-origin so CORS adds no value. Reflecting `*` would let "simple" cross-origin POSTs (`text/plain`, `urlencoded`) through; the token check is what rejects them. The token is rotated on every `arkor dev` launch, so a stale tab from a previous run will fail with HTTP 403 until you reload it. diff --git a/e2e/studio/src/harness/studioServer.ts b/e2e/studio/src/harness/studioServer.ts index ffa6a35b..8d9cb0fe 100644 --- a/e2e/studio/src/harness/studioServer.ts +++ b/e2e/studio/src/harness/studioServer.ts @@ -271,8 +271,28 @@ export function spawnDevToExit( child.stderr.on("data", (d: string) => { stderr += d; }); - child.on("error", reject); + // Safety net: this helper is for run-to-exit launches (a busy `--port`, + // so the child adopts-and-exits or errors within the 1.5s probe budget). + // If a caller ever spawns a launch that BINDS a free port, it would serve + // forever and this promise would never settle; kill it and reject rather + // than hang the test to Playwright's global timeout and orphan the server. + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject( + new Error( + `spawnDevToExit: child did not exit within ${String( + READY_TIMEOUT_MS, + )}ms (did it bind a free port instead of a busy one?).\n--- last output ---\n${stderr}${stdout}`, + ), + ); + }, READY_TIMEOUT_MS); + timer.unref(); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); child.on("close", (code) => { + clearTimeout(timer); resolve({ code, stdout, stderr }); }); }); diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index c983944b..6f6235c8 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -1071,18 +1071,24 @@ describe("runDev", () => { expect(existsSync(otherSameP)).toBe(true); }); - it("reaps dead-pid session files on startup but keeps live ones", async () => { + it("does not delete OTHER session files in the agent dir (no pid-liveness sweep)", async () => { + // A pid-liveness sweep (process.kill) is unsafe when the project dir is + // a shared bind-mount across containers (pids are namespace-local, so a + // live foreign session looks dead). Agent startup must therefore leave + // every other session file untouched and only ever manage its own. const dir = join(projectDir, ".arkor", "agent"); mkdirSync(dir, { recursive: true }); - // A pid above any real max_pid -> process.kill(pid, 0) throws ESRCH. - const dead = join(dir, `session-999999999-dead.json`); - // The parent process is alive -> process.kill(ppid, 0) succeeds. - const live = join(dir, `session-${process.ppid}-live.json`); - writeFileSync(dead, "{}"); - writeFileSync(live, "{}"); - await runDevCapturingStdout({ port: 4316, agent: true, cwd: projectDir }); - expect(existsSync(dead)).toBe(false); - expect(existsSync(live)).toBe(true); + const foreign = join(dir, `session-999999999-foreign.json`); + writeFileSync(foreign, "{}"); + const stdout = await runDevCapturingStdout({ + port: 4316, + agent: true, + cwd: projectDir, + }); + const ours = sessionPathFrom(stdout); + expect(existsSync(ours)).toBe(true); + // The unrelated file survived: no sweep ran. + expect(existsSync(foreign)).toBe(true); }); }); @@ -1190,6 +1196,17 @@ describe("runDev", () => { } }); + it("falls back when the occupant reports a RELATIVE cwd (no `.`-bypass of the project match)", async () => { + // A hostile occupant returning `cwd: "."` must NOT be adopted: without + // the absolute-path guard, realpathSync(".") resolves to the prober's + // own projectRoot and would spuriously match. + mockServeAddrInUse(); + mockProbe({ server: "arkor-studio", cwd: "." }); + await expect(runDev({ port: 4327, cwd: projectDir })).rejects.toThrow( + /Port 4327 is already in use/, + ); + }); + it("falls back when the probe gets a non-200 (e.g. a non-Studio occupant)", async () => { mockServeAddrInUse(); mockProbe({ error: "nope" }, { status: 404 }); diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index 6e59fd00..c26a8c65 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -1,8 +1,8 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { readdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs"; +import { readFileSync, realpathSync, unlinkSync } from "node:fs"; import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises"; import { constants as osConstants } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { ExpectedCliError } from "@arkor/cli-internal"; import { serve } from "@hono/node-server"; @@ -254,42 +254,6 @@ async function ensureAgentDir(projectRoot: string): Promise { return dir; } -/** - * Best-effort: remove session files whose owning pid is dead so a crashed - * prior `arkor dev --agent` does not strand a stale-token file that the docs' - * `ls session-*.json` glob could pick up. `process.kill(pid, 0)` probes - * liveness: ESRCH -> dead -> reap; EPERM -> alive under another uid -> keep; - * our own pid and unparseable names are skipped. Never touches a live - * session, so it is safe to run on every agent launch. - */ -function reapDeadAgentSessions(dir: string): void { - let names: string[]; - try { - names = readdirSync(dir); - } catch { - return; - } - for (const name of names) { - const match = /^session-(\d+)-.*\.json$/.exec(name); - if (!match) continue; - const pid = Number(match[1]); - if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue; - try { - process.kill(pid, 0); - // No error -> the pid is alive; leave its session file alone. - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ESRCH") { - try { - unlinkSync(join(dir, name)); - } catch { - // best-effort - } - } - // EPERM (or anything else) -> assume alive/uncertain; keep the file. - } - } -} - /** * Atomically write the agent-mode session file to `path` (a pre-computed, * per-launch-unique `session--.json`, mode 0600). The caller owns @@ -356,7 +320,12 @@ async function probeExistingStudio( if (typeof body !== "object" || body === null) return false; const b = body as { server?: unknown; cwd?: unknown }; if (b.server !== "arkor-studio") return false; - if (typeof b.cwd !== "string") return false; + // Require an ABSOLUTE cwd: a real Studio always reports its resolved + // project root (`trainCwd`, itself absolute). Rejecting a relative value + // stops a hostile occupant from returning `cwd: "."`, which + // `realpathSync` would otherwise resolve against THIS process's cwd + // (= projectRoot) and so spuriously pass the same-project check. + if (typeof b.cwd !== "string" || !isAbsolute(b.cwd)) return false; // Compare realpaths so a symlinked project root (e.g. macOS // `/var` -> `/private/var`) still matches the same project. return realpathSync(b.cwd) === realpathSync(projectRoot); @@ -531,10 +500,15 @@ export async function runDev(options: DevOptions = {}): Promise { // silently unusable to its own caller. try { await ensureAgentDir(projectRoot); - // Best-effort: clear session files left by crashed prior - // launches (dead pid) so the docs' `ls session-*.json` glob - // never hands an agent a stale, already-403ing token. - reapDeadAgentSessions(agentDir); + // We deliberately do NOT sweep other session files here. A + // crashed prior session leaves an inert `session-*.json` + // (its token died with the server); the documented recipe + // picks the NEWEST file (`ls -t`), which is always this live + // launch's, so a stale file is never selected. A pid-liveness + // sweep (`process.kill(pid, 0)`) would be actively unsafe when + // the project dir is a shared bind-mount across containers: + // pids are namespace-local, so a live foreign session's file + // would look dead (ESRCH) and get deleted out from under it. await writeAgentSessionFile(agentSessionPath, { token: studioToken, url, @@ -615,7 +589,11 @@ export async function runDev(options: DevOptions = {}): Promise { err instanceof Error && (err as NodeJS.ErrnoException).code === "EADDRINUSE" ) { - const portInUse = new Error( + // ExpectedCliError (not a bare Error) so bin.ts prints this one + // actionable line and exits 1 WITHOUT a minified `dist/bin.mjs` + // code-frame; a busy port is the most common dev failure and the + // stack would be pure noise. Mirrors the agent-write hard-fail below. + const portInUse = new ExpectedCliError( `Port ${port} is already in use. Another \`arkor dev\` may be running; pass --port to choose a different one.`, ); if (agent) { diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index 75917a78..a1c79028 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -221,6 +221,30 @@ describe("main (CLI Commander wiring)", () => { expect(runDev).not.toHaveBeenCalled(); }); + it("rejects out-of-range --port boundaries (0, negative, > 65535)", async () => { + // Pin the deliberate behavior change: `--port 0` no longer coerces to + // 4000, and the 1..65535 bounds are enforced. + delete process.env.CLAUDECODE; + for (const bad of ["0", "-1", "65536", "70000"]) { + vi.mocked(runDev).mockClear(); + await expect(main(["dev", "--port", bad])).rejects.toThrow(/port/i); + expect(runDev).not.toHaveBeenCalled(); + } + }); + + it("accepts the in-range --port boundaries (1 and 65535)", async () => { + delete process.env.CLAUDECODE; + for (const ok of [1, 65_535]) { + vi.mocked(runDev).mockClear(); + await main(["dev", "--port", String(ok)]); + expect(runDev).toHaveBeenCalledWith({ + port: ok, + open: false, + agent: false, + }); + } + }); + it("under CLAUDECODE=1, `dev` without --agent throws ClaudeCodeStrictExit and never starts the server", async () => { process.env.CLAUDECODE = "1"; const stderrChunks: string[] = []; diff --git a/packages/arkor/src/core/telemetry.ts b/packages/arkor/src/core/telemetry.ts index 6a0f06d5..da72420a 100644 --- a/packages/arkor/src/core/telemetry.ts +++ b/packages/arkor/src/core/telemetry.ts @@ -210,10 +210,20 @@ export function withTelemetry( } try { await handler(...args); - const longRunning = - typeof options.longRunning === "function" - ? options.longRunning() - : options.longRunning; + // Evaluate the (possibly function-form) longRunning flag AFTER the + // handler resolves, but guard it: a faulty predicate that throws must + // NOT fall through to the catch below, which would mislabel a genuine + // success as `cli_command_failed` and re-throw a non-zero exit. Treat a + // throwing predicate as "not long-running" (emit completed). + let longRunning = false; + try { + longRunning = + typeof options.longRunning === "function" + ? options.longRunning() + : Boolean(options.longRunning); + } catch (err) { + debugLog("longRunning predicate threw", err); + } if (identity && !longRunning) { safeCapture(identity.distinctId, "cli_command_completed", { ...baseProps, diff --git a/packages/arkor/src/studio/server.ts b/packages/arkor/src/studio/server.ts index 41a99bef..25bc0c43 100644 --- a/packages/arkor/src/studio/server.ts +++ b/packages/arkor/src/studio/server.ts @@ -269,9 +269,12 @@ export function buildStudioApp(options: StudioServerOptions) { // check. Deliberately synchronous data only: no credentials read, no cloud // round-trip, and above all no user-code execution (`readManifestSummary` // rebuilds and imports the project entry point; that must never hang off a - // status poll). Must never include secrets: the token guard still applies - // (it is under `/api/*`), but the response body is written as if it were - // public. `server: "arkor-studio"` is the discriminator the port-collision + // status poll). This route is token-EXEMPT (see the `/api/*` middleware + // above), so the body MUST stay secrets-free: it is served to any loopback + // client without the CSRF token. Add nothing here you would not `curl` + // publicly on the box; `cwd`/`pid`/`version`/`mode`/`url`/endpoint-list are + // deliberately low-sensitivity (the probe needs `cwd` for its same-project + // check). `server: "arkor-studio"` is the discriminator the port-collision // probe keys on to distinguish a Studio instance from an unrelated occupant // that happens to 200 on this path. app.get("/api/status", (c) => From 82c54fd3864d293f8fe3c3461bf50061170d1e7d Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:43:45 -0700 Subject: [PATCH 05/19] fix(ENG-967): make the relative-cwd probe test load-bearing + reconcile studio docs Workflow review of 28c64cc found: - The new "relative cwd" regression test passed whether or not the isAbsolute guard existed: it ran with a /tmp `projectDir` while the worker's process.cwd() is the package dir, so realpathSync(".") never matched projectRoot regardless of the guard. Pin projectRoot to process.cwd() so the `.`-bypass actually manifests; verified the test now fails when the guard is reverted and passes with it. - Stale token-required claims outside the files the prior commit touched: docs/concepts/studio.mdx (+ JA) said the token is required on "every request", and the architecture-diagram labels in concepts/studio.mdx and studio/overview.mdx (+ JA) still read "CSRF-token gated". Add the GET /api/status token-exempt carve-out to all four so the docs match the finalized model. --- docs/concepts/studio.mdx | 4 ++-- docs/ja/concepts/studio.mdx | 4 ++-- docs/ja/studio/overview.mdx | 2 +- docs/studio/overview.mdx | 2 +- packages/arkor/src/cli/commands/dev.test.ts | 13 +++++++++---- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/concepts/studio.mdx b/docs/concepts/studio.mdx index 03907deb..121c2bff 100644 --- a/docs/concepts/studio.mdx +++ b/docs/concepts/studio.mdx @@ -22,7 +22,7 @@ When you start `arkor dev`, the CLI: 1. Boots a Hono server on `127.0.0.1:4000` (use `-p` to change the port). 2. Serves a Vite + React SPA from the same origin so the UI talks to the CLI through `/api/*` on loopback. -3. Issues a per-launch CSRF token (saved to `~/.arkor/studio-token` with mode `0600`) and requires every request to present it. +3. Issues a per-launch CSRF token (saved to `~/.arkor/studio-token` with mode `0600`) and requires it on every `/api/*` request, except the secrets-free `GET /api/status` probe (which is token-exempt so the port-collision check can confirm an occupant without transmitting the token). The server only binds to loopback and rejects requests with a non-loopback `Host` header. There is no public URL, no inbound connection, and the token rotates every time you start `arkor dev`. You can run Studio safely on a shared dev machine without worrying about exposing your training data. @@ -32,7 +32,7 @@ Studio is just a viewer for what your CLI is doing. The CLI talks to the managed ``` Studio (browser tab) - │ /api/* on loopback, CSRF-token gated + │ /api/* on loopback, CSRF-token gated (except GET /api/status) ▼ arkor CLI (your machine) │ authenticated HTTPS diff --git a/docs/ja/concepts/studio.mdx b/docs/ja/concepts/studio.mdx index 12f176e2..de380064 100644 --- a/docs/ja/concepts/studio.mdx +++ b/docs/ja/concepts/studio.mdx @@ -22,7 +22,7 @@ dev ループのメモ: Studio の `/api/manifest` エンドポイントはリ 1. Hono サーバーを `127.0.0.1:4000` で起動(ポートは `-p` で変更可能)。 2. 同一オリジンで Vite + React の SPA を提供し、UI はループバックの `/api/*` 経由で CLI と話します。 -3. 起動ごとに CSRF トークンを発行し(`~/.arkor/studio-token` にモード `0600` で保存)、すべてのリクエストにこのトークンを要求します。 +3. 起動ごとに CSRF トークンを発行し(`~/.arkor/studio-token` にモード `0600` で保存)、`GET /api/status`(機密ゼロのプローブ。ポート競合チェックがトークンを送らずに占有者を確認できるようトークン不要)を除くすべての `/api/*` リクエストにこのトークンを要求します。 サーバーはループバックにのみバインドし、ループバック以外の `Host` ヘッダーのリクエストは拒否します。公開 URL もインバウンド接続もなく、トークンは `arkor dev` のたびにローテートされます。共有の開発環境でも学習データが漏れる心配なく安全に Studio を動かせます。 @@ -32,7 +32,7 @@ Studio は CLI の動作を可視化するための画面にすぎません。CL ``` Studio(ブラウザータブ) - │ ループバック上の /api/*、CSRF トークン必須 + │ ループバック上の /api/*、CSRF トークン必須(GET /api/status を除く) ▼ arkor CLI(ローカル) │ 認証付き HTTPS diff --git a/docs/ja/studio/overview.mdx b/docs/ja/studio/overview.mdx index 3ac5761a..ed0a3b90 100644 --- a/docs/ja/studio/overview.mdx +++ b/docs/ja/studio/overview.mdx @@ -26,7 +26,7 @@ Jobs ページは `#/jobs/:id` でジョブ単位の詳細(ライブステー ``` Studio(ブラウザータブ、http://localhost:4000) - │ ループバック上の /api/*、CSRF トークン必須 + │ ループバック上の /api/*、CSRF トークン必須(GET /api/status を除く) ▼ arkor CLI(ローカル) │ 認証付き HTTPS diff --git a/docs/studio/overview.mdx b/docs/studio/overview.mdx index b07f70e8..6e2d7d2d 100644 --- a/docs/studio/overview.mdx +++ b/docs/studio/overview.mdx @@ -26,7 +26,7 @@ The Jobs page also opens a per-run detail at `#/jobs/:id` (live status, loss cha ``` Studio (browser tab, http://localhost:4000) - │ /api/* on loopback, CSRF-token gated + │ /api/* on loopback, CSRF-token gated (except GET /api/status) ▼ arkor CLI (your machine) │ authenticated HTTPS diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 6f6235c8..9716cf8d 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -1197,12 +1197,17 @@ describe("runDev", () => { }); it("falls back when the occupant reports a RELATIVE cwd (no `.`-bypass of the project match)", async () => { - // A hostile occupant returning `cwd: "."` must NOT be adopted: without - // the absolute-path guard, realpathSync(".") resolves to the prober's - // own projectRoot and would spuriously match. + // A hostile occupant returning `cwd: "."` must NOT be adopted. This test + // is load-bearing only when it reproduces the production invariant + // `projectRoot === process.cwd()` (since `realpathSync(".")` resolves + // against the prober's process.cwd()): passing a /tmp `projectDir` here + // would make the guard untestable (realpathSync(".") != that /tmp path + // regardless of the guard). So pin projectRoot to process.cwd(): without + // the `isAbsolute` guard, `realpathSync(".") === realpathSync(cwd)` would + // wrongly adopt; with it, the launch rejects. mockServeAddrInUse(); mockProbe({ server: "arkor-studio", cwd: "." }); - await expect(runDev({ port: 4327, cwd: projectDir })).rejects.toThrow( + await expect(runDev({ port: 4327, cwd: process.cwd() })).rejects.toThrow( /Port 4327 is already in use/, ); }); From 009382502cb03368f1437e512d38aea2260e54c8 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:42:51 -0700 Subject: [PATCH 06/19] fix(ENG-967): exhaustive-audit fixes (test effectiveness, agent url, docs) An 8-angle audit of the whole changeset (2 adversarial verifiers per finding, several confirmed by live mutation testing) surfaced: Test effectiveness (the tests were not load-bearing; each fix is now mutation-verified to fail when its target regresses): - main.test.ts's withTelemetry mock dropped the options arg, so main.ts's adopt-path telemetry wiring (adopted = result.adopted; longRunning: () => !adopted) had ZERO coverage. The mock now captures and evaluates longRunning like the real wrapper; two tests pin the serving (long- running) vs connect-and-exit (completed) outcomes. - The probe "non-200" test used a body that also failed the discriminator, so it did not isolate the res.ok guard. It now sends a matching body so only res.ok can reject it. - The throwing-longRunning-predicate guard in telemetry.ts had no test; added one. - The CLAUDECODE gate e2e now asserts the "nothing persisted" contract (no home token, no session dir). Agent-facing URL: - The session file url and the /api/status echo now use the 127.0.0.1 literal the server bound (not localhost) so a non-Happy-Eyeballs agent client reaches it on an IPv6-localhost-first host. Human stdout / --open keep localhost. Docs recipe reads url from the session file. Robustness/consistency: - ExpectedCliError forwards ErrorOptions (preserves cause); the OAuth-only anonymous-bootstrap failure is now an ExpectedCliError so bin.ts prints it stack-free. - The e2e agent-readiness regex requires the trailing newline so a chunk-split stdout line cannot yield a truncated session path. Docs: fixed the stale --port coercion row (EN+JA) to match parseDevPort; added .arkor/agent/ to the project-structure inventory (EN+JA); documented the connect-adoption loopback-trust tradeoff (no token sent, so worst case is denial/redirect, never token disclosure or RCE) in dev.mdx (EN+JA) and AGENTS.md. --- AGENTS.md | 2 +- docs/cli/dev.mdx | 11 ++++-- docs/concepts/project-structure.mdx | 1 + docs/ja/cli/dev.mdx | 11 ++++-- docs/ja/concepts/project-structure.mdx | 1 + e2e/cli/src/arkor-dev.test.ts | 10 +++++ e2e/studio/src/harness/studioServer.ts | 9 ++++- e2e/studio/src/specs/agent.spec.ts | 7 ++-- packages/arkor/src/cli/commands/dev.test.ts | 12 ++++-- packages/arkor/src/cli/commands/dev.ts | 27 ++++++++++--- packages/arkor/src/cli/main.test.ts | 43 +++++++++++++++++++-- packages/arkor/src/core/telemetry.test.ts | 17 ++++++++ packages/cli-internal/src/errors.ts | 4 +- 13 files changed, 129 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ea64b4d..98179b04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ cd my-arkor-app && pnpm dev # Studio at http://127.0. 1. Passes it to `buildStudioApp({ studioToken })`. The Hono server validates every `/api/*` request via `X-Arkor-Studio-Token` header (or `?studioToken=` query for `EventSource`, which can't set headers). Comparison uses `timingSafeEqual`. **One carve-out:** `GET /api/status` is token-exempt (still behind the loopback + Host guard) because it is secrets-free and never executes user code; this lets the port-collision probe confirm an occupant without transmitting the token to an unverified peer (see item 4). 2. Persists it to `~/.arkor/studio-token` (mode 0600) so the SPA dev workflow (`pnpm --filter @arkor/studio-app dev`) can read it via the `arkor-studio-token` Vite plugin in [packages/studio-app/vite.config.ts](packages/studio-app/vite.config.ts), which injects `` into `index.html` on each request. Persistence failure must NOT block server start (read-only `$HOME` on Docker, etc.); just warn. 3. Cleans up on `exit`/SIGINT/SIGTERM/SIGHUP via `unlinkSync`, after verifying the file still holds this instance's token (a second `arkor dev` on another port may have overwritten it; last writer wins and the previous owner must not delete it). Signal handlers exit with the conventional `128 + signal` code (SIGINT -> 130, SIGTERM -> 143, SIGHUP -> 129) so supervisors can distinguish signal termination from a clean exit. -4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). Agent mode does NOT sweep or reap other session files (an earlier pid-liveness reap was removed: `process.kill(pid, 0)` is namespace-local, so on a shared bind-mount a LIVE foreign-container session looks dead and would be deleted). A crashed session leaves an inert file; the docs recipe selects the newest (`ls -t`), which is always the live launch, so a stale leftover is never picked. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` (127.0.0.1, matching the bind, not `localhost`) WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. +4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). Agent mode does NOT sweep or reap other session files (an earlier pid-liveness reap was removed: `process.kill(pid, 0)` is namespace-local, so on a shared bind-mount a LIVE foreign-container session looks dead and would be deleted). A crashed session leaves an inert file; the docs recipe selects the newest (`ls -t`), which is always the live launch, so a stale leftover is never picked. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. The session file `url` (and the `/api/status` echo) is the agent-facing `http://127.0.0.1:` literal, NOT `localhost` (an agent's HTTP client may not do Happy-Eyeballs, so it must get the IPv4 the server actually bound); the human-facing stdout line and the `--open` browser target still use `localhost`. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath, must be absolute) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Adoption is UNAUTHENTICATED (it trusts the loopback peer's self-reported `/api/status`); this is deliberate because the probe sends no token, so an impostor can never obtain the CSRF token or reach `POST /api/train` (RCE) -- worst case is a nuisance denial/`--open` redirect, never code execution. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. `/api/*` middleware also enforces a host-header allow-list (`127.0.0.1`/`localhost`) for DNS-rebinding defence. **CORS is intentionally NOT configured**: the SPA is same-origin so reflecting `*` would let "simple" cross-origin POSTs reach handlers. The token check rejects those; cross-origin tabs cannot read the SPA's ``. diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index fc9fda33..baf4b5f9 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -39,7 +39,7 @@ bun dev | Flag | Default | Description | | --- | --- | --- | -| `-p, --port ` | `4000` | Port to bind. The displayed URL uses `localhost`, but the listener binds `127.0.0.1` directly so it cannot end up IPv6-only on hosts where `/etc/hosts` lists `::1` before `127.0.0.1`. The CLI parses the value as `Number(opts.port) || 4000`, so falsy results (`0`, non-numeric) normalize to `4000`. **Truthy invalid values pass through unsanitized**: a negative port or a port above `65535` reaches the listener as-is and surfaces as a `serve()` failure (e.g. `RangeError`). Stick to the standard 1 to 65535 range yourself. | +| `-p, --port ` | `4000` | Port to bind. The displayed URL uses `localhost`, but the listener binds `127.0.0.1` directly so it cannot end up IPv6-only on hosts where `/etc/hosts` lists `::1` before `127.0.0.1`. The value is validated up front: it must be an integer in `1` to `65535`. Anything else (`0`, a negative, `> 65535`, or non-numeric) is rejected with ``--port must be an integer between 1 and 65535 (got …).`` and the CLI exits `1` before binding. | | `--open` | off | Open the Studio URL in a browser after the server is up. | | `--agent` | off | Agent mode: run the same Studio server headlessly for coding agents. Writes a per-session JSON token file under `.arkor/agent/` in the project and prints its path to stdout (see "Agent mode" below). Never opens a browser on its own; `--open` still works if you want one. | @@ -110,6 +110,8 @@ This means `arkor dev` is safe on a shared dev machine: another tab cannot read The token file (`~/.arkor/studio-token`) is written only **after** the port binds successfully, so a failed second launch on a busy port never overwrites or deletes the token a healthy first instance depends on. +**Adoption trust model.** Adoption trusts the loopback occupant by its self-reported `/api/status` (which is token-exempt), so it does **not** authenticate the peer. On a shared multi-user host, a local process that pre-binds the port and serves a matching `/api/status` can make your `arkor dev` print "already running" and exit (a nuisance denial of your own launch), and with `--open` your browser would open at that local origin. This is deliberately the safe side of the trade: because the probe sends **no token**, an impostor can never obtain the CSRF token or reach the RCE-grade `POST /api/train`; the worst case is a redirect or denial, never code execution. If you do not want the connect behavior on an untrusted shared box, pass an explicit free `--port`. + ## Errors | Symptom | What it means | Fix | @@ -202,10 +204,13 @@ Then, from the agent's shell, read the token out of the session file and confirm ```bash SESSION_FILE=$(ls -t .arkor/agent/session-*.json | head -n 1) TOKEN=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token' "$SESSION_FILE") -curl http://localhost:4000/api/status # token-free health probe -curl -H "X-Arkor-Studio-Token: $TOKEN" http://localhost:4000/api/jobs # token-guarded routes +URL=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).url' "$SESSION_FILE") +curl "$URL/api/status" # token-free health probe +curl -H "X-Arkor-Studio-Token: $TOKEN" "$URL/api/jobs" # token-guarded routes ``` +The `url` in the session file is the `127.0.0.1` literal the server bound (not `localhost`), so an HTTP client that does not do Happy Eyeballs still reaches it on an IPv6-localhost-first host. + ## See also - [Studio concept](/concepts/studio) for what the UI actually does diff --git a/docs/concepts/project-structure.mdx b/docs/concepts/project-structure.mdx index 4896a874..295ecba1 100644 --- a/docs/concepts/project-structure.mdx +++ b/docs/concepts/project-structure.mdx @@ -80,6 +80,7 @@ You should not commit this directory. - **`.arkor/state.json`**. Project routing: `orgSlug`, `projectSlug`, `projectId`. This is how the runtime maps your local repo to a workspace on the managed backend. The file is created by `ensureProjectState()`, which runs the first time a CLI or Studio action needs a scope: starting training, hitting base-model inference, or creating the first deployment from Studio's [Endpoints](/studio/endpoints) page. Pure read paths (the Endpoints / Jobs list views, for instance) deliberately do **not** trigger bootstrap, so opening Studio on a fresh checkout shows empty lists without provisioning a remote project as a side effect. For **anonymous** workspaces the file is auto-created on that first write call. For **OAuth** workspaces the runtime errors out instead: today, neither `arkor login` nor `arkor init` populates the file, so you create it by hand with `{ orgSlug, projectSlug, projectId }` and the runtime takes over from there. Once it exists, do not edit it by hand. - **`.arkor/build/index.mjs`**. Output of `arkor build`: an esbuild bundle of `src/arkor/index.ts` targeting Node 22.22 with bare specifiers kept external. `arkor start` runs this. +- **`.arkor/agent/session--.json`** (transient, agent mode). Written by `arkor dev --agent` (dir `0700`, file `0600`): a per-session `{ token, url, port, pid }` JSON so a coding agent can drive the local Studio API without the browser. It is removed on exit; a crash can leave one behind (inert, safe to delete). See the "Agent mode" section of [`arkor dev`](/cli/dev). ## `~/.arkor/` (per-user) diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index b841eba6..ca6c48bc 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -39,7 +39,7 @@ bun dev | フラグ | デフォルト | 説明 | | --- | --- | --- | -| `-p, --port ` | `4000` | バインドするポート。表示 URL は `localhost` ですが、リスナーは `127.0.0.1` を直接バインドします。これにより `/etc/hosts` で `::1` が `127.0.0.1` より先に並ぶホストでも IPv6 専用に終わりません。CLI は値を `Number(opts.port) || 4000` でパースするので、falsy な結果(`0`、非数)は `4000` に正規化されます。**truthy な不正値はそのまま透過します**。負のポートや `65535` を超えるポートはリスナーにそのまま到達し `serve()` の失敗(例: `RangeError`)として現れます。1 から 65535 の標準範囲を自分で守ってください。 | +| `-p, --port ` | `4000` | バインドするポート。表示 URL は `localhost` ですが、リスナーは `127.0.0.1` を直接バインドします。これにより `/etc/hosts` で `::1` が `127.0.0.1` より先に並ぶホストでも IPv6 専用に終わりません。値は事前に検証され、`1` から `65535` の整数でなければなりません。それ以外(`0`、負値、`65535` 超、非数)は ``--port must be an integer between 1 and 65535 (got …).`` を出力し、バインド前に exit code `1` で終了します。 | | `--open` | off | サーバー起動後にブラウザーで Studio URL を開く。 | | `--agent` | off | エージェントモード。同じ Studio サーバーをコーディングエージェント向けにヘッドレスで起動する。プロジェクト内の `.arkor/agent/` にセッションごとの JSON トークンファイルを書き、そのパスを stdout に出力する(下の「エージェントモード」参照)。単体でブラウザーを開くことはなく、開きたい場合は従来どおり `--open` を併用する。 | @@ -110,6 +110,8 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ トークンファイル(`~/.arkor/studio-token`)はポートのバインド成功 **後** にのみ書き込まれるため、使用中ポートへの 2 つ目の起動が失敗しても、正常に動いている 1 つ目のインスタンスが依存するトークンを上書き・削除することはありません。 +**採用の信頼モデル。** 接続採用は、占有者の自己申告する `/api/status`(トークン不要)だけを信頼し、相手を**認証しません**。共有のマルチユーザーホストでは、ローカルのプロセスが先にポートをバインドして一致する `/api/status` を返せば、あなたの `arkor dev` に「already running」と表示させて終了させたり(自分の起動を妨害される程度)、`--open` 時にそのローカルオリジンでブラウザーを開かせることができます。これは意図した安全側のトレードオフです。プローブは**トークンを一切送らない**ので、偽装者が CSRF トークンを得たり RCE 相当の `POST /api/train` に到達することは決してできません。最悪でもリダイレクトや妨害で、コード実行には至りません。信頼できない共有環境で接続動作を望まない場合は、空いている `--port` を明示してください。 + ## エラー | 症状 | 意味 | 対処 | @@ -202,10 +204,13 @@ bun dev --agent ```bash SESSION_FILE=$(ls -t .arkor/agent/session-*.json | head -n 1) TOKEN=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token' "$SESSION_FILE") -curl http://localhost:4000/api/status # トークン不要のヘルスプローブ -curl -H "X-Arkor-Studio-Token: $TOKEN" http://localhost:4000/api/jobs # トークン必須のルート +URL=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).url' "$SESSION_FILE") +curl "$URL/api/status" # トークン不要のヘルスプローブ +curl -H "X-Arkor-Studio-Token: $TOKEN" "$URL/api/jobs" # トークン必須のルート ``` +セッションファイルの `url` はサーバーがバインドした `127.0.0.1` リテラル(`localhost` ではない)なので、Happy Eyeballs をしない HTTP クライアントでも IPv6-localhost 優先ホストで到達できます。 + ## 関連項目 - [Studio コンセプト](/ja/concepts/studio): UI で実際に何が起きるか diff --git a/docs/ja/concepts/project-structure.mdx b/docs/ja/concepts/project-structure.mdx index 85d9de3a..2a9a7f3e 100644 --- a/docs/ja/concepts/project-structure.mdx +++ b/docs/ja/concepts/project-structure.mdx @@ -73,6 +73,7 @@ export default {}; - **`.arkor/state.json`**。プロジェクトのルーティング: `orgSlug`、`projectSlug`、`projectId`。これによってランタイムはローカルリポジトリをマネージドバックエンドのワークスペースにマップします。ファイルは `ensureProjectState()` が作成し、CLI や Studio がスコープを必要とする最初のアクション(学習の開始、ベースモデル推論、Studio の [Endpoints](/ja/studio/endpoints) ページからの初回 deployment 作成など)で走ります。純粋な read 経路(Endpoints / Jobs の一覧表示など)は意図的に初期化を起こさないので、新規チェックアウト直後に Studio を開いてもリモートにプロジェクトを副作用で作ったりせず、空一覧が表示されるだけです。**匿名**ワークスペースではこの初回 write 呼び出しで自動作成されます。**OAuth** ワークスペースの場合はランタイムがエラーを返します。今のところ `arkor login` も `arkor init` もこのファイルに値を書き込まないので、`{ orgSlug, projectSlug, projectId }` を手動で書いて作るのが現実的な方法です。一度作られた後は手で編集しないでください。 - **`.arkor/build/index.mjs`**。`arkor build` の出力。`src/arkor/index.ts` を Node 22.22 ターゲットで esbuild した単一バンドルで、bare specifier は外部のままにしてあります。`arkor start` がこれを実行します。 +- **`.arkor/agent/session--.json`**(一時的、エージェントモード)。`arkor dev --agent` が書き込みます(ディレクトリー `0700`、ファイル `0600`)。セッションごとの `{ token, url, port, pid }` JSON で、コーディングエージェントがブラウザーなしでローカル Studio API を操作できるようにします。終了時に削除されます。クラッシュすると残ることがあります(無害で削除して安全)。[`arkor dev`](/ja/cli/dev) の「エージェントモード」節を参照。 ## `~/.arkor/`(ユーザーごと) diff --git a/e2e/cli/src/arkor-dev.test.ts b/e2e/cli/src/arkor-dev.test.ts index 9fc9009e..96dbea83 100644 --- a/e2e/cli/src/arkor-dev.test.ts +++ b/e2e/cli/src/arkor-dev.test.ts @@ -10,6 +10,9 @@ // // `runCli` strips CLAUDECODE from the inherited env by default, so tests // opt INTO strict mode via `extraEnv`, mirroring arkor-init.test.ts. +import { existsSync } from "node:fs"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { ARKOR_BIN } from "./bins"; @@ -33,6 +36,13 @@ describe("arkor dev × CLAUDECODE strict gate (E2E)", () => { // The gate fires before the server starts: no ready line, no token // file side effects under the isolated HOME. expect(result.stdout).not.toContain("Arkor Studio running on"); + // Assert the documented "nothing persisted" contract concretely: the + // gate throws before runDev, so neither the home studio-token nor a + // project agent session dir is created (a regression that moved the gate + // after persistence would fail here). Telemetry may create ~/.arkor + // itself, so check the specific ENG-967 artifacts, not the whole dir. + expect(existsSync(join(dir, ".arkor", "studio-token"))).toBe(false); + expect(existsSync(join(dir, ".arkor", "agent"))).toBe(false); } finally { cleanup(dir); } diff --git a/e2e/studio/src/harness/studioServer.ts b/e2e/studio/src/harness/studioServer.ts index 8d9cb0fe..a1536482 100644 --- a/e2e/studio/src/harness/studioServer.ts +++ b/e2e/studio/src/harness/studioServer.ts @@ -36,7 +36,14 @@ const READY_LINE_PATTERN = /Arkor Studio running on/; // same async block right after the ready line, so matching it both proves // the server is up and hands us the session-file path without a second // wait-and-parse pass racing the child's stdout flush. -const AGENT_SESSION_LINE_PATTERN = /^Arkor Studio agent session file: (.+)$/m; +// +// The trailing `\r?\n` is load-bearing: it forces the match to see the FULL +// line before settling. `(.+)$` under `/m` also anchors at end-of-buffer, so +// a stdout chunk that split mid-path would otherwise match and hand back a +// truncated `sessionFile`. Requiring the newline makes readiness wait for the +// complete line regardless of pipe chunking. +const AGENT_SESSION_LINE_PATTERN = + /^Arkor Studio agent session file: (.+)\r?\n/m; const READY_TIMEOUT_MS = 30_000; const PORT_POLL_INTERVAL_MS = 50; const PORT_POLL_TIMEOUT_MS = 10_000; diff --git a/e2e/studio/src/specs/agent.spec.ts b/e2e/studio/src/specs/agent.spec.ts index 093e5919..9022a8a8 100644 --- a/e2e/studio/src/specs/agent.spec.ts +++ b/e2e/studio/src/specs/agent.spec.ts @@ -32,10 +32,11 @@ test.describe("Agent mode contract", () => { const agentDir = join(fixturePaths.projectDir, ".arkor", "agent"); expect(sessionFile!.startsWith(agentDir)).toBe(true); const payload = readSession(sessionFile!); - // The CLI prints/binds 127.0.0.1 but displays localhost; the session - // file carries the displayed URL. + // The session file carries the agent-facing 127.0.0.1 URL (the literal the + // server bound), which is exactly what the harness connects to. const port = new URL(agentStudio.url).port; - expect(payload.url).toBe(`http://localhost:${port}`); + expect(payload.url).toBe(`http://127.0.0.1:${port}`); + expect(payload.url).toBe(agentStudio.url); expect(String(payload.port)).toBe(port); expect(Number.isInteger(payload.pid)).toBe(true); // The file token is the same per-launch CSRF token the SPA reads from diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 9716cf8d..d967b77e 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -940,7 +940,9 @@ describe("runDev", () => { port: number; pid: number; }; - expect(payload.url).toBe("http://localhost:4310"); + // Agent-facing URL is the 127.0.0.1 literal (matches the bind), not the + // localhost display name, so a non-Happy-Eyeballs client always reaches it. + expect(payload.url).toBe("http://127.0.0.1:4310"); expect(payload.port).toBe(4310); expect(payload.pid).toBe(process.pid); expect(payload.token).toMatch(/^[\w-]+$/); @@ -1212,9 +1214,13 @@ describe("runDev", () => { ); }); - it("falls back when the probe gets a non-200 (e.g. a non-Studio occupant)", async () => { + it("falls back on a non-200 even when the body WOULD otherwise match (isolates the res.ok guard)", async () => { mockServeAddrInUse(); - mockProbe({ error: "nope" }, { status: 404 }); + // Body has the right discriminator AND a matching cwd, so only the + // `if (!res.ok) return false` branch can reject it. This keeps the test + // load-bearing for that guard specifically (a body that also failed the + // discriminator would pass whether or not the status check existed). + mockProbe({ server: "arkor-studio", cwd: projectDir }, { status: 404 }); await expect(runDev({ port: 4323, cwd: projectDir })).rejects.toThrow( /Port 4323 is already in use/, ); diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index c26a8c65..3ce2c141 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -144,8 +144,10 @@ export async function ensureCredentialsForStudio(): Promise { // `err.message` already starts with "Failed to acquire…" and // includes the response-body snippet, which would double-prefix the // wrap and risk leaking noisy HTML/JSON error pages. The full - // detail is preserved on `cause` for debugging. - throw new Error( + // detail is preserved on `cause` for debugging. ExpectedCliError so + // bin.ts prints this actionable line alone (no minified dist stack) + // for a routine first-run-on-an-OAuth-only-deployment failure. + throw new ExpectedCliError( `Failed to bootstrap an anonymous session (HTTP ${err.status}). This deployment may require sign-in. Run \`arkor login --oauth\` and try again.`, { cause: err }, ); @@ -407,9 +409,17 @@ export async function runDev(options: DevOptions = {}): Promise { // before `127.0.0.1 localhost`, a "localhost" bind would refuse IPv4 // connections, breaking the studio-app Vite proxy (hardcoded to // `http://127.0.0.1:4000`) and any browser that resolves localhost to - // IPv4. The host-header guard already accepts both, so the displayed URL - // can still be `localhost`. + // IPv4. The host-header guard accepts both. + // + // Two URL forms: `url` (localhost) is human-facing (the stdout line, the + // browser `--open` target: browsers do Happy-Eyeballs and a friendly host + // reads better). `agentUrl` (127.0.0.1) is what a coding AGENT consumes + // programmatically (the session file's `url`, the `/api/status` echo): an + // agent's HTTP client may not do Happy-Eyeballs, so it must get the same + // IPv4 literal the server actually bound, not a name that can resolve to + // an unbound `::1`. const url = `http://localhost:${port}`; + const agentUrl = `http://127.0.0.1:${port}`; // `autoAnonymous: true` (the default) lets the Hono server retry the // anonymous bootstrap on first `/api/credentials` hit if the up-front @@ -421,7 +431,10 @@ export async function runDev(options: DevOptions = {}): Promise { const app = buildStudioApp({ studioToken, mode: agent ? "agent" : "studio", - url, + // Echo the agent-facing (127.0.0.1) URL from /api/status so a probe or + // agent reading it reaches the bound listener regardless of localhost + // resolution order. + url: agentUrl, cwd: projectRoot, }); @@ -511,7 +524,9 @@ export async function runDev(options: DevOptions = {}): Promise { // would look dead (ESRCH) and get deleted out from under it. await writeAgentSessionFile(agentSessionPath, { token: studioToken, - url, + // Agent-facing URL: 127.0.0.1 (the literal the server bound), + // never `localhost`, so a non-Happy-Eyeballs client reaches it. + url: agentUrl, port, pid: process.pid, }); diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index a1c79028..ef620fa3 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -12,16 +12,31 @@ vi.mock("./commands/build", () => ({ runBuild: vi.fn() })); vi.mock("./commands/start", () => ({ runStart: vi.fn() })); vi.mock("./commands/dev", () => ({ runDev: 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. +// Telemetry: the wrapper delegates to the inner handler, but it also +// captures the `options` (esp. the `longRunning` flag/predicate) and +// evaluates it AFTER the handler resolves, exactly as the real +// withTelemetry does. This lets tests assert main.ts's per-command +// telemetry wiring (e.g. `dev`'s `{ longRunning: () => !adopted }` and its +// `adopted = result.adopted` capture) instead of silently dropping it. +const capturedTelemetry = vi.hoisted(() => ({ + longRunningAfter: undefined as boolean | undefined, +})); vi.mock("../core/telemetry", () => ({ withTelemetry: ( _name: string, handler: (...args: TArgs) => Promise, + options?: { longRunning?: boolean | (() => boolean) }, ) => - async (...args: TArgs) => - handler(...args), + async (...args: TArgs) => { + capturedTelemetry.longRunningAfter = undefined; + const result = await handler(...args); + capturedTelemetry.longRunningAfter = + typeof options?.longRunning === "function" + ? options.longRunning() + : options?.longRunning; + return result; + }, shutdownTelemetry: vi.fn(async () => undefined), })); @@ -290,6 +305,26 @@ describe("main (CLI Commander wiring)", () => { }); }); + it("classifies a SERVING `dev` as long-running (suppresses cli_command_completed)", async () => { + // runDev resolving { adopted: false } => the process keeps serving => + // longRunning must be true so the near-zero-duration completed event is + // skipped. This exercises main.ts's `adopted = result.adopted` capture + // and `{ longRunning: () => !adopted }` wiring (inverting it fails here). + delete process.env.CLAUDECODE; + vi.mocked(runDev).mockResolvedValueOnce({ adopted: false }); + await main(["dev"]); + expect(capturedTelemetry.longRunningAfter).toBe(true); + }); + + it("classifies a CONNECT-and-exit `dev` as NOT long-running (emits cli_command_completed)", async () => { + // runDev resolving { adopted: true } => connected to a running Studio and + // exits => a genuine short-lived completion, so longRunning must be false. + delete process.env.CLAUDECODE; + vi.mocked(runDev).mockResolvedValueOnce({ adopted: true }); + await main(["dev"]); + expect(capturedTelemetry.longRunningAfter).toBe(false); + }); + it("flushes a recorded deprecation warning after parse and awaits shutdownTelemetry", async () => { mockDeprecation.value = { sdkVersion: "1.4.0", diff --git a/packages/arkor/src/core/telemetry.test.ts b/packages/arkor/src/core/telemetry.test.ts index 6e08dac2..bba3b9e0 100644 --- a/packages/arkor/src/core/telemetry.test.ts +++ b/packages/arkor/src/core/telemetry.test.ts @@ -434,6 +434,23 @@ describe("withTelemetry", () => { expect(captureMock.mock.calls[0][0].event).toBe("cli_command_started"); }); + it("treats a THROWING longRunning predicate as not-long-running (a succeeded command stays a success)", async () => { + // A faulty predicate must NOT propagate into the failure path: that would + // mislabel a genuine success as cli_command_failed and re-throw a non-zero + // exit. The guard defaults a throwing predicate to "not long-running", so + // the command still resolves AND emits cli_command_completed. + const mod = await loadTelemetry({ key: "phc_test" }); + const wrapped = mod.withTelemetry("dev", async () => {}, { + longRunning: () => { + throw new Error("predicate boom"); + }, + }); + await expect(wrapped()).resolves.toBeUndefined(); + const events = captureMock.mock.calls.map((c) => c[0].event); + expect(events).toEqual(["cli_command_started", "cli_command_completed"]); + expect(events).not.toContain("cli_command_failed"); + }); + it("does not double-initialise the PostHog client across multiple wrapped calls", async () => { // The lazy `getClient` returns the cached instance after the first // safeCapture; later calls (started + completed of a 2nd command) diff --git a/packages/cli-internal/src/errors.ts b/packages/cli-internal/src/errors.ts index ad40beab..4b2a8a34 100644 --- a/packages/cli-internal/src/errors.ts +++ b/packages/cli-internal/src/errors.ts @@ -8,8 +8,8 @@ * caller. */ export class ExpectedCliError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = "ExpectedCliError"; } } From 49b706bdf3d1be0da1567911267466454eba783b Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:26:26 -0700 Subject: [PATCH 07/19] fix(ENG-967): audit rounds 2-3 (probe hardening, port validation, load-bearing tests, docs) Two more full-changeset audit rounds (8 angles x 2 verifiers each) to convergence. Every code fix is mutation-verified: the new test fails when the target regresses and passes when restored. Correctness / hardening: - probeExistingStudio reads /api/status with a hard 64 KiB BYTE cap (readCapped), not just the 1.5s time bound, so an untrusted loopback occupant cannot stream an unbounded body into the probing process. - parseDevPort now requires a plain decimal-integer string (/^\d+$/), so Number()'s hex/exponent/decimal/whitespace coercions no longer bind a surprising port while the error copy promises "an integer". - Non-EADDRINUSE pre-bind failures (EACCES on a privileged port, now that --port permits 1-1023, EADDRNOTAVAIL, ...) reject an ExpectedCliError so bin.ts prints one clean line instead of a minified dist stack. - The OAuth-only anonymous-bootstrap failure is an ExpectedCliError too (ErrorOptions cause preserved). Agent-facing URL: the session file url and the /api/status echo are the 127.0.0.1 literal the server bound (not localhost); the JSON example in the docs (EN+JA) and the curl recipe now match, and the e2e asserts the /api/status echo. Test effectiveness (each mutation-verified load-bearing): the probe res.ok guard, the byte cap, realpathSync canonicalization (symlink), the adopt-path telemetry wiring, the throwing-longRunning guard, parseDevPort strictness, the bind-error wrap, and the CLAUDECODE-gate "nothing persisted" side effect. Corrected two comments that overstated which sibling test pins a given behavior. Docs: fixed the stale --port coercion row; documented the connect- adoption loopback-trust tradeoff; added .arkor/agent/ to the project- structure inventory; softened the "newest file is always this launch's" claim for concurrent sessions; noted .tmp staging remnants are safe to delete. All EN+JA. --- docs/cli/dev.mdx | 6 +- docs/ja/cli/dev.mdx | 6 +- e2e/studio/src/specs/agent.spec.ts | 3 + packages/arkor/src/cli/commands/dev.test.ts | 65 ++++++++++++++++++++ packages/arkor/src/cli/commands/dev.ts | 68 ++++++++++++++++++--- packages/arkor/src/cli/main.test.ts | 17 +++++- packages/arkor/src/cli/main.ts | 6 +- packages/arkor/src/core/telemetry.test.ts | 5 +- 8 files changed, 158 insertions(+), 18 deletions(-) diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index baf4b5f9..842ba3d6 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -60,7 +60,7 @@ When the process exits (normal exit, `SIGINT`, `SIGTERM`, or `SIGHUP`) the studi Everything in the launch sequence above happens unchanged, plus: -1. **Project-local session file.** The per-launch CSRF token is written to a unique JSON file inside the project: `/.arkor/agent/session--.json`. The `.arkor/agent/` directory is created with mode `0700` and the file with mode `0600`; `.arkor/` is already gitignored by the scaffolder. The content is `{ "token": "", "url": "http://localhost:", "port": , "pid": }`. Unlike the home token, a failure to write this file **aborts startup** with exit code 1: the session file is the agent's only token channel, so a server without it would be silently unusable. +1. **Project-local session file.** The per-launch CSRF token is written to a unique JSON file inside the project: `/.arkor/agent/session--.json`. The `.arkor/agent/` directory is created with mode `0700` and the file with mode `0600`; `.arkor/` is already gitignored by the scaffolder. The content is `{ "token": "", "url": "http://127.0.0.1:", "port": , "pid": }` (the `url` is the `127.0.0.1` literal the server bound, see below). Unlike the home token, a failure to write this file **aborts startup** with exit code 1: the session file is the agent's only token channel, so a server without it would be silently unusable. 2. **stdout contract.** Three lines, in this order; the prefixes of the first two are stable and safe to grep: ``` @@ -71,7 +71,7 @@ Everything in the launch sequence above happens unchanged, plus: 3. **Status probe.** `GET /api/status` is the one **token-exempt** `/api/*` route (still behind the loopback + Host guard): it is secrets-free and never executes project code, so a coding agent (and the port-collision probe) can read it without the token. It returns `status`, `server: "arkor-studio"`, `version`, `mode` (`"agent"` here), `url`, `pid`, `cwd`, and the list of available `endpoints`. It never includes credentials or the CSRF token, so it is the right first call to confirm the server and discover the API surface. Every OTHER `/api/*` route still requires the `X-Arkor-Studio-Token` header. -The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP` (exactly this launch's file, by its known unique path, so a co-located session that happens to share the pid is never touched). A crash (`SIGKILL`, power loss) can leave a `session-*.json` behind; it is inert (the token died with the server) and safe to delete. `arkor dev --agent` does not sweep other session files on startup: a liveness check would be unsafe when the project dir is a shared bind-mount across containers (pids are namespace-local). Selecting the newest file (below) means a stale leftover is never picked anyway. +The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP` (exactly this launch's file, by its known unique path, so a co-located session that happens to share the pid is never touched). A crash (`SIGKILL`, power loss) can leave a `session-*.json` (or a `session-*.json.tmp` staging remnant) behind; both are inert (the token died with the server) and safe to delete. `arkor dev --agent` does not sweep other session files on startup: a liveness check would be unsafe when the project dir is a shared bind-mount across containers (pids are namespace-local). Selecting the newest file (below) means a stale leftover is never picked anyway. The Studio SPA is still served in agent mode: a human can open the printed URL in a browser while the agent works, and every guard from "Loopback and CSRF model" below applies unchanged. @@ -199,7 +199,7 @@ bun dev --agent -Then, from the agent's shell, read the token out of the session file and confirm the server. Prefer the exact path printed on stdout. If you must glob, select the **newest** file (`ls -t`), never the lexically first: a crashed prior session can leave an older `session-*.json` behind, and picking it would hand you a dead token. The newest file is always this launch's live session. +Then, from the agent's shell, read the token out of the session file and confirm the server. **Prefer the exact path printed on stdout** (that is unambiguously your launch, even with other sessions in the same project). If you must glob, select the **newest** file (`ls -t`), never the lexically first: a crashed prior session can leave an older `session-*.json` behind, and picking it would hand you a dead token. In the common single-session case the newest is your live session; if you run two `arkor dev --agent` in the same project at once, only the printed path is unambiguous. ```bash SESSION_FILE=$(ls -t .arkor/agent/session-*.json | head -n 1) diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index ca6c48bc..e0a17ad7 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -60,7 +60,7 @@ bun dev 上記の起動シーケンスはすべてそのまま実行され、加えて次が行われます。 -1. **プロジェクト内セッションファイル。** この起動用の CSRF トークンを、プロジェクト内の固有な JSON ファイル `/.arkor/agent/session--.json` に書き込みます。`.arkor/agent/` ディレクトリーはモード `0700`、ファイルはモード `0600` で作成されます。`.arkor/` は scaffold の時点で gitignore 済みです。内容は `{ "token": "", "url": "http://localhost:", "port": , "pid": }` です。home のトークンと異なり、このファイルの書き込みに失敗すると **起動は中止され** exit code 1 になります。セッションファイルはエージェント唯一のトークン受け渡し経路であり、これが無いサーバーは呼び出し元から静かに使用不能になるためです。 +1. **プロジェクト内セッションファイル。** この起動用の CSRF トークンを、プロジェクト内の固有な JSON ファイル `/.arkor/agent/session--.json` に書き込みます。`.arkor/agent/` ディレクトリーはモード `0700`、ファイルはモード `0600` で作成されます。`.arkor/` は scaffold の時点で gitignore 済みです。内容は `{ "token": "", "url": "http://127.0.0.1:", "port": , "pid": }` です(`url` はサーバーがバインドした `127.0.0.1` リテラル。下記参照)。home のトークンと異なり、このファイルの書き込みに失敗すると **起動は中止され** exit code 1 になります。セッションファイルはエージェント唯一のトークン受け渡し経路であり、これが無いサーバーは呼び出し元から静かに使用不能になるためです。 2. **stdout の契約。** 次の 3 行がこの順で出力されます。先頭 2 行のプレフィックスは安定しており grep して安全です。 ``` @@ -71,7 +71,7 @@ bun dev 3. **ステータスプローブ。** `GET /api/status` は唯一の**トークン不要**な `/api/*` ルートです(ループバック + Host ガードは適用)。機密ゼロでプロジェクトのコードを実行しないため、コーディングエージェント(およびポート競合プローブ)はトークンなしで読めます。`status`、`server: "arkor-studio"`、`version`、`mode`(ここでは `"agent"`)、`url`、`pid`、`cwd`、利用可能な `endpoints` の一覧を返します。認証情報や CSRF トークンを含むことはないため、サーバーの確認と API サーフェスの発見に最初に呼ぶべきエンドポイントです。他の `/api/*` ルートは引き続き `X-Arkor-Studio-Token` ヘッダーが必須です。 -セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます(この起動の固有パスのファイルだけを消すので、pid をたまたま共有する別セッションには触れません)。クラッシュ(`SIGKILL`、電源断)では `session-*.json` が残ることがありますが、トークンはサーバーとともに死んでいるため無害で、削除して安全です。`arkor dev --agent` は起動時に他のセッションファイルを掃きません。プロジェクトディレクトリーが複数コンテナ間の共有 bind-mount の場合、pid は名前空間ローカルなので liveness チェックが安全でないためです。下記のように最新ファイルを選べば、古い残骸を掴むことはありません。 +セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます(この起動の固有パスのファイルだけを消すので、pid をたまたま共有する別セッションには触れません)。クラッシュ(`SIGKILL`、電源断)では `session-*.json`(あるいは `session-*.json.tmp` のステージング残骸)が残ることがありますが、いずれもトークンはサーバーとともに死んでいるため無害で、削除して安全です。`arkor dev --agent` は起動時に他のセッションファイルを掃きません。プロジェクトディレクトリーが複数コンテナ間の共有 bind-mount の場合、pid は名前空間ローカルなので liveness チェックが安全でないためです。下記のように最新ファイルを選べば、古い残骸を掴むことはありません。 エージェントモードでも Studio SPA は引き続き配信されます。エージェントが作業している間、人間は出力された URL をブラウザーで開けますし、下の「ループバックと CSRF モデル」のガードもすべてそのまま適用されます。 @@ -199,7 +199,7 @@ bun dev --agent -続いてエージェントのシェルから、セッションファイルのトークンを読んでサーバーを確認します。stdout に出力された正確なパスを使うのが第一候補です。glob する場合は辞書順の先頭ではなく **最新**(`ls -t`)を選んでください。クラッシュした前回セッションが古い `session-*.json` を残していることがあり、それを拾うと死んだトークンを掴みます。最新のファイルは常にこの起動のライブセッションです。 +続いてエージェントのシェルから、セッションファイルのトークンを読んでサーバーを確認します。**stdout に出力された正確なパスを使うのが第一候補です**(同一プロジェクトに他のセッションがあっても、これは確実にあなたの起動です)。glob する場合は辞書順の先頭ではなく **最新**(`ls -t`)を選んでください。クラッシュした前回セッションが古い `session-*.json` を残していることがあり、それを拾うと死んだトークンを掴みます。単一セッションの通常ケースでは最新があなたのライブセッションですが、同一プロジェクトで `arkor dev --agent` を同時に 2 つ動かす場合は、出力されたパスだけが一意です。 ```bash SESSION_FILE=$(ls -t .arkor/agent/session-*.json | head -n 1) diff --git a/e2e/studio/src/specs/agent.spec.ts b/e2e/studio/src/specs/agent.spec.ts index 9022a8a8..b315e034 100644 --- a/e2e/studio/src/specs/agent.spec.ts +++ b/e2e/studio/src/specs/agent.spec.ts @@ -60,6 +60,9 @@ test.describe("Agent mode contract", () => { expect(body.status).toBe("ok"); expect(body.server).toBe("arkor-studio"); expect(body.mode).toBe("agent"); + // The echoed url is the agent-facing 127.0.0.1 literal (not localhost). + expect(body.url).toBe(agentStudio.url); + expect(body.url).toBe(`http://127.0.0.1:${new URL(agentStudio.url).port}`); expect(body.endpoints).toContain("POST /api/train"); // Safe by contract: the CSRF token must never round-trip in the body. expect(JSON.stringify(body)).not.toContain( diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index d967b77e..fe73e8e0 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -6,6 +6,7 @@ import { readdirSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -1184,6 +1185,29 @@ describe("runDev", () => { expect(open).toHaveBeenCalledWith("http://localhost:4321"); }); + it("adopts when the occupant's cwd is a SYMLINK that realpath-resolves to this project (pins realpathSync)", async () => { + if (process.platform === "win32") { + // Symlink creation needs privileges on Windows; the realpath path is + // exercised on POSIX where the connect flow matters most. + return; + } + mockServeAddrInUse(); + // The occupant reports a DIFFERENT path STRING that resolves to the same + // real directory. A plain string compare would reject (no adopt); only + // realpathSync(b.cwd) === realpathSync(projectRoot) matches. Reverting + // the canonicalization to a string compare makes this test fail. + const linkPath = `${projectDir}-link`; + symlinkSync(projectDir, linkPath); + try { + mockProbe({ server: "arkor-studio", cwd: linkPath }); + await expect(runDev({ port: 4328, cwd: projectDir })).resolves.toEqual({ + adopted: true, + }); + } finally { + rmSync(linkPath, { force: true }); + } + }); + it("falls back to the port-in-use error when the occupant serves a DIFFERENT project", async () => { mockServeAddrInUse(); // Same-machine Arkor Studio, but a different project root -> do NOT adopt. @@ -1214,6 +1238,21 @@ describe("runDev", () => { ); }); + it("falls back when the occupant streams an over-cap body even if it WOULD otherwise match (byte cap)", async () => { + mockServeAddrInUse(); + // A 200 whose body has the right discriminator + cwd but is > 64 KiB. + // Only the byte cap can reject it, so this isolates readCapped: with an + // uncapped `res.json()` the launch would wrongly adopt. + mockProbe({ + server: "arkor-studio", + cwd: projectDir, + filler: "x".repeat(70 * 1024), + }); + await expect(runDev({ port: 4329, cwd: projectDir })).rejects.toThrow( + /Port 4329 is already in use/, + ); + }); + it("falls back on a non-200 even when the body WOULD otherwise match (isolates the res.ok guard)", async () => { mockServeAddrInUse(); // Body has the right discriminator AND a matching cwd, so only the @@ -1252,5 +1291,31 @@ describe("runDev", () => { ).rejects.toThrow(/Port 4326 is already in use/); expect(fetchSpy).not.toHaveBeenCalled(); }); + + it("wraps a non-EADDRINUSE pre-bind error (e.g. EACCES on a privileged port) in a clean ExpectedCliError", async () => { + // parseDevPort now permits 1-1023, so a non-root `arkor dev --port 80` + // hits EACCES. That must print a clean actionable line, not a minified + // dist stack, so runDev rejects with an ExpectedCliError. + vi.mocked(serve).mockImplementationOnce((() => { + const server = { + close: vi.fn(), + on: (event: string, cb: (err: NodeJS.ErrnoException) => void) => { + if (event === "error") { + queueMicrotask(() => + cb(Object.assign(new Error("bind EACCES"), { code: "EACCES" })), + ); + } + return server; + }, + }; + return server; + }) as unknown as typeof serve); + await expect(runDev({ port: 80, cwd: projectDir })).rejects.toMatchObject( + { + name: "ExpectedCliError", + message: expect.stringMatching(/Could not bind port 80:.*EACCES/), + }, + ); + }); }); }); diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index 3ce2c141..ff422bad 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -308,6 +308,38 @@ async function writeAgentSessionFile( * unreadable cwd) reports false and the caller falls back to the existing * port-in-use error. */ +/** + * Read a fetch `Response` body as UTF-8 text, giving up past `maxBytes`. The + * per-request AbortSignal bounds TIME; this bounds BYTES, so an untrusted + * loopback occupant streaming a huge 200 body cannot balloon the probing + * process's memory. Returns null if the body exceeds the cap or cannot be read. + */ +async function readCapped( + res: Response, + maxBytes: number, +): Promise { + const body = res.body; + if (body === null) return null; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + } catch { + return null; + } + return Buffer.concat(chunks).toString("utf8"); +} + async function probeExistingStudio( port: number, projectRoot: string, @@ -318,7 +350,19 @@ async function probeExistingStudio( { signal: AbortSignal.timeout(1500) }, ); if (!res.ok) return false; - const body: unknown = await res.json(); + // Read the body with a hard BYTE cap, not just the 1.5s time bound: the + // occupant is untrusted (any local process can squat a loopback port), so + // `res.json()` alone would let it stream an arbitrarily large 200 body + // into memory within the timeout window. A real /api/status payload is a + // few hundred bytes; 64 KiB is generous headroom. Over the cap -> reject. + const text = await readCapped(res, 64 * 1024); + if (text === null) return false; + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return false; + } if (typeof body !== "object" || body === null) return false; const b = body as { server?: unknown; cwd?: unknown }; if (b.server !== "arkor-studio") return false; @@ -551,12 +595,12 @@ export async function runDev(options: DevOptions = {}): Promise { return; } } - // Persisting the token to `~/.arkor/studio-token` is needed for - // the Vite SPA dev workflow and the port-collision probe. The - // bundled `:port` flow injects the meta tag at request time via - // `buildStudioApp`, so a failure here (read-only $HOME on Docker - // / locked-down CI / restrictive umask) must not block the - // server. + // Persisting the token to `~/.arkor/studio-token` is needed ONLY + // for the Vite SPA dev workflow (the port-collision probe reads no + // token; /api/status is token-exempt). The bundled `:port` flow + // injects the meta tag at request time via `buildStudioApp`, so a + // failure here (read-only $HOME on Docker / locked-down CI / + // restrictive umask) must not block the server. try { await persistStudioToken(studioToken); } catch (err) { @@ -642,7 +686,15 @@ export async function runDev(options: DevOptions = {}): Promise { })(); return; } - reject(err instanceof Error ? err : new Error(message)); + // Any other pre-bind listener failure (EACCES on a privileged port now + // that `--port` permits 1-1023, EADDRNOTAVAIL, ...) is a user-actionable + // bind error, so surface it as an ExpectedCliError: bin.ts prints this + // one line and exits 1 without dumping a minified `dist/bin.mjs` stack. + reject( + new ExpectedCliError(`Could not bind port ${port}: ${message}`, { + cause: err instanceof Error ? err : undefined, + }), + ); }); }); diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index ef620fa3..efb4ceff 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -247,6 +247,18 @@ describe("main (CLI Commander wiring)", () => { } }); + it("rejects non-decimal-integer --port forms (hex, exponent, decimal, padded)", async () => { + // The validator requires a plain decimal-integer string, so Number()'s + // coercions (0x1F4 -> 500, 4e3 -> 4000, 500.0 -> 500, " 80 " -> 80) do NOT + // silently bind a surprising port; each is rejected to match the docs. + delete process.env.CLAUDECODE; + for (const bad of ["0x1F4", "4e3", "500.0", " 80 ", "80\n"]) { + vi.mocked(runDev).mockClear(); + await expect(main(["dev", "--port", bad])).rejects.toThrow(/port/i); + expect(runDev).not.toHaveBeenCalled(); + } + }); + it("accepts the in-range --port boundaries (1 and 65535)", async () => { delete process.env.CLAUDECODE; for (const ok of [1, 65_535]) { @@ -308,8 +320,9 @@ describe("main (CLI Commander wiring)", () => { it("classifies a SERVING `dev` as long-running (suppresses cli_command_completed)", async () => { // runDev resolving { adopted: false } => the process keeps serving => // longRunning must be true so the near-zero-duration completed event is - // skipped. This exercises main.ts's `adopted = result.adopted` capture - // and `{ longRunning: () => !adopted }` wiring (inverting it fails here). + // skipped. This pins the `() => !adopted` predicate DIRECTION; the sibling + // CONNECT test (adopted: true) is what pins the `adopted = result.adopted` + // capture, since `adopted` defaults to false here either way. delete process.env.CLAUDECODE; vi.mocked(runDev).mockResolvedValueOnce({ adopted: false }); await main(["dev"]); diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index 1df8da29..19f4a9d5 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -35,8 +35,12 @@ import { ui } from "./prompts"; * `http://localhost:` URL, so an ephemeral port has no supported flow. */ function parseDevPort(raw: string): number { + // Require a plain decimal-integer string. `Number()` alone would accept + // hex (`0x1F4`), scientific notation (`4e3`), decimals (`500.0`), and + // whitespace-padded values, binding a surprising port while the error copy + // (and the docs) promise "an integer". `/^\d+$/` keeps the contract honest. const n = Number(raw); - if (!Number.isInteger(n) || n < 1 || n > 65_535) { + if (!/^\d+$/.test(raw) || !Number.isInteger(n) || n < 1 || n > 65_535) { throw new ExpectedCliError( `--port must be an integer between 1 and 65535 (got ${JSON.stringify( raw, diff --git a/packages/arkor/src/core/telemetry.test.ts b/packages/arkor/src/core/telemetry.test.ts index bba3b9e0..dd2b8e8e 100644 --- a/packages/arkor/src/core/telemetry.test.ts +++ b/packages/arkor/src/core/telemetry.test.ts @@ -424,7 +424,10 @@ describe("withTelemetry", () => { it("evaluates a function-form longRunning that stays true (dev serving skips completed)", async () => { const mod = await loadTelemetry({ key: "phc_test" }); // Never reassigned: the handler serves (does not "adopt"), so the - // predicate stays true and `cli_command_completed` is skipped. + // predicate stays true and `cli_command_completed` is skipped. Note this + // true-case cannot distinguish CALLING the predicate from `Boolean`- + // coercing the function object (both are truthy); the false-case sibling + // above ("...reports completed") is what proves the predicate is invoked. const adopted = false; const wrapped = mod.withTelemetry("dev", async () => {}, { longRunning: () => !adopted, From e421dad2fba7670df00a4d9ba889db89f44f90a1 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:59:07 -0700 Subject: [PATCH 08/19] fix(ENG-967): audit round 4 (probe-timeout test, coverage, JSDoc) - Assert the probe passes an AbortSignal: the 1.5s timeout was a load- bearing guard (Node fetch has no default timeout, so an occupant that accepts TCP but never responds would hang `arkor dev`) with zero coverage; deleting it now fails the connect test (mutation-verified). - Assert the agent-mode busy-port path writes no `.arkor/agent` (pins the bind-first ordering of the session-file write). - Tighten the e2e session-file location check to dirname === agentDir (not a loose prefix that would accept a sibling like `.arkor/agentX`). - Relocate the probeExistingStudio JSDoc back above its function (the round-3 readCapped insertion had orphaned it above readCapped). The remaining audit findings are the accepted, in-code-documented design tradeoffs (token-exempt /api/status metadata disclosure; unauthenticated connect-adoption of a loopback peer, which sends no token so the worst case is denial/redirect, never RCE) and are not defects. --- e2e/studio/src/specs/agent.spec.ts | 6 ++-- packages/arkor/src/cli/commands/dev.test.ts | 10 +++++- packages/arkor/src/cli/commands/dev.ts | 36 ++++++++++----------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/e2e/studio/src/specs/agent.spec.ts b/e2e/studio/src/specs/agent.spec.ts index b315e034..d5ae3a1e 100644 --- a/e2e/studio/src/specs/agent.spec.ts +++ b/e2e/studio/src/specs/agent.spec.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { expect, test } from "../harness/fixture"; import { spawnDevToExit } from "../harness/studioServer"; @@ -30,7 +30,9 @@ test.describe("Agent mode contract", () => { const sessionFile = agentStudio.sessionFile; expect(sessionFile).toBeDefined(); const agentDir = join(fixturePaths.projectDir, ".arkor", "agent"); - expect(sessionFile!.startsWith(agentDir)).toBe(true); + // Exact parent-dir match (not a loose prefix, which would also accept a + // sibling like `.arkor/agentX`). + expect(dirname(sessionFile!)).toBe(agentDir); const payload = readSession(sessionFile!); // The session file carries the agent-facing 127.0.0.1 URL (the literal the // server bound), which is exactly what the harness connects to. diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index fe73e8e0..d3a50fc7 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -1165,6 +1165,11 @@ describe("runDev", () => { ]; expect(String(reqUrl)).toBe("http://127.0.0.1:4320/api/status"); expect(reqInit?.headers).toBeUndefined(); + // The probe is time-bounded by an AbortSignal (Node fetch has no default + // timeout): without it, an occupant that accepts TCP but never responds + // would hang `arkor dev` forever. Assert the signal is passed so removing + // the timeout guard fails here. + expect(reqInit?.signal).toBeInstanceOf(AbortSignal); // No shutdown handler was registered by this doomed launch. expect(process.listeners("exit").length).toBe(exitBefore); }); @@ -1283,13 +1288,16 @@ describe("runDev", () => { ); }); - it("agent mode never probes: EADDRINUSE stays a hard error", async () => { + it("agent mode never probes: EADDRINUSE stays a hard error and writes no session file", async () => { mockServeAddrInUse(); const fetchSpy = mockProbe({ server: "arkor-studio", cwd: projectDir }); await expect( runDev({ port: 4326, agent: true, cwd: projectDir }), ).rejects.toThrow(/Port 4326 is already in use/); expect(fetchSpy).not.toHaveBeenCalled(); + // Bind-first ordering: the session file is written only in the listening + // callback, so a doomed busy-port launch leaves no `.arkor/agent`. + expect(existsSync(join(projectDir, ".arkor", "agent"))).toBe(false); }); it("wraps a non-EADDRINUSE pre-bind error (e.g. EACCES on a privileged port) in a clean ExpectedCliError", async () => { diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index ff422bad..eb6b57bd 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -290,24 +290,6 @@ async function writeAgentSessionFile( } } -/** - * True when the busy `port` is held by a running Arkor Studio that serves - * THIS project. Used by the normal-mode EADDRINUSE path so a plain - * `arkor dev` connects to an already-running Studio (typically an - * `arkor dev --agent` session that owns the port) instead of failing. - * - * The probe hits the token-EXEMPT `GET /api/status` over `127.0.0.1` (not - * `localhost`, matching the server's IPv4 bind and avoiding the `::1`-first - * resolution the module goes out of its way to dodge). No token is sent: the - * endpoint is secrets-free, so we never disclose the CSRF token to an - * unverified port occupant. Adoption requires both the `server: - * "arkor-studio"` discriminator AND that the instance's `cwd` (realpath) - * equals this launch's project root, so a plain `arkor dev` in project B - * never silently attaches to project A's Studio on the same default port. - * Best-effort: any failure (timeout, non-Studio occupant, different project, - * unreadable cwd) reports false and the caller falls back to the existing - * port-in-use error. - */ /** * Read a fetch `Response` body as UTF-8 text, giving up past `maxBytes`. The * per-request AbortSignal bounds TIME; this bounds BYTES, so an untrusted @@ -340,6 +322,24 @@ async function readCapped( return Buffer.concat(chunks).toString("utf8"); } +/** + * True when the busy `port` is held by a running Arkor Studio that serves + * THIS project. Used by the normal-mode EADDRINUSE path so a plain + * `arkor dev` connects to an already-running Studio (typically an + * `arkor dev --agent` session that owns the port) instead of failing. + * + * The probe hits the token-EXEMPT `GET /api/status` over `127.0.0.1` (not + * `localhost`, matching the server's IPv4 bind and avoiding the `::1`-first + * resolution the module goes out of its way to dodge). No token is sent: the + * endpoint is secrets-free, so we never disclose the CSRF token to an + * unverified port occupant. Adoption requires both the `server: + * "arkor-studio"` discriminator AND that the instance's `cwd` (realpath) + * equals this launch's project root, so a plain `arkor dev` in project B + * never silently attaches to project A's Studio on the same default port. + * Best-effort: any failure (timeout, non-Studio occupant, different project, + * unreadable cwd) reports false and the caller falls back to the existing + * port-in-use error. + */ async function probeExistingStudio( port: number, projectRoot: string, From 1428227d53fced82de269d0e22ab667819d43398 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:53:30 -0700 Subject: [PATCH 09/19] fix(ENG-967): audit round 5 (CSRF-doc carve-out, bin.ts coverage, port strictness) - docs/cli/dev.mdx (EN+JA): the "Loopback and CSRF model" three-checks list still claimed the token is required on every /api/* with no GET /api/status carve-out, contradicting the same page. Added the exemption (the sibling studio/concepts pages already had it). - Errors table (EN+JA): documented the new "Could not bind port " ExpectedCliError (EACCES on a privileged port, EADDRNOTAVAIL). - Test coverage: the agent hard-fail test now asserts the rejection is an ExpectedCliError (not just any throw); a new e2e spawns the real bin with an invalid --port and asserts bin.ts prints a clean one-liner with no V8/dist stack, covering the ExpectedCliError HANDLING branch that had none. Both mutation-verified. - parseDevPort: reject leading-zero forms (`080`) too via /^[1-9]\d*$/, so the canonical-decimal-integer contract is exact. Remaining audit findings are the accepted, documented tradeoffs (token-exempt /api/status metadata; unauthenticated connect-adoption of a loopback peer) plus one niche edge (a first-EVER `arkor dev` that is also offline cannot adopt an existing Studio because the credential bootstrap runs first) that is left as-is: the primary connect case is an agent session that already bootstrapped credentials. --- docs/cli/dev.mdx | 3 ++- docs/ja/cli/dev.mdx | 3 ++- e2e/cli/src/arkor-dev.test.ts | 20 ++++++++++++++++++++ packages/arkor/src/cli/commands/dev.test.ts | 8 +++++++- packages/arkor/src/cli/main.test.ts | 2 +- packages/arkor/src/cli/main.ts | 11 ++++++----- 6 files changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index 842ba3d6..2f209667 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -96,7 +96,7 @@ Re-run with the --agent flag: The Studio server enforces three checks on every `/api/*` request: 1. The `Host` header must be `127.0.0.1` or `localhost` (defense against DNS rebinding). -2. The CSRF token must be present as the `X-Arkor-Studio-Token` header. The job-event stream also accepts `?studioToken=...` because `EventSource` cannot send custom headers; mutation routes do not accept query-string tokens. Token comparison is `timingSafeEqual`. +2. The CSRF token must be present as the `X-Arkor-Studio-Token` header, with one exception: the secrets-free `GET /api/status` probe is token-exempt (see above). The job-event stream also accepts `?studioToken=...` because `EventSource` cannot send custom headers; mutation routes do not accept query-string tokens. Token comparison is `timingSafeEqual`. 3. CORS is intentionally not configured: the SPA is same-origin so CORS adds no value, and reflecting `*` would let "simple" cross-origin POSTs (`text/plain`, `urlencoded`) skip preflight. Without a token, the middleware rejects them. This means `arkor dev` is safe on a shared dev machine: another tab cannot read the meta, a stale tab from a previous launch holds an old token that no longer matches, and an attacker page in a different origin cannot forge requests. @@ -117,6 +117,7 @@ The token file (`~/.arkor/studio-token`) is written only **after** the port bind | Symptom | What it means | Fix | | --- | --- | --- | | ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.`` | Another process holds the port (the underlying error is `EADDRINUSE`) and, in normal mode, the running-Studio probe did not confirm an Arkor Studio. In agent mode this fires without probing. | Stop it, or use `--port `. | +| `Could not bind port : ` (exit `1`) | A non-`EADDRINUSE` bind failure, e.g. `EACCES` binding a privileged port (`1`-`1023`) as a non-root user, or `EADDRNOTAVAIL`. | Use an unprivileged `--port` (>= 1024), or run with the privileges the port requires. | | `Arkor Studio already running on http://localhost:` (exit `0`) | Normal mode found a healthy Arkor Studio serving THIS project on the port (often an `arkor dev --agent` session) and connected to it instead of failing. Informational, not an error. | Nothing required. Open the printed URL; pass `--open` to have the CLI do it. | | `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.` (exit `1`) | The Claude Code strict gate: `CLAUDECODE=1` is set and `--agent` was not passed. No server was started and no token or session file was written. | Re-run as `arkor dev --agent` and use the printed session file. | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.` (exit `1`) | Agent mode could not create `.arkor/agent/` or the session file (read-only project dir, permission problem). Unlike the home token this is fatal: the file is the agent's only token channel. | Make the project directory writable (check `.arkor/` permissions) and re-run. | diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index e0a17ad7..28f72f5c 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -96,7 +96,7 @@ Re-run with the --agent flag: Studio サーバーはすべての `/api/*` リクエストに 3 つのチェックを課します。 1. `Host` ヘッダーは `127.0.0.1` か `localhost`(DNS リバインディング対策)。 -2. CSRF トークンは `X-Arkor-Studio-Token` ヘッダーか `?studioToken=...`(カスタムヘッダーを送れない `EventSource` 用)として必須。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。 +2. CSRF トークンは `X-Arkor-Studio-Token` ヘッダーか `?studioToken=...`(カスタムヘッダーを送れない `EventSource` 用)として必須。ただし 1 つだけ例外があり、機密ゼロの `GET /api/status` プローブはトークン不要です(上記参照)。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。 3. CORS は意図的に未設定。SPA は同一オリジンなので CORS は意味を持たず、`*` を反射すると preflight をスキップする「simple」なクロスオリジン POST(`text/plain`、`urlencoded`)を素通りさせてしまう。トークンが無ければミドルウェアが拒否します。 これにより `arkor dev` は共有の開発環境でも安全です。別タブは meta を読めず、過去の起動の古いタブはトークンが一致せず、別オリジンの攻撃者ページはリクエストを偽造できません。 @@ -117,6 +117,7 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | 症状 | 意味 | 対処 | | --- | --- | --- | | ``Port 4000 is already in use. Another `arkor dev` may be running; pass --port to choose a different one.``(ポート 4000 は既に使用中。根本のエラーは `EADDRINUSE`) | 別プロセスがポートを保持していて、通常モードでは起動中 Studio プローブが Arkor Studio を確認できなかった。エージェントモードではプローブなしでこのエラーになる。 | 止めるか `--port `。 | +| `Could not bind port : `(exit `1`) | `EADDRINUSE` 以外のバインド失敗。例: 非 root で特権ポート(`1`-`1023`)をバインドしたときの `EACCES`、または `EADDRNOTAVAIL`。 | 非特権ポート(1024 以上)を `--port` で指定するか、そのポートに必要な権限で実行する。 | | `Arkor Studio already running on http://localhost:`(exit `0`) | 通常モードがポート上でこのプロジェクトを提供する健全な Arkor Studio(多くは `arkor dev --agent` セッション)を発見し、失敗ではなく接続扱いにした。エラーではなく情報行。 | 何もしなくてよい。出力された URL を開く。CLI に開かせたいなら `--open`。 | | `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.`(exit `1`) | Claude Code の厳格ゲート。`CLAUDECODE=1` が設定されていて `--agent` が渡されていない。サーバーは起動されず、トークンファイルもセッションファイルも書き込まれていない。 | `arkor dev --agent` で再実行し、出力されたセッションファイルを使う。 | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.`(exit `1`) | エージェントモードが `.arkor/agent/` またはセッションファイルを作成できなかった(読み取り専用のプロジェクトディレクトリー、権限の問題)。home のトークンと異なりこれは致命的で、ファイルはエージェント唯一のトークン経路。 | プロジェクトディレクトリーを書き込み可能にして(`.arkor/` の権限を確認)再実行。 | diff --git a/e2e/cli/src/arkor-dev.test.ts b/e2e/cli/src/arkor-dev.test.ts index 96dbea83..e5872f60 100644 --- a/e2e/cli/src/arkor-dev.test.ts +++ b/e2e/cli/src/arkor-dev.test.ts @@ -48,6 +48,26 @@ describe("arkor dev × CLAUDECODE strict gate (E2E)", () => { } }); + it("prints an ExpectedCliError (invalid --port) as a clean one-liner with no stack (bin.ts contract)", async () => { + const dir = makeTempDir("arkor-dev-port-e2e-"); + try { + const result = await runCli(ARKOR_BIN, ["dev", "--port", "0"], dir, { + HOME: dir, + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain( + "--port must be an integer between 1 and 65535", + ); + // The whole point of ExpectedCliError: bin.ts prints `err.message` alone, + // so no `dist/bin.mjs` code-frame / V8 stack lines leak out. + expect(result.stderr).not.toMatch(/^\s+at\s/m); + expect(result.stderr).not.toContain("ExpectedCliError:"); + expect(result.stderr).not.toContain("bin.mjs"); + } finally { + cleanup(dir); + } + }); + it("advertises --agent in `dev --help`", async () => { const dir = makeTempDir("arkor-dev-help-e2e-"); try { diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index d3a50fc7..ace228af 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -1034,9 +1034,15 @@ describe("runDev", () => { .spyOn(process.stdout, "write") .mockImplementation((() => true) as typeof process.stdout.write); try { + // ExpectedCliError (not the raw fs Error) so bin.ts prints the one + // actionable line without a minified stack; reverting to the raw + // error must fail here. await expect( runDev({ port: 4313, agent: true, cwd: projectDir }), - ).rejects.toThrow(); + ).rejects.toMatchObject({ + name: "ExpectedCliError", + message: expect.stringMatching(/Could not write the agent session/), + }); } finally { stdoutSpy.mockRestore(); chmodSync(join(projectDir, ".arkor"), 0o755); diff --git a/packages/arkor/src/cli/main.test.ts b/packages/arkor/src/cli/main.test.ts index efb4ceff..6c3dab28 100644 --- a/packages/arkor/src/cli/main.test.ts +++ b/packages/arkor/src/cli/main.test.ts @@ -252,7 +252,7 @@ describe("main (CLI Commander wiring)", () => { // coercions (0x1F4 -> 500, 4e3 -> 4000, 500.0 -> 500, " 80 " -> 80) do NOT // silently bind a surprising port; each is rejected to match the docs. delete process.env.CLAUDECODE; - for (const bad of ["0x1F4", "4e3", "500.0", " 80 ", "80\n"]) { + for (const bad of ["0x1F4", "4e3", "500.0", " 80 ", "80\n", "080", "00"]) { vi.mocked(runDev).mockClear(); await expect(main(["dev", "--port", bad])).rejects.toThrow(/port/i); expect(runDev).not.toHaveBeenCalled(); diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index 19f4a9d5..1861269b 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -35,12 +35,13 @@ import { ui } from "./prompts"; * `http://localhost:` URL, so an ephemeral port has no supported flow. */ function parseDevPort(raw: string): number { - // Require a plain decimal-integer string. `Number()` alone would accept - // hex (`0x1F4`), scientific notation (`4e3`), decimals (`500.0`), and - // whitespace-padded values, binding a surprising port while the error copy - // (and the docs) promise "an integer". `/^\d+$/` keeps the contract honest. + // Require a canonical decimal-integer string (no leading zero). `Number()` + // alone would accept hex (`0x1F4`), scientific notation (`4e3`), decimals + // (`500.0`), whitespace-padded, and zero-padded (`080`) values, binding a + // surprising port while the error copy (and the docs) promise "an integer". + // `/^[1-9]\d*$/` keeps the contract honest. const n = Number(raw); - if (!/^\d+$/.test(raw) || !Number.isInteger(n) || n < 1 || n > 65_535) { + if (!/^[1-9]\d*$/.test(raw) || !Number.isInteger(n) || n < 1 || n > 65_535) { throw new ExpectedCliError( `--port must be an integer between 1 and 65535 (got ${JSON.stringify( raw, From d4e123c9344e9bbece338ef279fd37ba27826a40 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:28:43 -0700 Subject: [PATCH 10/19] test(ENG-967): harden agent-mode probe/bootstrap tests and status contract (self-review r6) - dev.ts: defer credential-bootstrap failure so an offline first-run launch can still adopt an already-running Studio; only rethrow when this process serves. Normalise the captured error to an Error at the catch site. - dev.test.ts: add a fake-timer behavioral test that the 1500ms probe timeout actually fires (mutation-verified: an unbounded signal hangs the test), plus two deferred-bootstrap tests (adopt-when-offline vs reject-when-serving) and a redirect:manual assertion on the probe fetch. - server.test.ts: assert the FULL /api/status endpoints array (contract), not a spot-check. - server.ts: correct the studioToken JSDoc for the token-exempt /api/status. - seedFixture.ts: wrap cleanup rmSync in try/catch (Windows EPERM/EBUSY), match e2e/cli. - AGENTS.md: soften the 'ls -t newest is always the live launch' overclaim for concurrent sessions. --- AGENTS.md | 2 +- e2e/studio/src/harness/seedFixture.ts | 10 ++- packages/arkor/src/cli/commands/dev.test.ts | 80 ++++++++++++++++++++- packages/arkor/src/cli/commands/dev.ts | 41 +++++++++-- packages/arkor/src/studio/server.test.ts | 23 +++++- packages/arkor/src/studio/server.ts | 11 +-- 6 files changed, 154 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 98179b04..bc646da5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ cd my-arkor-app && pnpm dev # Studio at http://127.0. 1. Passes it to `buildStudioApp({ studioToken })`. The Hono server validates every `/api/*` request via `X-Arkor-Studio-Token` header (or `?studioToken=` query for `EventSource`, which can't set headers). Comparison uses `timingSafeEqual`. **One carve-out:** `GET /api/status` is token-exempt (still behind the loopback + Host guard) because it is secrets-free and never executes user code; this lets the port-collision probe confirm an occupant without transmitting the token to an unverified peer (see item 4). 2. Persists it to `~/.arkor/studio-token` (mode 0600) so the SPA dev workflow (`pnpm --filter @arkor/studio-app dev`) can read it via the `arkor-studio-token` Vite plugin in [packages/studio-app/vite.config.ts](packages/studio-app/vite.config.ts), which injects `` into `index.html` on each request. Persistence failure must NOT block server start (read-only `$HOME` on Docker, etc.); just warn. 3. Cleans up on `exit`/SIGINT/SIGTERM/SIGHUP via `unlinkSync`, after verifying the file still holds this instance's token (a second `arkor dev` on another port may have overwritten it; last writer wins and the previous owner must not delete it). Signal handlers exit with the conventional `128 + signal` code (SIGINT -> 130, SIGTERM -> 143, SIGHUP -> 129) so supervisors can distinguish signal termination from a clean exit. -4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). Agent mode does NOT sweep or reap other session files (an earlier pid-liveness reap was removed: `process.kill(pid, 0)` is namespace-local, so on a shared bind-mount a LIVE foreign-container session looks dead and would be deleted). A crashed session leaves an inert file; the docs recipe selects the newest (`ls -t`), which is always the live launch, so a stale leftover is never picked. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. The session file `url` (and the `/api/status` echo) is the agent-facing `http://127.0.0.1:` literal, NOT `localhost` (an agent's HTTP client may not do Happy-Eyeballs, so it must get the IPv4 the server actually bound); the human-facing stdout line and the `--open` browser target still use `localhost`. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath, must be absolute) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Adoption is UNAUTHENTICATED (it trusts the loopback peer's self-reported `/api/status`); this is deliberate because the probe sends no token, so an impostor can never obtain the CSRF token or reach `POST /api/train` (RCE) -- worst case is a nuisance denial/`--open` redirect, never code execution. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. +4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). Agent mode does NOT sweep or reap other session files (an earlier pid-liveness reap was removed: `process.kill(pid, 0)` is namespace-local, so on a shared bind-mount a LIVE foreign-container session looks dead and would be deleted). A crashed session leaves an inert file; the docs recipe selects the newest (`ls -t`), which is never a stale crashed leftover (those are older than any live session), though with two concurrent `--agent` launches in one project only the stdout-printed path is unambiguous. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. The session file `url` (and the `/api/status` echo) is the agent-facing `http://127.0.0.1:` literal, NOT `localhost` (an agent's HTTP client may not do Happy-Eyeballs, so it must get the IPv4 the server actually bound); the human-facing stdout line and the `--open` browser target still use `localhost`. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath, must be absolute) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Adoption is UNAUTHENTICATED (it trusts the loopback peer's self-reported `/api/status`); this is deliberate because the probe sends no token, so an impostor can never obtain the CSRF token or reach `POST /api/train` (RCE) -- worst case is a nuisance denial/`--open` redirect, never code execution. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. `/api/*` middleware also enforces a host-header allow-list (`127.0.0.1`/`localhost`) for DNS-rebinding defence. **CORS is intentionally NOT configured**: the SPA is same-origin so reflecting `*` would let "simple" cross-origin POSTs reach handlers. The token check rejects those; cross-origin tabs cannot read the SPA's ``. diff --git a/e2e/studio/src/harness/seedFixture.ts b/e2e/studio/src/harness/seedFixture.ts index f301dccd..82560b3f 100644 --- a/e2e/studio/src/harness/seedFixture.ts +++ b/e2e/studio/src/harness/seedFixture.ts @@ -25,7 +25,15 @@ export function makeTempDir(prefix: string): string { } export function cleanup(dir: string): void { - rmSync(dir, { recursive: true, force: true }); + // Best-effort: a spawned `arkor dev` (esp. the agentStudio fixture's second + // child, force-killed on Windows) can briefly hold a handle inside `dir`, so + // rmSync may throw EPERM/EBUSY on Windows. Swallow it: teardown must not + // fail the test. Mirrors e2e/cli's cleanup. + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort teardown + } } /** diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index ace228af..37c09f2e 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -1174,8 +1174,11 @@ describe("runDev", () => { // The probe is time-bounded by an AbortSignal (Node fetch has no default // timeout): without it, an occupant that accepts TCP but never responds // would hang `arkor dev` forever. Assert the signal is passed so removing - // the timeout guard fails here. + // the timeout guard fails here (the timeout DURATION is exercised + // behaviorally by the "times out on a silent occupant" test below). expect(reqInit?.signal).toBeInstanceOf(AbortSignal); + // Never follow a redirect from an untrusted occupant (no blind SSRF). + expect(reqInit?.redirect).toBe("manual"); // No shutdown handler was registered by this doomed launch. expect(process.listeners("exit").length).toBe(exitBefore); }); @@ -1331,5 +1334,80 @@ describe("runDev", () => { }, ); }); + + it("times out and falls back when the occupant accepts the connection but never responds", async () => { + // Exercises the timeout DURATION, not just that a signal is passed: + // fetch never resolves and only rejects when the AbortSignal fires. + // Weakening the guard to an unbounded signal makes this test hang/fail. + mockServeAddrInUse(); + vi.useFakeTimers(); + globalThis.fetch = vi.fn( + (_url: unknown, init?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + // probeExistingStudio catches every throw, so the rejection reason + // is irrelevant; a plain Error keeps prefer-promise-reject-errors + // happy. What matters is that ONLY the abort fires it. + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted by AbortSignal.timeout")); + }); + }), + ) as unknown as typeof fetch; + try { + // Await the rejection inline (Promise.all) while driving the fake + // clock past 1500ms so AbortSignal.timeout fires and the probe bails. + await Promise.all([ + expect(runDev({ port: 4332, cwd: projectDir })).rejects.toThrow( + /Port 4332 is already in use/, + ), + vi.advanceTimersByTimeAsync(1600), + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("adopts an existing Studio even when first-run credential bootstrap hard-fails (offline)", async () => { + // Deferred bootstrap: with no credentials AND the config fetch failing, + // ensureCredentialsForStudio throws, but the adopt path needs no + // credentials, so a first-run offline launch must still connect. + rmSync(join(fakeHome, ".arkor", "credentials.json"), { force: true }); + mockServeAddrInUse(); + globalThis.fetch = vi.fn(async (input: unknown) => { + if (String(input).includes("/api/status")) { + return Response.json({ server: "arkor-studio", cwd: projectDir }); + } + throw new TypeError("fetch failed"); // config/anon bootstrap fails + }) as unknown as typeof fetch; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + try { + await expect(runDev({ port: 4330, cwd: projectDir })).resolves.toEqual({ + adopted: true, + }); + } finally { + stdoutSpy.mockRestore(); + } + }); + + it("still rejects when bootstrap hard-fails AND this process actually serves", async () => { + // The deferral only helps the adopt path: if we bind and serve (serving + // needs credentials), the deferred bootstrap error is surfaced. + rmSync(join(fakeHome, ".arkor", "credentials.json"), { force: true }); + // Default serve mock fires the listening callback (successful bind). + globalThis.fetch = vi.fn(async () => { + throw new TypeError("fetch failed"); + }) as unknown as typeof fetch; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + try { + await expect(runDev({ port: 4331, cwd: projectDir })).rejects.toThrow( + /fetch failed/, + ); + } finally { + stdoutSpy.mockRestore(); + } + }); }); }); diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index eb6b57bd..c9b97e56 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -347,7 +347,14 @@ async function probeExistingStudio( try { const res = await fetch( new URL("/api/status", `http://127.0.0.1:${port}`), - { signal: AbortSignal.timeout(1500) }, + { + signal: AbortSignal.timeout(1500), + // A real /api/status is terminal and never redirects. `redirect: + // "manual"` stops an untrusted port occupant from bouncing this CLI-side + // GET to an arbitrary URL (a 3xx then yields an opaque, non-ok response + // that fails the check below), removing that blind-request surface. + redirect: "manual", + }, ); if (!res.ok) return false; // Read the body with a hard BYTE cap, not just the 1.5s time bound: the @@ -426,7 +433,20 @@ export interface RunDevResult { } export async function runDev(options: DevOptions = {}): Promise { - await ensureCredentialsForStudio(); + // DEFER a credential-bootstrap hard failure instead of failing up front. The + // normal-mode "connect to an already-running Studio and exit" (adopt) path + // starts no server and needs no credentials, so a first-run OFFLINE launch + // should still be able to adopt an existing local Studio. We rethrow the + // error only if this process actually binds and serves (which does need + // credentials); the EADDRINUSE adopt path ignores it. + let bootstrapError: Error | undefined; + try { + await ensureCredentialsForStudio(); + } catch (err) { + // Normalise at capture so the later `reject(bootstrapError)` always hands + // the promise an Error (a bare throw is theoretically `unknown`). + bootstrapError = err instanceof Error ? err : new Error(String(err)); + } const port = options.port ?? 4000; const agent = options.agent === true; @@ -443,8 +463,9 @@ export async function runDev(options: DevOptions = {}): Promise { // Set true only on the normal-mode "connect to a running Studio" path. let adopted = false; // Per-launch CSRF token: injected into index.html as , required on - // every /api/* request. Prevents another tab on the same machine from - // hitting `arkor start` (and therefore RCE via dynamic import). + // every /api/* request except the token-exempt GET /api/status probe. + // Prevents another tab on the same machine from hitting `arkor start` + // (and therefore RCE via dynamic import). const studioToken = randomBytes(32).toString("base64url"); // Bind to 127.0.0.1 (not "localhost") so the listener can't end up on `::1` @@ -549,6 +570,18 @@ export async function runDev(options: DevOptions = {}): Promise { } }); void (async () => { + // We are about to SERVE, which needs credentials. If the up-front + // bootstrap hard-failed (deferred above), surface it now: close the + // just-bound listener and reject. The adopt path never reaches here. + if (bootstrapError !== undefined) { + try { + server.close(); + } catch { + // best-effort + } + reject(bootstrapError); + return; + } if (agent && agentSessionPath !== undefined) { // The session file is the agent's only realistic token channel // (an agent does not scrape the tag the SPA reads), so diff --git a/packages/arkor/src/studio/server.test.ts b/packages/arkor/src/studio/server.test.ts index 5ab9f103..6d1c8c44 100644 --- a/packages/arkor/src/studio/server.test.ts +++ b/packages/arkor/src/studio/server.test.ts @@ -313,7 +313,28 @@ describe("Studio server", () => { cwd: trainCwd, }); expect(typeof body.version).toBe("string"); - expect(body.endpoints).toContain("GET /api/status"); + // Assert the FULL contract, not a spot-check: the endpoints array is the + // agent's discovery surface, so a dropped/renamed/reordered route is a + // silent contract break. `/api/status` must lead (it is the probe's own + // entry) and no route may carry the studio token or credentials. + expect(body.endpoints).toEqual([ + "GET /api/status", + "GET /api/credentials", + "GET /api/me", + "GET /api/manifest", + "GET /api/jobs", + "GET /api/jobs/:id/events", + "POST /api/train", + "POST /api/inference/chat", + "GET /api/deployments", + "POST /api/deployments", + "GET /api/deployments/:id", + "PATCH /api/deployments/:id", + "DELETE /api/deployments/:id", + "GET /api/deployments/:id/keys", + "POST /api/deployments/:id/keys", + "DELETE /api/deployments/:id/keys/:keyId", + ]); expect(body.endpoints).toContain("POST /api/train"); expect(body.endpoints).toContain("GET /api/jobs/:id/events"); }); diff --git a/packages/arkor/src/studio/server.ts b/packages/arkor/src/studio/server.ts index 25bc0c43..8294d91f 100644 --- a/packages/arkor/src/studio/server.ts +++ b/packages/arkor/src/studio/server.ts @@ -54,11 +54,12 @@ export interface StudioServerOptions { autoAnonymous?: boolean; /** * Per-launch CSRF token. Every `/api/*` request must include it via header - * `X-Arkor-Studio-Token`; the job-event stream also accepts `?studioToken=` - * because `EventSource` cannot carry custom headers. The token is injected - * into the served `index.html` as a `` tag so the same-origin SPA can - * read it; cross-origin tabs cannot, so even a "simple" CORS POST without - * preflight is rejected. + * `X-Arkor-Studio-Token`, EXCEPT the token-exempt `GET /api/status` probe; + * the job-event stream also accepts `?studioToken=` because `EventSource` + * cannot carry custom headers. The token is injected into the served + * `index.html` as a `` tag so the same-origin SPA can read it; + * cross-origin tabs cannot, so even a "simple" CORS POST without preflight + * is rejected. */ studioToken: string; /** From fc84107ec27dd0eaf0848c2185796811516cd788 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:14:11 -0700 Subject: [PATCH 11/19] test(ENG-967): fix probe-timeout test to be honest and fast (self-review r7) Round-7 audit (adversarial, 2/2 verifiers CONFIRMED) caught that vitest fake timers do NOT drive AbortSignal.timeout: the previous test's vi.useFakeTimers()/ advanceTimersByTimeAsync scaffolding was inert and its comment was factually wrong. The test only passed by burning ~1.5s of REAL wall-clock, silently adding that to every suite run. Rewrite to spy on AbortSignal.timeout instead: assert it is called with exactly 1500 (an unbounded 'new AbortController().signal' never calls it) and fire the abort synchronously so the test runs in ~25ms (was ~1535ms). Mutation-verified load-bearing against BOTH an unbounded signal and a wrong duration (15000). --- packages/arkor/src/cli/commands/dev.test.ts | 41 ++++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 37c09f2e..17f5133f 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -1335,12 +1335,28 @@ describe("runDev", () => { ); }); - it("times out and falls back when the occupant accepts the connection but never responds", async () => { - // Exercises the timeout DURATION, not just that a signal is passed: - // fetch never resolves and only rejects when the AbortSignal fires. - // Weakening the guard to an unbounded signal makes this test hang/fail. + it("time-bounds the probe with AbortSignal.timeout(1500) and falls back when the occupant never responds", async () => { + // Asserts the probe is TIME-BOUNDED, not merely that some signal is + // passed. NOTE: vitest fake timers do NOT drive AbortSignal.timeout (it + // uses an internal, unmockable timer), so we cannot advance a fake clock + // to fire it. Instead we spy on AbortSignal.timeout: this both proves the + // production code requests the 1500ms bound (an unbounded + // `new AbortController().signal` never calls it -> the toHaveBeenCalled + // assertion fails) AND lets us fire the abort synchronously so the test + // stays fast (no real 1.5s wall-clock wait). mockServeAddrInUse(); - vi.useFakeTimers(); + const timeoutSpy = vi + .spyOn(AbortSignal, "timeout") + .mockImplementation((ms) => { + const ac = new AbortController(); + // Fire on the next microtask so the mocked fetch below (which only + // rejects on abort) settles promptly, standing in for the real + // occupant that accepts the connection but never responds. + queueMicrotask(() => { + ac.abort(new Error(`stub timeout after ${String(ms)}ms`)); + }); + return ac.signal; + }); globalThis.fetch = vi.fn( (_url: unknown, init?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { @@ -1353,16 +1369,13 @@ describe("runDev", () => { }), ) as unknown as typeof fetch; try { - // Await the rejection inline (Promise.all) while driving the fake - // clock past 1500ms so AbortSignal.timeout fires and the probe bails. - await Promise.all([ - expect(runDev({ port: 4332, cwd: projectDir })).rejects.toThrow( - /Port 4332 is already in use/, - ), - vi.advanceTimersByTimeAsync(1600), - ]); + await expect(runDev({ port: 4332, cwd: projectDir })).rejects.toThrow( + /Port 4332 is already in use/, + ); + // The exact bound is the contract; assert it, not just "was called". + expect(timeoutSpy).toHaveBeenCalledWith(1500); } finally { - vi.useRealTimers(); + timeoutSpy.mockRestore(); } }); From dd5e221e3c9e8b96aaf1e24be4964e851b3b74f9 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:47:18 -0700 Subject: [PATCH 12/19] fix(ENG-967): self-review r8 (doc precision, JSDoc steering, close coverage) Round-8 adversarial pass over the full branch (initial implementation plus rounds 1-7). No functional or security defects survived; this lands the remaining copy and test-hardening nits, after merging origin/main so the branch no longer diffs as a version downgrade: - server.ts: StudioServerOptions.url JSDoc said "Public URL the CLI printed (e.g. http://localhost:4000)", steering a future reader toward echoing the human-facing localhost form; it now states the agent-facing 127.0.0.1 literal contract and points at the url/agentUrl split. - docs/ja/cli/dev.mdx: the CSRF-model item 2 still presented ?studioToken= as a general header alternative; now scoped to the job-event stream with the mutation-route restriction, matching EN. - Port error copy: "--port must be a plain decimal integer between 1 and 65535" (main.ts, e2e assertion, docs EN+JA): the old wording was false for in-range non-canonical forms (080, 4e3, 500.0), which the validator rejects on purpose. - AGENTS.md + dev.mdx EN/JA: the "a stale leftover is never newest" claim is only true for sessions that crashed before yours started; a later-started crashed session can leave a newer file. Reworded. - dev.test.ts: the bootstrap-hard-fail-when-serving test now pins server.close() with a close spy (the swallowing try/catch made that branch mutation-invisible); fixed a stale above/below comment in dev.ts. --- AGENTS.md | 2 +- docs/cli/dev.mdx | 4 ++-- docs/ja/cli/dev.mdx | 6 +++--- e2e/cli/src/arkor-dev.test.ts | 2 +- packages/arkor/src/cli/commands/dev.test.ts | 16 +++++++++++++++- packages/arkor/src/cli/commands/dev.ts | 2 +- packages/arkor/src/cli/main.ts | 2 +- packages/arkor/src/studio/server.ts | 8 ++++++-- 8 files changed, 30 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc646da5..9d84732d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ cd my-arkor-app && pnpm dev # Studio at http://127.0. 1. Passes it to `buildStudioApp({ studioToken })`. The Hono server validates every `/api/*` request via `X-Arkor-Studio-Token` header (or `?studioToken=` query for `EventSource`, which can't set headers). Comparison uses `timingSafeEqual`. **One carve-out:** `GET /api/status` is token-exempt (still behind the loopback + Host guard) because it is secrets-free and never executes user code; this lets the port-collision probe confirm an occupant without transmitting the token to an unverified peer (see item 4). 2. Persists it to `~/.arkor/studio-token` (mode 0600) so the SPA dev workflow (`pnpm --filter @arkor/studio-app dev`) can read it via the `arkor-studio-token` Vite plugin in [packages/studio-app/vite.config.ts](packages/studio-app/vite.config.ts), which injects `` into `index.html` on each request. Persistence failure must NOT block server start (read-only `$HOME` on Docker, etc.); just warn. 3. Cleans up on `exit`/SIGINT/SIGTERM/SIGHUP via `unlinkSync`, after verifying the file still holds this instance's token (a second `arkor dev` on another port may have overwritten it; last writer wins and the previous owner must not delete it). Signal handlers exit with the conventional `128 + signal` code (SIGINT -> 130, SIGTERM -> 143, SIGHUP -> 129) so supervisors can distinguish signal termination from a clean exit. -4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). Agent mode does NOT sweep or reap other session files (an earlier pid-liveness reap was removed: `process.kill(pid, 0)` is namespace-local, so on a shared bind-mount a LIVE foreign-container session looks dead and would be deleted). A crashed session leaves an inert file; the docs recipe selects the newest (`ls -t`), which is never a stale crashed leftover (those are older than any live session), though with two concurrent `--agent` launches in one project only the stdout-printed path is unambiguous. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. The session file `url` (and the `/api/status` echo) is the agent-facing `http://127.0.0.1:` literal, NOT `localhost` (an agent's HTTP client may not do Happy-Eyeballs, so it must get the IPv4 the server actually bound); the human-facing stdout line and the `--open` browser target still use `localhost`. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath, must be absolute) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Adoption is UNAUTHENTICATED (it trusts the loopback peer's self-reported `/api/status`); this is deliberate because the probe sends no token, so an impostor can never obtain the CSRF token or reach `POST /api/train` (RCE) -- worst case is a nuisance denial/`--open` redirect, never code execution. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. +4. **Agent mode (ENG-967).** `arkor dev --agent` additionally writes the token as JSON (`{ token, url, port, pid }`) to a per-session file `/.arkor/agent/session--.json` (agent dir 0700, file 0600, atomic temp+rename; the parent `.arkor` is created with the default mode, NOT 0700, so a first-run agent launch does not tighten a directory `arkor build`/state.json share) and prints the path to stdout as the stable line `Arkor Studio agent session file: ` (the e2e/studio harness and coding agents grep it; don't change the prefix). Unlike the home token this write is FAIL-HARD (it is the agent's only token channel). The exact session path is computed BEFORE the write and captured, so the shutdown handlers unlink that one path (plus its deterministic `.tmp` sibling) without a readdir sweep: matching a bare `session--*` prefix would delete a co-located live session that happens to share this pid (real under two containers bind-mounting the same project, both running as pid 1). Agent mode does NOT sweep or reap other session files (an earlier pid-liveness reap was removed: `process.kill(pid, 0)` is namespace-local, so on a shared bind-mount a LIVE foreign-container session looks dead and would be deleted). A crashed session leaves an inert file; the docs recipe selects the newest (`ls -t`), which in the single-session case is always the live launch (leftovers from sessions that crashed BEFORE it started are older; a session launched after it that crashed can leave a newer file), and with overlapping `--agent` launches in one project only the stdout-printed path is unambiguous. Under `CLAUDECODE=1`, plain `arkor dev` exits 1 via the `ClaudeCodeStrictExit` sentinel asking for `--agent`; the flag itself needs no env var. The session file `url` (and the `/api/status` echo) is the agent-facing `http://127.0.0.1:` literal, NOT `localhost` (an agent's HTTP client may not do Happy-Eyeballs, so it must get the IPv4 the server actually bound); the human-facing stdout line and the `--open` browser target still use `localhost`. `GET /api/status` is the token-EXEMPT, secrets-free probe (`server: "arkor-studio"` discriminator, echoes `cwd`, never executes user code); normal-mode `arkor dev` on a busy port probes `http://127.0.0.1:/api/status` WITHOUT a token, and adopts the occupant only when it is an Arkor Studio AND its `cwd` (realpath, must be absolute) equals this launch's project root -> prints `Arkor Studio already running on ` and exits 0 instead of EADDRINUSE-failing. A different project on the port, or a non-Studio occupant, falls back to the hard port-in-use error. Adoption is UNAUTHENTICATED (it trusts the loopback peer's self-reported `/api/status`); this is deliberate because the probe sends no token, so an impostor can never obtain the CSRF token or reach `POST /api/train` (RCE) -- worst case is a nuisance denial/`--open` redirect, never code execution. Agent mode never adopts an existing instance (needs its own server + session file); it hard-errors and advises `--port`. Both e2e harnesses strip `CLAUDECODE` from spawned-child envs so the gate can't leak into tests from a Claude Code session. `/api/*` middleware also enforces a host-header allow-list (`127.0.0.1`/`localhost`) for DNS-rebinding defence. **CORS is intentionally NOT configured**: the SPA is same-origin so reflecting `*` would let "simple" cross-origin POSTs reach handlers. The token check rejects those; cross-origin tabs cannot read the SPA's ``. diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index 2f209667..3c0a2995 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -39,7 +39,7 @@ bun dev | Flag | Default | Description | | --- | --- | --- | -| `-p, --port ` | `4000` | Port to bind. The displayed URL uses `localhost`, but the listener binds `127.0.0.1` directly so it cannot end up IPv6-only on hosts where `/etc/hosts` lists `::1` before `127.0.0.1`. The value is validated up front: it must be an integer in `1` to `65535`. Anything else (`0`, a negative, `> 65535`, or non-numeric) is rejected with ``--port must be an integer between 1 and 65535 (got …).`` and the CLI exits `1` before binding. | +| `-p, --port ` | `4000` | Port to bind. The displayed URL uses `localhost`, but the listener binds `127.0.0.1` directly so it cannot end up IPv6-only on hosts where `/etc/hosts` lists `::1` before `127.0.0.1`. The value is validated up front: it must be a plain decimal integer in `1` to `65535`. Anything else (`0`, a negative, `> 65535`, non-numeric, or a non-canonical form such as `080`, `4e3`, `500.0`) is rejected with ``--port must be a plain decimal integer between 1 and 65535 (got …).`` and the CLI exits `1` before binding. | | `--open` | off | Open the Studio URL in a browser after the server is up. | | `--agent` | off | Agent mode: run the same Studio server headlessly for coding agents. Writes a per-session JSON token file under `.arkor/agent/` in the project and prints its path to stdout (see "Agent mode" below). Never opens a browser on its own; `--open` still works if you want one. | @@ -71,7 +71,7 @@ Everything in the launch sequence above happens unchanged, plus: 3. **Status probe.** `GET /api/status` is the one **token-exempt** `/api/*` route (still behind the loopback + Host guard): it is secrets-free and never executes project code, so a coding agent (and the port-collision probe) can read it without the token. It returns `status`, `server: "arkor-studio"`, `version`, `mode` (`"agent"` here), `url`, `pid`, `cwd`, and the list of available `endpoints`. It never includes credentials or the CSRF token, so it is the right first call to confirm the server and discover the API surface. Every OTHER `/api/*` route still requires the `X-Arkor-Studio-Token` header. -The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP` (exactly this launch's file, by its known unique path, so a co-located session that happens to share the pid is never touched). A crash (`SIGKILL`, power loss) can leave a `session-*.json` (or a `session-*.json.tmp` staging remnant) behind; both are inert (the token died with the server) and safe to delete. `arkor dev --agent` does not sweep other session files on startup: a liveness check would be unsafe when the project dir is a shared bind-mount across containers (pids are namespace-local). Selecting the newest file (below) means a stale leftover is never picked anyway. +The session file is unlinked on exit, `SIGINT`, `SIGTERM`, and `SIGHUP` (exactly this launch's file, by its known unique path, so a co-located session that happens to share the pid is never touched). A crash (`SIGKILL`, power loss) can leave a `session-*.json` (or a `session-*.json.tmp` staging remnant) behind; both are inert (the token died with the server) and safe to delete. `arkor dev --agent` does not sweep other session files on startup: a liveness check would be unsafe when the project dir is a shared bind-mount across containers (pids are namespace-local). Selecting the newest file (below) avoids leftovers from sessions that crashed before yours started; only a session launched after yours (and since crashed) can leave a newer file, which is one more reason to prefer the printed path. The Studio SPA is still served in agent mode: a human can open the printed URL in a browser while the agent works, and every guard from "Loopback and CSRF model" below applies unchanged. diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 28f72f5c..3ae1d3d7 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -39,7 +39,7 @@ bun dev | フラグ | デフォルト | 説明 | | --- | --- | --- | -| `-p, --port ` | `4000` | バインドするポート。表示 URL は `localhost` ですが、リスナーは `127.0.0.1` を直接バインドします。これにより `/etc/hosts` で `::1` が `127.0.0.1` より先に並ぶホストでも IPv6 専用に終わりません。値は事前に検証され、`1` から `65535` の整数でなければなりません。それ以外(`0`、負値、`65535` 超、非数)は ``--port must be an integer between 1 and 65535 (got …).`` を出力し、バインド前に exit code `1` で終了します。 | +| `-p, --port ` | `4000` | バインドするポート。表示 URL は `localhost` ですが、リスナーは `127.0.0.1` を直接バインドします。これにより `/etc/hosts` で `::1` が `127.0.0.1` より先に並ぶホストでも IPv6 専用に終わりません。値は事前に検証され、`1` から `65535` のプレーンな 10 進整数でなければなりません。それ以外(`0`、負値、`65535` 超、非数、および `080`、`4e3`、`500.0` のような非正規形)は ``--port must be a plain decimal integer between 1 and 65535 (got …).`` を出力し、バインド前に exit code `1` で終了します。 | | `--open` | off | サーバー起動後にブラウザーで Studio URL を開く。 | | `--agent` | off | エージェントモード。同じ Studio サーバーをコーディングエージェント向けにヘッドレスで起動する。プロジェクト内の `.arkor/agent/` にセッションごとの JSON トークンファイルを書き、そのパスを stdout に出力する(下の「エージェントモード」参照)。単体でブラウザーを開くことはなく、開きたい場合は従来どおり `--open` を併用する。 | @@ -71,7 +71,7 @@ bun dev 3. **ステータスプローブ。** `GET /api/status` は唯一の**トークン不要**な `/api/*` ルートです(ループバック + Host ガードは適用)。機密ゼロでプロジェクトのコードを実行しないため、コーディングエージェント(およびポート競合プローブ)はトークンなしで読めます。`status`、`server: "arkor-studio"`、`version`、`mode`(ここでは `"agent"`)、`url`、`pid`、`cwd`、利用可能な `endpoints` の一覧を返します。認証情報や CSRF トークンを含むことはないため、サーバーの確認と API サーフェスの発見に最初に呼ぶべきエンドポイントです。他の `/api/*` ルートは引き続き `X-Arkor-Studio-Token` ヘッダーが必須です。 -セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます(この起動の固有パスのファイルだけを消すので、pid をたまたま共有する別セッションには触れません)。クラッシュ(`SIGKILL`、電源断)では `session-*.json`(あるいは `session-*.json.tmp` のステージング残骸)が残ることがありますが、いずれもトークンはサーバーとともに死んでいるため無害で、削除して安全です。`arkor dev --agent` は起動時に他のセッションファイルを掃きません。プロジェクトディレクトリーが複数コンテナ間の共有 bind-mount の場合、pid は名前空間ローカルなので liveness チェックが安全でないためです。下記のように最新ファイルを選べば、古い残骸を掴むことはありません。 +セッションファイルは通常終了、`SIGINT`、`SIGTERM`、`SIGHUP` で unlink されます(この起動の固有パスのファイルだけを消すので、pid をたまたま共有する別セッションには触れません)。クラッシュ(`SIGKILL`、電源断)では `session-*.json`(あるいは `session-*.json.tmp` のステージング残骸)が残ることがありますが、いずれもトークンはサーバーとともに死んでいるため無害で、削除して安全です。`arkor dev --agent` は起動時に他のセッションファイルを掃きません。プロジェクトディレクトリーが複数コンテナ間の共有 bind-mount の場合、pid は名前空間ローカルなので liveness チェックが安全でないためです。下記のように最新ファイルを選べば、自分の起動より前にクラッシュしたセッションの残骸を掴むことはありません。自分より後に起動してクラッシュしたセッションだけがより新しいファイルを残し得るので、その点でも出力されたパスを使うのが確実です。 エージェントモードでも Studio SPA は引き続き配信されます。エージェントが作業している間、人間は出力された URL をブラウザーで開けますし、下の「ループバックと CSRF モデル」のガードもすべてそのまま適用されます。 @@ -96,7 +96,7 @@ Re-run with the --agent flag: Studio サーバーはすべての `/api/*` リクエストに 3 つのチェックを課します。 1. `Host` ヘッダーは `127.0.0.1` か `localhost`(DNS リバインディング対策)。 -2. CSRF トークンは `X-Arkor-Studio-Token` ヘッダーか `?studioToken=...`(カスタムヘッダーを送れない `EventSource` 用)として必須。ただし 1 つだけ例外があり、機密ゼロの `GET /api/status` プローブはトークン不要です(上記参照)。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。 +2. CSRF トークンは `X-Arkor-Studio-Token` ヘッダーとして必須。ただし 1 つだけ例外があり、機密ゼロの `GET /api/status` プローブはトークン不要です(上記参照)。ジョブイベントストリームだけは `?studioToken=...` も受け付けます(`EventSource` はカスタムヘッダーを送れないため)。変更系ルートはクエリー文字列のトークンを受け付けません。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。 3. CORS は意図的に未設定。SPA は同一オリジンなので CORS は意味を持たず、`*` を反射すると preflight をスキップする「simple」なクロスオリジン POST(`text/plain`、`urlencoded`)を素通りさせてしまう。トークンが無ければミドルウェアが拒否します。 これにより `arkor dev` は共有の開発環境でも安全です。別タブは meta を読めず、過去の起動の古いタブはトークンが一致せず、別オリジンの攻撃者ページはリクエストを偽造できません。 diff --git a/e2e/cli/src/arkor-dev.test.ts b/e2e/cli/src/arkor-dev.test.ts index e5872f60..880089a1 100644 --- a/e2e/cli/src/arkor-dev.test.ts +++ b/e2e/cli/src/arkor-dev.test.ts @@ -56,7 +56,7 @@ describe("arkor dev × CLAUDECODE strict gate (E2E)", () => { }); expect(result.code).toBe(1); expect(result.stderr).toContain( - "--port must be an integer between 1 and 65535", + "--port must be a plain decimal integer between 1 and 65535", ); // The whole point of ExpectedCliError: bin.ts prints `err.message` alone, // so no `dist/bin.mjs` code-frame / V8 stack lines leak out. diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 17f5133f..6110f17b 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -1407,7 +1407,20 @@ describe("runDev", () => { // The deferral only helps the adopt path: if we bind and serve (serving // needs credentials), the deferred bootstrap error is surfaced. rmSync(join(fakeHome, ".arkor", "credentials.json"), { force: true }); - // Default serve mock fires the listening callback (successful bind). + // Bind succeeds (listening fires), but expose a close spy: the reject + // path must also CLOSE the just-bound listener, or a failed launch + // would leak a live socket until process exit. The default serve stub + // has no `close`, and dev.ts wraps the call in try/catch, so without + // this spy the assertion below is the only thing keeping the + // `server.close()` call alive under mutation. + const closeSpy = vi.fn(); + vi.mocked(serve).mockImplementationOnce((( + _opts: unknown, + onListen?: () => void, + ) => { + queueMicrotask(() => onListen?.()); + return { on: vi.fn(), close: closeSpy }; + }) as unknown as typeof serve); globalThis.fetch = vi.fn(async () => { throw new TypeError("fetch failed"); }) as unknown as typeof fetch; @@ -1421,6 +1434,7 @@ describe("runDev", () => { } finally { stdoutSpy.mockRestore(); } + expect(closeSpy).toHaveBeenCalled(); }); }); }); diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index c9b97e56..ed55b63e 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -684,7 +684,7 @@ export async function runDev(options: DevOptions = {}): Promise { // ExpectedCliError (not a bare Error) so bin.ts prints this one // actionable line and exits 1 WITHOUT a minified `dist/bin.mjs` // code-frame; a busy port is the most common dev failure and the - // stack would be pure noise. Mirrors the agent-write hard-fail below. + // stack would be pure noise. Mirrors the agent-write hard-fail above. const portInUse = new ExpectedCliError( `Port ${port} is already in use. Another \`arkor dev\` may be running; pass --port to choose a different one.`, ); diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index 1861269b..8fceaac8 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -43,7 +43,7 @@ function parseDevPort(raw: string): number { const n = Number(raw); if (!/^[1-9]\d*$/.test(raw) || !Number.isInteger(n) || n < 1 || n > 65_535) { throw new ExpectedCliError( - `--port must be an integer between 1 and 65535 (got ${JSON.stringify( + `--port must be a plain decimal integer between 1 and 65535 (got ${JSON.stringify( raw, )}).`, ); diff --git a/packages/arkor/src/studio/server.ts b/packages/arkor/src/studio/server.ts index 8294d91f..c4ea917f 100644 --- a/packages/arkor/src/studio/server.ts +++ b/packages/arkor/src/studio/server.ts @@ -81,8 +81,12 @@ export interface StudioServerOptions { */ mode?: "agent" | "studio"; /** - * Public URL the CLI printed (e.g. `http://localhost:4000`). Echoed by - * `GET /api/status`; `null` there when omitted (e.g. app-only tests). + * Agent-facing URL of the bound listener, as the `http://127.0.0.1:` + * LITERAL, not the `localhost` form the CLI prints for humans: `GET + * /api/status` echoes this value for programmatic clients that may not do + * Happy-Eyeballs, so it must be the IPv4 address the server actually bound + * (see the url/agentUrl split in cli/commands/dev.ts). `null` in the echo + * when omitted (e.g. app-only tests). */ url?: string; } From 92400888fabb1ccb61a564a74a20a18170505bf3 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:49:49 -0700 Subject: [PATCH 13/19] fix(ENG-967): assert adopt-path server.close(), correct redirect comment (self-review r10) Round-10 audit (re-run on the latest model) found two real items the earlier rounds missed: 1. The adopt path's `server.close()` was NOT load-bearing: mockServeAddrInUse accepts a closeSpy but no call site passed one, and dev.ts wraps the call in try/catch, so deleting it left all 470 tests green. Inject the spy in the connect test and assert it. Mutation-verified: removing the close call now fails the test. 2. The `redirect: "manual"` rationale comment claimed a 3xx yields an "opaque" response. That is browser-fetch semantics; verified empirically on Node 24 that undici returns an ordinary response (type "basic", real 302 status, readable body) because opaqueredirect filtering only applies to navigate-mode requests. The security claim is unchanged; the comment now states what actually happens and why `res.ok` rejects it before any body read. Also refreshed a stale cross-reference to the probe-timeout test renamed in fc84107. --- packages/arkor/src/cli/commands/dev.test.ts | 15 +++++++++++---- packages/arkor/src/cli/commands/dev.ts | 9 ++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 6110f17b..72859dde 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -1144,8 +1144,12 @@ describe("runDev", () => { return fetchSpy; } - it("connects to a running Studio serving THIS project: resolves adopted, prints the URL, registers no cleanup, sends no token", async () => { - mockServeAddrInUse(); + it("connects to a running Studio serving THIS project: resolves adopted, prints the URL, closes its own listener, registers no cleanup, sends no token", async () => { + // dev.ts wraps the adopt-path `server.close()` in try/catch, so without + // this spy nothing keeps that call alive under mutation (deleting it left + // the whole suite green). Inject it and assert below. + const closeSpy = vi.fn(); + mockServeAddrInUse(closeSpy); // Occupant reports it is an Arkor Studio serving the same project root. const fetchSpy = mockProbe({ server: "arkor-studio", cwd: projectDir }); const exitBefore = process.listeners("exit").length; @@ -1174,11 +1178,14 @@ describe("runDev", () => { // The probe is time-bounded by an AbortSignal (Node fetch has no default // timeout): without it, an occupant that accepts TCP but never responds // would hang `arkor dev` forever. Assert the signal is passed so removing - // the timeout guard fails here (the timeout DURATION is exercised - // behaviorally by the "times out on a silent occupant" test below). + // the timeout guard fails here (the exact DURATION is pinned by the + // "time-bounds the probe with AbortSignal.timeout(1500)" test below). expect(reqInit?.signal).toBeInstanceOf(AbortSignal); // Never follow a redirect from an untrusted occupant (no blind SSRF). expect(reqInit?.redirect).toBe("manual"); + // The adopted instance owns the port; this launch must release the + // listener object it created instead of leaking it. + expect(closeSpy).toHaveBeenCalled(); // No shutdown handler was registered by this doomed launch. expect(process.listeners("exit").length).toBe(exitBefore); }); diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index ed55b63e..085ee276 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -350,9 +350,12 @@ async function probeExistingStudio( { signal: AbortSignal.timeout(1500), // A real /api/status is terminal and never redirects. `redirect: - // "manual"` stops an untrusted port occupant from bouncing this CLI-side - // GET to an arbitrary URL (a 3xx then yields an opaque, non-ok response - // that fails the check below), removing that blind-request surface. + // "manual"` stops an untrusted port occupant from bouncing this + // CLI-side GET to an arbitrary URL, removing that blind-request + // surface. Under Node/undici the 3xx then comes back as an ordinary + // response carrying the real status (NOT the browser's opaqueredirect + // filtering, which only applies to navigate-mode requests), so `res.ok` + // is false and the check below rejects it before any body is read. redirect: "manual", }, ); From cc144c44211dd3605792bb45f2c59955aba52e3b Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:52:42 -0700 Subject: [PATCH 14/19] docs(ENG-967): document the deferred credential bootstrap, add /api/status to the npm README (self-review r11) Round-11 audit found two real documentation defects (2/2 verifiers CONFIRMED each; 18 of 24 votes refuted the other candidates): 1. The r6 change that DEFERRED the credential-bootstrap failure left docs/cli/dev.mdx factually wrong in two places (and identically in the JA mirror): launch-sequence step 1 and the `TypeError: fetch failed` errors row both still promised the error is rethrown and `arkor dev` "exits fast", and the deferral was documented nowhere. On a first-run OFFLINE launch with an Arkor Studio already serving this project on the port, the shipped code instead exits 0 with `Arkor Studio already running on `, pinned by the dev.test.ts offline-adopt test. Both spots now state that the error is re-raised only if this launch actually serves, and why the deferral exists. 2. packages/arkor/README.md (the published npm page, and the file scaffolded AGENTS.md points coding agents at first) itemizes the Studio security model with an exception list detailed enough to read as exhaustive, but was never updated for the token-exempt `GET /api/status` this changeset added to every other doc surface. Added the carve-out with its rationale. Verified the new doc claims against the code path and both bootstrap tests. The root README.md / README.ja.md pair carries only a one-line summary with no exception list, so it is not falsified and needs no edit. --- docs/cli/dev.mdx | 4 ++-- docs/ja/cli/dev.mdx | 4 ++-- packages/arkor/README.md | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index 3c0a2995..1a3cbac7 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -47,7 +47,7 @@ bun dev ### Launch sequence -1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; on anon-only deployments it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint because `arkor login --oauth` would fail there. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``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.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/cli/config` itself is unreachable, the same transport error is rethrown and `arkor dev` exits fast (restore connectivity and re-run). If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment), it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in. Run `arkor login --oauth` and try again.``). +1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; on anon-only deployments it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint because `arkor login --oauth` would fail there. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``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.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/cli/config` itself is unreachable, the deployment mode cannot be determined, so the same transport error is kept and re-raised **only if this launch goes on to serve** (serving needs credentials): `arkor dev` then exits 1 (restore connectivity and re-run). The failure is deliberately deferred rather than immediate so that the port-collision path below still works offline: connecting to an Arkor Studio that already serves this project needs no credentials of its own, so that case still prints `Arkor Studio already running on ` and exits 0. If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment), it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in. Run `arkor login --oauth` and try again.``). 2. **CSRF token.** A 32-byte token (base64url, ~43 chars) is generated for this launch. It is injected into `index.html` as `` so the same-origin SPA can read it. Cross-origin tabs cannot read the meta and are rejected by the `/api/*` middleware. 3. **Listener.** Hono on `127.0.0.1:`. The `Host` header guard accepts both `127.0.0.1` and `localhost`, so the URL the CLI prints (`http://localhost:`) works without surprising DNS-rebinding fallout. 4. **Token persistence (best-effort, after a successful bind).** Once the listener is up, the token is written to `~/.arkor/studio-token` (mode `0600`) so the studio-app Vite dev server (`pnpm --filter @arkor/studio-app dev`) can pick it up. Binding first means a doomed second launch on a busy port never touches the file (see "Port collision" below). If writing fails (read-only `$HOME`, locked-down umask), `arkor dev` continues; only the standalone Vite dev workflow is affected. @@ -122,7 +122,7 @@ The token file (`~/.arkor/studio-token`) is written only **after** the port bind | `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.` (exit `1`) | The Claude Code strict gate: `CLAUDECODE=1` is set and `--agent` was not passed. No server was started and no token or session file was written. | Re-run as `arkor dev --agent` and use the printed session file. | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.` (exit `1`) | Agent mode could not create `.arkor/agent/` or the session file (read-only project dir, permission problem). Unlike the home token this is fatal: the file is the agent's only token channel. | Make the project directory writable (check `.arkor/` permissions) and re-run. | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.` | `/v1/auth/cli/config` already succeeded, but the follow-up `/v1/auth/anonymous` hit a transport error. The Studio server starts and will retry. | Bring connectivity back; the SPA recovers on its next `/api/credentials` poll without restarting `arkor dev`. | -| `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`. | +| `TypeError: fetch failed` (or an equivalent transport error, exit `1`) | `/v1/auth/cli/config` itself was unreachable, so the deployment mode could not be determined. The error is deferred and re-raised only if this launch actually serves; if the port is already held by an Arkor Studio for this project, the connect path wins instead and exits `0`. | 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 OAuth 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. | diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 3ae1d3d7..9501d410 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -47,7 +47,7 @@ bun dev ### 起動シーケンス -1. **認証情報の初期化。** `~/.arkor/credentials.json` が無いとき、CLI は **常に匿名セッションの初期化を試みます**。`/v1/auth/cli/config` を呼び、続いて `/v1/auth/anonymous` から匿名トークンを要求します。初期化前のメッセージはデプロイが OAuth をアドバタイズしているかで分岐します。OAuth が設定されている場合は ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションを初期化します。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください)を出して、好きなタイミングで本物のアカウントへアップグレードできることを案内します。匿名専用デプロイでは代わりに `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、`arkor login --oauth` がそのデプロイでは失敗するため OAuth ヒントは省略されます。いずれの場合も OAuth フローを自動で起動することはありません。トークンが届くと `arkor dev` は ``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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`)を出します。デプロイが OAuth をアドバタイズしている場合に限り、成功メッセージと並んで 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` でサインインしてください)が発行され、アップグレード経路が発行時点で見えます。匿名専用デプロイでは `arkor login --oauth` を案内すると失敗するコマンドへユーザーを誘導してしまうので、warn は意図的に抑制されます。一過性のトランスポート障害(`fetch failed`)の扱いはタイミングで分かれます。`/v1/auth/cli/config` が成功してデプロイモードが特定済みのあと、`/v1/auth/anonymous` で同様の障害が出た場合のみ警告して続行し、Studio サーバーは初回の `/api/credentials` ヒットで再試行します。`/v1/auth/cli/config` 自体に到達できなかった場合は同じトランスポートエラーがそのまま再スローされて `arkor dev` は fail-fast で終了するので、接続を回復してから再実行してください。`/v1/auth/anonymous` が 4xx で拒否される場合(例えば、このデプロイで匿名サインインが無効になっているなど)は HTTP ステータスを含むエラーで `arkor login --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` を実行して再試行してください)。 +1. **認証情報の初期化。** `~/.arkor/credentials.json` が無いとき、CLI は **常に匿名セッションの初期化を試みます**。`/v1/auth/cli/config` を呼び、続いて `/v1/auth/anonymous` から匿名トークンを要求します。初期化前のメッセージはデプロイが OAuth をアドバタイズしているかで分岐します。OAuth が設定されている場合は ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションを初期化します。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください)を出して、好きなタイミングで本物のアカウントへアップグレードできることを案内します。匿名専用デプロイでは代わりに `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、`arkor login --oauth` がそのデプロイでは失敗するため OAuth ヒントは省略されます。いずれの場合も OAuth フローを自動で起動することはありません。トークンが届くと `arkor dev` は ``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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`)を出します。デプロイが OAuth をアドバタイズしている場合に限り、成功メッセージと並んで 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` でサインインしてください)が発行され、アップグレード経路が発行時点で見えます。匿名専用デプロイでは `arkor login --oauth` を案内すると失敗するコマンドへユーザーを誘導してしまうので、warn は意図的に抑制されます。一過性のトランスポート障害(`fetch failed`)の扱いはタイミングで分かれます。`/v1/auth/cli/config` が成功してデプロイモードが特定済みのあと、`/v1/auth/anonymous` で同様の障害が出た場合のみ警告して続行し、Studio サーバーは初回の `/api/credentials` ヒットで再試行します。`/v1/auth/cli/config` 自体に到達できなかった場合はデプロイモードが特定できないため、そのトランスポートエラーは保持され、**この起動が実際にサーブへ進んだときにのみ**再スローされます(サーブには認証情報が必要)。その場合 `arkor dev` は exit 1 で終了するので、接続を回復してから再実行してください。即座に失敗させず意図的に遅延させているのは、下記のポート衝突の経路をオフラインでも機能させるためです。同じプロジェクトをサーブしている既存の Arkor Studio への接続はそれ自体に認証情報を必要としないので、そのケースでは引き続き `Arkor Studio already running on ` を出して exit 0 で終了します。`/v1/auth/anonymous` が 4xx で拒否される場合(例えば、このデプロイで匿名サインインが無効になっているなど)は HTTP ステータスを含むエラーで `arkor login --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` を実行して再試行してください)。 2. **CSRF トークン。** この起動用に 32 バイトのトークン(base64url、約 43 文字)を生成。同一オリジンの SPA が読めるよう `` として `index.html` にインジェクトされます。クロスオリジンタブはこの meta を読めず、`/api/*` のミドルウェアに拒否されます。 3. **リスナー。** `127.0.0.1:` 上の Hono。`Host` ヘッダーのガードは `127.0.0.1` と `localhost` の両方を受け付けるので、CLI が表示する URL(`http://localhost:`)は DNS リバインディング系の挙動なしで動きます。 4. **トークンの永続化(ベストエフォート、バインド成功後)。** リスナーが立ち上がってから、トークンを `~/.arkor/studio-token`(モード `0600`)に書きます。studio-app の Vite dev サーバー(`pnpm --filter @arkor/studio-app dev`)が拾えるようにするためです。先にバインドするので、使用中ポートへの 2 つ目の起動がこのファイルに触れることはありません(下の「ポート競合」参照)。書き込みが失敗(`$HOME` が読み取り専用、umask が厳しいなど)しても `arkor dev` は続行します。影響を受けるのはスタンドアローン Vite dev ワークフローだけです。 @@ -122,7 +122,7 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.`(exit `1`) | Claude Code の厳格ゲート。`CLAUDECODE=1` が設定されていて `--agent` が渡されていない。サーバーは起動されず、トークンファイルもセッションファイルも書き込まれていない。 | `arkor dev --agent` で再実行し、出力されたセッションファイルを使う。 | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.`(exit `1`) | エージェントモードが `.arkor/agent/` またはセッションファイルを作成できなかった(読み取り専用のプロジェクトディレクトリー、権限の問題)。home のトークンと異なりこれは致命的で、ファイルはエージェント唯一のトークン経路。 | プロジェクトディレクトリーを書き込み可能にして(`.arkor/` の権限を確認)再実行。 | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.`(`` に到達できませんでした(fetch 失敗)。Studio は起動を続け、初回の `/api/credentials` 受信時に再試行します) | `/v1/auth/cli/config` は成功したが、続く `/v1/auth/anonymous` がトランスポート障害で失敗。Studio サーバーは起動して再試行する。 | 接続を回復させれば `arkor dev` を再起動せずに次の `/api/credentials` ポーリングで SPA が回復。 | -| `TypeError: fetch failed`(または同等のトランスポートエラーで `arkor dev` がそのまま終了する場合) | `/v1/auth/cli/config` 自体に届かなかったため、デプロイモードが特定できず fail-fast。 | 接続を回復してから `arkor dev` を再実行。 | +| `TypeError: fetch failed`(または同等のトランスポートエラー、exit `1`) | `/v1/auth/cli/config` 自体に届かなかったため、デプロイモードが特定できない。このエラーは遅延され、この起動が実際にサーブしたときにのみ再スローされる。ポートを同一プロジェクトの Arkor Studio が既に握っている場合は接続経路が優先され、exit `0` になる。 | 接続を回復してから `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` で OAuth がアドバタイズされていない)。`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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`) | 匿名初期化の完了後の情報行。クラウド側の識別子と、それを保持しているファイルの場所を明示する。 | 何もしなくてよい。別の環境から同じ匿名 ID を使いたいなら認証情報ファイルをバックアップ。 | diff --git a/packages/arkor/README.md b/packages/arkor/README.md index 9b2d1083..df962f7c 100644 --- a/packages/arkor/README.md +++ b/packages/arkor/README.md @@ -115,6 +115,12 @@ Studio is loopback-only and per-launch CSRF-token-gated: headers. Mutation routes never accept the token from the query string. The token is generated on every `arkor dev` launch and injected into the served `index.html` as a `` tag the same-origin SPA reads at startup. +- `GET /api/status` is the one token-exempt route (still behind the `Host` and + loopback guards). It is secrets-free and never runs project code: it reports + liveness, version, mode, url, pid, cwd and the endpoint list, never the token + or credentials. The exemption exists so a coding agent, and `arkor dev`'s own + port-collision probe, can confirm an occupant without transmitting the token + to an unverified peer. ## Public exports From 1739c67864842b877a6f1e357b49a8dec69a2086 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:33:26 -0700 Subject: [PATCH 15/19] docs(e2e): correct the waitForPort bind-ordering comment (self-review r12) PRE-EXISTING inaccuracy, not introduced by ENG-967 (the two comments are byte-identical on main and this changeset does not touch those lines). Fixed here because the file is already in this PR's diff and the ordering the comment misstates is the same reasoning ENG-967's EADDRINUSE/adopt path relies on. The comment claimed `arkor dev` prints the ready line BEFORE `http.Server.listen()` finishes binding, leaving an ECONNREFUSED window that waitForPort exists to close. That is inverted: @hono/node-server's `serve(opts, cb)` is `server.listen(port, hostname, cb)`, so cb IS the listening handler and dev.ts writes the ready line from inside it, post-bind (agent mode waits on the even-later session-file line). No such window exists. Keep the poll (it is nearly free and guards future ordering changes) but state what it actually is, and warn against inferring a pre-bind callback: dev.ts sets `bound = true` inside that callback and its EADDRINUSE vs post-bind failure split depends on that being a real post-bind signal. e2e/studio: 16 passed. --- e2e/studio/src/harness/studioServer.ts | 30 +++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/e2e/studio/src/harness/studioServer.ts b/e2e/studio/src/harness/studioServer.ts index a1536482..81de2916 100644 --- a/e2e/studio/src/harness/studioServer.ts +++ b/e2e/studio/src/harness/studioServer.ts @@ -89,13 +89,23 @@ class TailBuffer { } /** - * `arkor dev`'s [@hono/node-server] writes the "Arkor Studio running on …" - * line *before* the underlying `http.Server.listen()` settles, so a fetch - * fired in the small window between stdout flush and bound socket gets - * `ECONNREFUSED`. The window is sub-millisecond on a warm Node process but - * grows on a cold one. Poll a bare TCP connect until the kernel accepts: - * cheaper than retrying every test's fetch and keeps the contract local - * to the harness so spec authors can fetch immediately after `studio.url`. + * Defence in depth before the first fetch of a spawned Studio. + * + * NOTE: this is NOT load-bearing for the ordering an earlier version of this + * comment claimed. `@hono/node-server`'s `serve(opts, cb)` is + * `server.listen(port, hostname, cb)`, so `cb` IS the `listening` handler and + * `arkor dev` writes "Arkor Studio running on ..." from inside it, i.e. AFTER + * the socket is bound and accepting (in agent mode the harness waits on the + * session-file line, printed later still). There is no stdout-flush-before-bind + * window that could yield `ECONNREFUSED`. + * + * The poll is kept anyway because it is nearly free (it succeeds on the first + * iteration) and it keeps the harness robust to a future change in that + * ordering, to a proxy/port-forward in front of the child, and to a `url` whose + * port was reported before the OS finished plumbing it. Do not infer from this + * function that `serve()`'s callback fires pre-bind: `dev.ts` sets its + * `bound = true` discriminator inside that same callback, and the EADDRINUSE + * vs post-bind failure split depends on that being a genuine post-bind signal. */ async function waitForPort(port: number): Promise { const deadline = Date.now() + PORT_POLL_TIMEOUT_MS; @@ -503,9 +513,9 @@ export async function startStudio( const { url, kill, sessionFile } = await spawnStudio(opts); let token: string; try { - // `arkor dev` writes the ready line before `http.Server.listen()` - // finishes binding; wait for the port to actually accept TCP - // connections before any fetch. See `waitForPort` comment. + // Belt-and-braces TCP poll before the first fetch. The ready line is + // already printed post-bind, so this normally passes on its first + // iteration. See the `waitForPort` docblock for why it is kept. const port = Number(new URL(url).port); await waitForPort(port); token = await readMetaToken(url); From 8fa99a7fcb456c5b16c4850f63650d2a8f8090c8 Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:36:42 -0700 Subject: [PATCH 16/19] fix(ENG-967): full-scope audit round 13 (pre-existing issues now in scope) The audit scope was widened per the repo owner: this branch is a full review of the `arkor dev` / Studio / agent-mode surface, so "pre-existing" is no longer a reason to dismiss a finding. 42 candidates, 33 survived adversarial 2-vote verification, ~25 distinct after dedup. All fixed: CODE / BEHAVIOUR - dev.ts: close a bypass of the adopt-path same-project guard. `/proc/self/cwd` is absolute (clearing the isAbsolute check) but realpath-resolves against the PROBING process, so a hostile occupant could report it and be adopted without knowing where the project lives. Reject process-relative magic namespaces (/proc, /dev/fd) before resolving. Mutation-verified. - main.ts: `arkor dev --help` printed the port default twice ('(default: 4000)' in the description plus Commander's own). Drop the hardcoded copy. TEST GAPS (all mutation-verified load-bearing) - Atomic temp+rename of the agent session file was pinned by nothing: the "no .tmp strays" test passes with a plain writeFile. Record rename calls via a hoisted pass-through mock and assert the staging path is a .tmp sibling in the same directory. - The GET-only narrowing of the /api/status token exemption had no method-scope test; dropping `method === "GET"` left the suite green. Assert POST/PUT/PATCH/ DELETE still 403. - The EADDRINUSE rejection pinned only the message, not the ExpectedCliError class that keeps bin.ts from dumping a minified stack. WRONG COMMENTS / DOCS (verified against code) - `arkor train` does not exist: the CLI registers `start`, and /api/train spawns `arkor start`. Fixed in AGENTS.md and in two USER-FACING scaffolder warnings plus comments (scaffold.ts, create-arkor/bin.ts, init.ts, server.ts). - dev.ts shutdown docblock: the /api/train kill hook is one shared 'exit' listener, not one registered per child. - dev.test.ts: the home studio-token is not what makes the port-collision probe work (the probe sends no token at all). - claude-code.ts: the isClaudeCode docblock described the gate as scaffolder-only with a --yes escape; `arkor dev` is a third consumer with neither. - e2e studioServer.ts: readMetaToken pointed at server.ts:85-90, which now holds unrelated JSDoc. Reference `injectStudioToken` by name instead. DOCS ACCURACY (EN + JA) - concepts/studio.mdx repeated the pre-deferral "exits fast" claim. - cli/dev.mdx: the deferral covers EVERY bootstrap failure (4xx wrap, raw rejection, local fs errors), not just the config-unreachable transport error; and the 4xx sign-in hint only fires when the deployment advertises OAuth. - cli/dev.mdx: `--agent` is still a foreground never-exiting server an agent must background, and "safe on a shared dev machine" overstated the CSRF token: it closes the browser surface, not another local user who can GET / and read the token out of index.html. - quickstart: the CLAUDECODE=1 section covered only the scaffolders while step 3 tells the reader to run `pnpm dev`, which now exits 1 without --agent. - studio/overview: search and status filtering ARE shipped (JobsList.tsx); only pagination is missing, as the JA side already said. - guides/cli/dev: the Playground only offers completed jobs' final adapters (Playground.tsx filters status === 'completed'), not mid-run checkpoints. - README.md/README.ja.md disagreed on the Host-guard scope; the guard is app.use('*'), so it covers static HTML too. - ja/studio/overview omitted that mutation routes reject query-string tokens. - Broken anchors: six #srcarkor links (Mintlify preserves '/', so the id is src/arkor/) and two JA links dropping the full-width parens. - npm README: documented --agent and the CLAUDECODE gate, replaced the false 'hot reload' claim (there is no watcher), and noted error_message is sent unscrubbed and can contain local paths. - AGENTS.md: the generated-files list omitted packages/*/README.ja.md. Verified: typecheck 10/10, lint 7/7 clean, 486 arkor + 226 cli-internal + 205 studio-app + 47 create-arkor unit tests, 16 e2e-studio, 109 e2e-cli, oxfmt and em-dash sweeps clean. --- AGENTS.md | 4 +- README.ja.md | 2 +- README.md | 2 +- docs/cli/build-and-start.mdx | 4 +- docs/cli/dev.mdx | 10 ++- docs/concepts/studio.mdx | 2 +- docs/guides/cli/dev.mdx | 2 +- docs/ja/cli/build-and-start.mdx | 6 +- docs/ja/cli/dev.mdx | 10 ++- docs/ja/concepts/studio.mdx | 2 +- docs/ja/quickstart.mdx | 4 + docs/ja/sdk/create-arkor.mdx | 2 +- docs/ja/sdk/trainer-control.mdx | 2 +- docs/ja/studio/overview.mdx | 2 +- docs/quickstart.mdx | 4 + docs/sdk/create-arkor.mdx | 2 +- docs/studio/overview.mdx | 2 +- e2e/studio/src/harness/studioServer.ts | 5 +- packages/arkor/README.md | 17 ++++- packages/arkor/src/cli/commands/dev.test.ts | 84 ++++++++++++++++++++- packages/arkor/src/cli/commands/dev.ts | 44 ++++++++++- packages/arkor/src/cli/commands/init.ts | 2 +- packages/arkor/src/cli/main.ts | 5 +- packages/arkor/src/studio/server.test.ts | 18 +++++ packages/arkor/src/studio/server.ts | 2 +- packages/cli-internal/src/claude-code.ts | 8 ++ packages/cli-internal/src/scaffold.ts | 10 +-- packages/create-arkor/src/bin.ts | 2 +- 28 files changed, 215 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d84732d..8c60f3c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ cd my-arkor-app && pnpm dev # Studio at http://127.0. `/api/*` middleware also enforces a host-header allow-list (`127.0.0.1`/`localhost`) for DNS-rebinding defence. **CORS is intentionally NOT configured**: the SPA is same-origin so reflecting `*` would let "simple" cross-origin POSTs reach handlers. The token check rejects those; cross-origin tabs cannot read the SPA's ``. -The whole point: prevents another browser tab on the same machine from POSTing `/api/train` (which spawns `arkor train` and dynamically imports user TS: RCE-grade). +The whole point: prevents another browser tab on the same machine from POSTing `/api/train` (which spawns `arkor start` and dynamically imports user TS: RCE-grade). Note the route is `/api/train` but the child it spawns is `arkor start`; there is no `arkor train` command. When touching the Studio server or SPA fetch layer, preserve: token via header for `fetch`, query param for `EventSource`, host-header guard, no CORS, timing-safe compare. The Vite plugin is dev-only (`apply: "serve"`): running it during `vite build` would bake a stale per-launch token into the production `index.html` and shadow the runtime tag, causing every `/api/*` call to 403. @@ -116,7 +116,7 @@ Don't split these into "docs in a follow-up PR" or "tests later"; land them in t Cross-page links in `docs/` must therefore mirror the actual rendered id, which preserves `/`, `=`, and full-width parens but strips ASCII parens and backticks. Always confirm before adding or editing an anchor link: `curl -s "/" | grep -oE 'id="[^"]*[^"]*"'`. Do not act on review comments (human or bot) claiming "Mintlify strips punctuation in headings" as a universal rule: Copilot in particular gets this wrong repeatedly by extrapolating from `github-slugger`, and has even cited the pre-existing broken `#ci--non-interactive-shells` link as supposed evidence. PR #141 fixed that broken link to `#ci-/-non-interactive-shells`, and the surviving anchors in `docs/cli/overview.mdx` and the strict-mode sections of `docs/quickstart.mdx` / `docs/cli/init.mdx` (plus their JA mirrors) are the canonical examples to copy from. - **Don't call a HuggingFace model name "non-existent"** based on training-data alone. Templates reference real models (e.g. `unsloth/gemma-4-E4B-it`) that may post-date Claude's knowledge cutoff. Verify (e.g. `WebFetch`) before flagging in issues or PR comments. If unverifiable, hedge ("could not confirm") rather than asserting absence. -- **Generated files** copied into package dirs are gitignored: `packages/*/CONTRIBUTING.md` (from root), `packages/arkor/docs/` (from root `docs/`). Edit the source under repo root, not the copies. +- **Generated files** copied into package dirs are gitignored: `packages/*/CONTRIBUTING.md` and `packages/*/README.ja.md` (both from root, via `packages/arkor/scripts/copy-root-files.mjs`), `packages/arkor/docs/` (from root `docs/`). Edit the source under repo root, not the copies. Note `packages/arkor/README.md` is NOT generated: it is its own tracked file (the published npm page), so an English change there has no root counterpart to mirror. - **Node version**: published packages declare `engines.node >=22.22.0` (raised from 22.6 to dodge the [Jan 2026 async-hooks DoS CVE](https://nodejs.org/en/blog/vulnerability/january-2026-dos-mitigation-async-hooks)). Use Node 24 (latest preferred) for development per [CONTRIBUTING.md](CONTRIBUTING.md). - **pnpm policy** ([pnpm-workspace.yaml](pnpm-workspace.yaml)): `minimumReleaseAge: 1440` (24 h), `trustPolicy: no-downgrade`, and `blockExoticSubdeps: true` are intentional supply-chain guards. `minimumReleaseAgeExclude` carries a single narrow carve-out for our own `@arkor/cloud-api-client` so this submodule can pick up alpha publishes the same hour they ship; the trade-off is documented inline in `pnpm-workspace.yaml` and the entry should not grow without re-evaluating it. `blockExoticSubdeps` blocks transitive deps from resolving through `git+ssh://` or direct tarball URLs that would bypass the release-age and downgrade gates; we keep it set explicitly as policy (not as a transitional pin for pnpm 10.x's opt-in default) so the supply-chain stance stays co-located with the other guards and survives any future default change upstream. `allowBuilds` is the explicit per-package map for postinstall scripts: `rolldown`, `unrs-resolver`, and `esbuild` are approved (`true`) because the SDK build path needs them; the Mintlify-side transitives `keytar`, `puppeteer`, and `sharp` are explicitly denied (`false`) so pnpm 11's `ERR_PNPM_IGNORED_BUILDS` stops failing the install without granting docs-only postinstalls the right to run install-time code. Flip a denied entry to `true` only if a concrete docs task starts failing because its native binary is missing. - **Dependency catalog** ([pnpm-workspace.yaml](pnpm-workspace.yaml) `catalog:` block): direct third-party (registry) deps declared in workspace `package.json` files resolve through the default catalog instead of carrying literal semver per `package.json`; each consumer references the entry as `"": "catalog:"`. The point is one source of truth: a version bump is a single-line edit in `pnpm-workspace.yaml`, not N parallel edits across `packages/*` and `e2e/*` that risk drifting out of sync. The catalog only covers **direct** deps; transitive deps fall back to the lockfile and the supply-chain guards above. To bump a dep, edit the catalog entry and run `pnpm install` (do NOT `pnpm add -w @` against a catalog entry: it would rewrite the workspace's `package.json` from `catalog:` to a literal version and re-introduce the drift). Workspace-internal links (`@arkor/cli-internal`, `arkor`, `create-arkor`) stay as `workspace:*` since catalogs only cover registry versions. **Carve-out**: the **runtime `dependencies`** of the two published packages ([`packages/arkor/package.json`](packages/arkor/package.json) and [`packages/create-arkor/package.json`](packages/create-arkor/package.json)) keep literal semver. This carve-out predates the switch to `pnpm publish`: the [release workflow](.github/workflows/build.yaml) used to publish via `npm publish` (run from the package directory so the npm CLI embedded `README.md` into the registry packument), and `npm publish` does not rewrite the pnpm-specific `catalog:` protocol, so consumers would have seen `EUNSUPPORTEDPROTOCOL`. The workflow now publishes via `pnpm publish` with `embedReadme: true` set in [pnpm-workspace.yaml](pnpm-workspace.yaml) (pnpm 11's native publish does not embed the README by default), and `pnpm publish` *does* resolve `catalog:`/`workspace:` to concrete versions in the published manifest, so the `EUNSUPPORTEDPROTOCOL` hazard is gone. The runtime deps are still kept literal on purpose, so the published manifest mirrors source exactly with no publish-time catalog-expansion step to reason about. Their `devDependencies` are catalog-bound as normal: `pnpm pack` does still ship the field in the published `package.json`, but `npm install ` on the consumer side never installs a dependency's `devDependencies`, so the `catalog:` protocol there is never resolved at install time and cannot trigger `EUNSUPPORTEDPROTOCOL`. diff --git a/README.ja.md b/README.ja.md index 7773ab31..560676c6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -152,7 +152,7 @@ my-arkor-app/ `arkor dev` は [Hono](https://hono.dev) サーバーを `127.0.0.1:4000` で起動し、同一オリジンから Vite + React SPA を配信します。 -SPA は起動ごとの CSRF トークンでゲートされた `/api/*` ルート (ループバック専用、DNS リバインディング対策の `Host` ヘッダーガード付き) を通じてあなたのコードと通信します。あなたのコードは認証付き HTTPS で Arkor トレーニングバックエンドと通信します。 +Studio サーバーは HTML を配信する前の段階で、ループバック以外の `Host` ヘッダーを拒否します (DNS リバインディング対策)。SPA は起動ごとの CSRF トークンでゲートされた `/api/*` ルートを通じてあなたのコードと通信します。あなたのコードは認証付き HTTPS で Arkor トレーニングバックエンドと通信します。 トレーニングはマネージド GPU 上で実行され、チェックポイントは SSE イベントとしてストリームバックされ、プロセス内であなたの `callbacks.*` を発火させます。 diff --git a/README.md b/README.md index faa555f7..803457ae 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ my-arkor-app/ `arkor dev` boots a [Hono](https://hono.dev) server on `127.0.0.1:4000` that serves a Vite + React SPA from the same origin. -The Studio server rejects non-loopback `Host` headers before serving HTML, and the SPA talks to your code via per-launch CSRF-token-gated `/api/*` routes; your code talks to the Arkor training backend over authenticated HTTPS. +The Studio server rejects non-loopback `Host` headers before serving HTML (DNS-rebinding defence), and the SPA talks to your code via per-launch CSRF-token-gated `/api/*` routes; your code talks to the Arkor training backend over authenticated HTTPS. Training runs on managed GPUs; checkpoints stream back as SSE events that fire your `callbacks.*` in process. diff --git a/docs/cli/build-and-start.mdx b/docs/cli/build-and-start.mdx index f25f12ae..693ae011 100644 --- a/docs/cli/build-and-start.mdx +++ b/docs/cli/build-and-start.mdx @@ -112,7 +112,7 @@ The "reuse the artifact" path is what lets Studio surface trainer edits via its | Message | What it means | Fix | | --- | --- | --- | | `Build entry not found: . Create src/arkor/index.ts or pass an explicit entry argument.` | `arkor start` runs `arkor build` first whenever you pass an entry argument, or whenever `.arkor/build/index.mjs` is missing. A bad entry path surfaces here, not at the runner stage. | Pass an entry that exists, or omit it and rely on the default `src/arkor/index.ts`. | -| `Training entry must export 'arkor' (from createArkor({...})) or 'trainer' (from createTrainer({...})), or default-export one of them.` | The bundle imported successfully but did not expose any of the supported export shapes. | See [Project structure § `src/arkor/`](/concepts/project-structure#srcarkor) for the three accepted forms (named `arkor`, named `trainer`, or default). | +| `Training entry must export 'arkor' (from createArkor({...})) or 'trainer' (from createTrainer({...})), or default-export one of them.` | The bundle imported successfully but did not expose any of the supported export shapes. | See [Project structure § `src/arkor/`](/concepts/project-structure#src/arkor/) for the three accepted forms (named `arkor`, named `trainer`, or default). | `runTrainer` (programmatic): @@ -198,6 +198,6 @@ bun run build && bun start ## See also -- [Project structure § `src/arkor/`](/concepts/project-structure#srcarkor) for the export shapes the runner accepts +- [Project structure § `src/arkor/`](/concepts/project-structure#src/arkor/) for the export shapes the runner accepts - [`runTrainer`](/sdk/overview#auxiliary-helpers-advanced) for driving the same runtime path from your own TypeScript code - [Programmatic runs recipe](/cookbook/programmatic-runs) for a full server / script wiring diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index 1a3cbac7..f7d003e0 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -47,7 +47,9 @@ bun dev ### Launch sequence -1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; on anon-only deployments it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint because `arkor login --oauth` would fail there. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``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.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/cli/config` itself is unreachable, the deployment mode cannot be determined, so the same transport error is kept and re-raised **only if this launch goes on to serve** (serving needs credentials): `arkor dev` then exits 1 (restore connectivity and re-run). The failure is deliberately deferred rather than immediate so that the port-collision path below still works offline: connecting to an Arkor Studio that already serves this project needs no credentials of its own, so that case still prints `Arkor Studio already running on ` and exits 0. If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment), it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in. Run `arkor login --oauth` and try again.``). +1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; on anon-only deployments it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint because `arkor login --oauth` would fail there. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``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.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/cli/config` itself is unreachable, the deployment mode cannot be determined, so the same transport error is kept and re-raised **only if this launch goes on to serve** (serving needs credentials): `arkor dev` then exits 1 (restore connectivity and re-run). The failure is deliberately deferred rather than immediate so that the port-collision path below still works offline: connecting to an Arkor Studio that already serves this project needs no credentials of its own, so that case still prints `Arkor Studio already running on ` and exits 0. If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment) **and** the deployment advertises OAuth, it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in. Run `arkor login --oauth` and try again.``). The sign-in hint is conditional because on an anon-only deployment `arkor login --oauth` would itself fail; there the raw rejection surfaces instead (`Failed to acquire anonymous token (): `). + +Note that **every** one of these bootstrap failures is deferred the same way, not just the config-unreachable case: `arkor dev` captures whatever `ensureCredentialsForStudio` throws (the 4xx wrap, the raw rejection, and local `~/.arkor/credentials.json` write errors alike) and re-raises it only if this launch actually serves. On the port-collision connect path it is discarded, so a first run against an unreachable or sign-in-only deployment can still adopt a Studio that is already serving this project and exit 0. 2. **CSRF token.** A 32-byte token (base64url, ~43 chars) is generated for this launch. It is injected into `index.html` as `` so the same-origin SPA can read it. Cross-origin tabs cannot read the meta and are rejected by the `/api/*` middleware. 3. **Listener.** Hono on `127.0.0.1:`. The `Host` header guard accepts both `127.0.0.1` and `localhost`, so the URL the CLI prints (`http://localhost:`) works without surprising DNS-rebinding fallout. 4. **Token persistence (best-effort, after a successful bind).** Once the listener is up, the token is written to `~/.arkor/studio-token` (mode `0600`) so the studio-app Vite dev server (`pnpm --filter @arkor/studio-app dev`) can pick it up. Binding first means a doomed second launch on a busy port never touches the file (see "Port collision" below). If writing fails (read-only `$HOME`, locked-down umask), `arkor dev` continues; only the standalone Vite dev workflow is affected. @@ -58,6 +60,8 @@ When the process exits (normal exit, `SIGINT`, `SIGTERM`, or `SIGHUP`) the studi `arkor dev --agent` runs the exact same Studio server headlessly for coding agents (Claude Code and others) that operate Arkor through direct HTTP calls instead of the browser UI. The flag is a plain opt-in: no environment variable is required. +`--agent` changes what the server *reports*, not how long it runs: it is still a foreground process that serves until you stop it, exactly like a plain `arkor dev`. An agent must therefore start it in the background (or in a separate shell/pane) and then read the session file, rather than waiting for the command to return. It never will. + Everything in the launch sequence above happens unchanged, plus: 1. **Project-local session file.** The per-launch CSRF token is written to a unique JSON file inside the project: `/.arkor/agent/session--.json`. The `.arkor/agent/` directory is created with mode `0700` and the file with mode `0600`; `.arkor/` is already gitignored by the scaffolder. The content is `{ "token": "", "url": "http://127.0.0.1:", "port": , "pid": }` (the `url` is the `127.0.0.1` literal the server bound, see below). Unlike the home token, a failure to write this file **aborts startup** with exit code 1: the session file is the agent's only token channel, so a server without it would be silently unusable. @@ -99,7 +103,9 @@ The Studio server enforces three checks on every `/api/*` request: 2. The CSRF token must be present as the `X-Arkor-Studio-Token` header, with one exception: the secrets-free `GET /api/status` probe is token-exempt (see above). The job-event stream also accepts `?studioToken=...` because `EventSource` cannot send custom headers; mutation routes do not accept query-string tokens. Token comparison is `timingSafeEqual`. 3. CORS is intentionally not configured: the SPA is same-origin so CORS adds no value, and reflecting `*` would let "simple" cross-origin POSTs (`text/plain`, `urlencoded`) skip preflight. Without a token, the middleware rejects them. -This means `arkor dev` is safe on a shared dev machine: another tab cannot read the meta, a stale tab from a previous launch holds an old token that no longer matches, and an attacker page in a different origin cannot forge requests. +This closes the **browser** attack surface: another tab cannot read the meta, a stale tab from a previous launch holds an old token that no longer matches, and an attacker page in a different origin cannot forge requests. + +It is not a defence against another **local user** on a shared machine. Anything that can open a loopback socket can `GET /` and read the token straight out of the served `index.html`, then drive `/api/*` with it. Treat `arkor dev` as trusting every local process on the box: on a multi-user host, do not run it on a port other users can reach. ### Port collision diff --git a/docs/concepts/studio.mdx b/docs/concepts/studio.mdx index 121c2bff..b20c4ea4 100644 --- a/docs/concepts/studio.mdx +++ b/docs/concepts/studio.mdx @@ -42,7 +42,7 @@ Arkor managed backend (training, inference) That separation is why Studio works without you logging into anything in the browser: the CLI already has your credentials in `~/.arkor/credentials.json`, and Studio inherits them by virtue of running locally. -If `~/.arkor/credentials.json` is missing, the entry point decides what to do. **`arkor dev`** bootstraps an anonymous session at launch and prints a one-line hint pointing at `arkor login --oauth` so you can upgrade to a real account whenever you want; it never auto-launches the OAuth flow. The one outage case where it does not bootstrap is when `/v1/auth/cli/config` itself is unreachable on first run: the same transport error is rethrown and `arkor dev` exits fast (see [`arkor dev`](/cli/dev) for the exact recovery story). The **Studio server's lazy bootstrap** (when an `/api/*` request arrives before credentials are on disk) does the same anonymous fallback. To use an account session, run `arkor login --oauth` separately before (or after) clicking around in Studio; the credentials file is shared, so Studio picks up the account session on its next request. +If `~/.arkor/credentials.json` is missing, the entry point decides what to do. **`arkor dev`** bootstraps an anonymous session at launch and prints a one-line hint pointing at `arkor login --oauth` so you can upgrade to a real account whenever you want; it never auto-launches the OAuth flow. The one outage case where it does not bootstrap is when `/v1/auth/cli/config` itself is unreachable on first run: the transport error is kept and re-raised only if this launch goes on to serve, in which case `arkor dev` exits 1. If instead the port is already held by an Arkor Studio serving this project, the connect path wins and the launch still exits 0, because adopting a running instance needs no credentials of its own (see [`arkor dev`](/cli/dev) for the exact recovery story). The **Studio server's lazy bootstrap** (when an `/api/*` request arrives before credentials are on disk) does the same anonymous fallback. To use an account session, run `arkor login --oauth` separately before (or after) clicking around in Studio; the credentials file is shared, so Studio picks up the account session on its next request. ## What you actually see diff --git a/docs/guides/cli/dev.mdx b/docs/guides/cli/dev.mdx index 505e5ff3..9f820ce0 100644 --- a/docs/guides/cli/dev.mdx +++ b/docs/guides/cli/dev.mdx @@ -29,7 +29,7 @@ bun dev -Open the URL the CLI prints. Click **Run training** to spawn a training run against `src/arkor/index.ts`. Live metrics stream in; once a checkpoint lands, the Playground lets you chat with it. +Open the URL the CLI prints. Click **Run training** to spawn a training run against `src/arkor/index.ts`. Live metrics stream in, and once the job completes you can chat with its final adapter in the Playground (to try intermediate checkpoints mid-run, use the SDK's [`onCheckpoint({ infer })`](/sdk/callbacks) instead). `arkor dev` does **not** start a training run on its own. It only serves the UI plus the small loopback API the SPA talks to. Training is triggered from the UI. diff --git a/docs/ja/cli/build-and-start.mdx b/docs/ja/cli/build-and-start.mdx index 53839c5e..8648b7f5 100644 --- a/docs/ja/cli/build-and-start.mdx +++ b/docs/ja/cli/build-and-start.mdx @@ -112,7 +112,7 @@ bun start | メッセージ | 意味 | 対処 | | --- | --- | --- | | `Build entry not found: . Create src/arkor/index.ts or pass an explicit entry argument.`(ビルドエントリーが見つかりません: ``。`src/arkor/index.ts` を作成するか、明示的なエントリー引数を渡してください) | `arkor start` はエントリー引数を渡したとき、または `.arkor/build/index.mjs` が無いとき先に `arkor build` を走らせます。不正なエントリーパスはランナー段階ではなくこのビルド段階で検出されます。 | 存在するエントリーを渡すか、省略してデフォルトの `src/arkor/index.ts` に任せる。 | -| `Training entry must export 'arkor' (from createArkor({...})) or 'trainer' (from createTrainer({...})), or default-export one of them.`(学習エントリーは `createArkor({...})` の `arkor` か `createTrainer({...})` の `trainer` をエクスポートするか、いずれかをデフォルトエクスポートする必要があります) | バンドルの import は成功したが、サポートしているエクスポート形をどれも露出していない。 | 受け付ける 3 つの形(名前付き `arkor`、名前付き `trainer`、デフォルト)は [プロジェクト構成 § `src/arkor/`](/ja/concepts/project-structure#srcarkor) を参照。 | +| `Training entry must export 'arkor' (from createArkor({...})) or 'trainer' (from createTrainer({...})), or default-export one of them.`(学習エントリーは `createArkor({...})` の `arkor` か `createTrainer({...})` の `trainer` をエクスポートするか、いずれかをデフォルトエクスポートする必要があります) | バンドルの import は成功したが、サポートしているエクスポート形をどれも露出していない。 | 受け付ける 3 つの形(名前付き `arkor`、名前付き `trainer`、デフォルト)は [プロジェクト構成 § `src/arkor/`](/ja/concepts/project-structure#src/arkor/) を参照。 | `runTrainer`(プログラム): @@ -198,6 +198,6 @@ bun run build && bun start ## 関連項目 -- [プロジェクト構成 § `src/arkor/`](/ja/concepts/project-structure#srcarkor): ランナーが受け付けるエクスポート形 -- [`runTrainer`](/ja/sdk/overview#補助ヘルパー上級者向け): 同じランタイムの動作を自分の TypeScript コードから実行 +- [プロジェクト構成 § `src/arkor/`](/ja/concepts/project-structure#src/arkor/): ランナーが受け付けるエクスポート形 +- [`runTrainer`](/ja/sdk/overview#補助ヘルパー(上級者向け)): 同じランタイムの動作を自分の TypeScript コードから実行 - [プログラム実行レシピ](/ja/cookbook/programmatic-runs): サーバーやスクリプトに組み込む完全な配線 diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 9501d410..7540f15c 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -47,7 +47,9 @@ bun dev ### 起動シーケンス -1. **認証情報の初期化。** `~/.arkor/credentials.json` が無いとき、CLI は **常に匿名セッションの初期化を試みます**。`/v1/auth/cli/config` を呼び、続いて `/v1/auth/anonymous` から匿名トークンを要求します。初期化前のメッセージはデプロイが OAuth をアドバタイズしているかで分岐します。OAuth が設定されている場合は ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションを初期化します。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください)を出して、好きなタイミングで本物のアカウントへアップグレードできることを案内します。匿名専用デプロイでは代わりに `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、`arkor login --oauth` がそのデプロイでは失敗するため OAuth ヒントは省略されます。いずれの場合も OAuth フローを自動で起動することはありません。トークンが届くと `arkor dev` は ``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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`)を出します。デプロイが OAuth をアドバタイズしている場合に限り、成功メッセージと並んで 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` でサインインしてください)が発行され、アップグレード経路が発行時点で見えます。匿名専用デプロイでは `arkor login --oauth` を案内すると失敗するコマンドへユーザーを誘導してしまうので、warn は意図的に抑制されます。一過性のトランスポート障害(`fetch failed`)の扱いはタイミングで分かれます。`/v1/auth/cli/config` が成功してデプロイモードが特定済みのあと、`/v1/auth/anonymous` で同様の障害が出た場合のみ警告して続行し、Studio サーバーは初回の `/api/credentials` ヒットで再試行します。`/v1/auth/cli/config` 自体に到達できなかった場合はデプロイモードが特定できないため、そのトランスポートエラーは保持され、**この起動が実際にサーブへ進んだときにのみ**再スローされます(サーブには認証情報が必要)。その場合 `arkor dev` は exit 1 で終了するので、接続を回復してから再実行してください。即座に失敗させず意図的に遅延させているのは、下記のポート衝突の経路をオフラインでも機能させるためです。同じプロジェクトをサーブしている既存の Arkor Studio への接続はそれ自体に認証情報を必要としないので、そのケースでは引き続き `Arkor Studio already running on ` を出して exit 0 で終了します。`/v1/auth/anonymous` が 4xx で拒否される場合(例えば、このデプロイで匿名サインインが無効になっているなど)は HTTP ステータスを含むエラーで `arkor login --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` を実行して再試行してください)。 +1. **認証情報の初期化。** `~/.arkor/credentials.json` が無いとき、CLI は **常に匿名セッションの初期化を試みます**。`/v1/auth/cli/config` を呼び、続いて `/v1/auth/anonymous` から匿名トークンを要求します。初期化前のメッセージはデプロイが OAuth をアドバタイズしているかで分岐します。OAuth が設定されている場合は ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションを初期化します。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください)を出して、好きなタイミングで本物のアカウントへアップグレードできることを案内します。匿名専用デプロイでは代わりに `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、`arkor login --oauth` がそのデプロイでは失敗するため OAuth ヒントは省略されます。いずれの場合も OAuth フローを自動で起動することはありません。トークンが届くと `arkor dev` は ``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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`)を出します。デプロイが OAuth をアドバタイズしている場合に限り、成功メッセージと並んで 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` でサインインしてください)が発行され、アップグレード経路が発行時点で見えます。匿名専用デプロイでは `arkor login --oauth` を案内すると失敗するコマンドへユーザーを誘導してしまうので、warn は意図的に抑制されます。一過性のトランスポート障害(`fetch failed`)の扱いはタイミングで分かれます。`/v1/auth/cli/config` が成功してデプロイモードが特定済みのあと、`/v1/auth/anonymous` で同様の障害が出た場合のみ警告して続行し、Studio サーバーは初回の `/api/credentials` ヒットで再試行します。`/v1/auth/cli/config` 自体に到達できなかった場合はデプロイモードが特定できないため、そのトランスポートエラーは保持され、**この起動が実際にサーブへ進んだときにのみ**再スローされます(サーブには認証情報が必要)。その場合 `arkor dev` は exit 1 で終了するので、接続を回復してから再実行してください。即座に失敗させず意図的に遅延させているのは、下記のポート衝突の経路をオフラインでも機能させるためです。同じプロジェクトをサーブしている既存の Arkor Studio への接続はそれ自体に認証情報を必要としないので、そのケースでは引き続き `Arkor Studio already running on ` を出して exit 0 で終了します。`/v1/auth/anonymous` が 4xx で拒否され、**かつ**そのデプロイが OAuth をアドバタイズしている場合は、HTTP ステータスを含むエラーで `arkor login --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` を実行して再試行してください)。サインイン案内が条件付きなのは、匿名専用デプロイでは `arkor login --oauth` 自体が失敗するためで、その場合は生の拒否エラー(`Failed to acquire anonymous token (): `)がそのまま出ます。 + +なお、これらのブートストラップ失敗は config 到達不可のケースだけでなく**すべて**同じ形で遅延されます。`arkor dev` は `ensureCredentialsForStudio` が投げたもの(4xx のラップ、生の拒否、ローカルの `~/.arkor/credentials.json` 書き込みエラーのいずれも)を捕捉し、この起動が実際にサーブしたときにのみ再スローします。ポート衝突の接続経路では破棄されるため、到達不可あるいはサインイン必須のデプロイに対する初回起動でも、同じプロジェクトを既にサーブしている Studio に接続して exit 0 で終了できます。 2. **CSRF トークン。** この起動用に 32 バイトのトークン(base64url、約 43 文字)を生成。同一オリジンの SPA が読めるよう `` として `index.html` にインジェクトされます。クロスオリジンタブはこの meta を読めず、`/api/*` のミドルウェアに拒否されます。 3. **リスナー。** `127.0.0.1:` 上の Hono。`Host` ヘッダーのガードは `127.0.0.1` と `localhost` の両方を受け付けるので、CLI が表示する URL(`http://localhost:`)は DNS リバインディング系の挙動なしで動きます。 4. **トークンの永続化(ベストエフォート、バインド成功後)。** リスナーが立ち上がってから、トークンを `~/.arkor/studio-token`(モード `0600`)に書きます。studio-app の Vite dev サーバー(`pnpm --filter @arkor/studio-app dev`)が拾えるようにするためです。先にバインドするので、使用中ポートへの 2 つ目の起動がこのファイルに触れることはありません(下の「ポート競合」参照)。書き込みが失敗(`$HOME` が読み取り専用、umask が厳しいなど)しても `arkor dev` は続行します。影響を受けるのはスタンドアローン Vite dev ワークフローだけです。 @@ -58,6 +60,8 @@ bun dev `arkor dev --agent` は、ブラウザー UI ではなく HTTP を直接叩いて Arkor を操作するコーディングエージェント(Claude Code など)向けに、まったく同じ Studio サーバーをヘッドレスで起動します。フラグは純粋なオプトインで、環境変数は不要です。 +`--agent` が変えるのはサーバーが**報告する内容**であって、動作時間ではありません。素の `arkor dev` とまったく同じく、停止するまで動き続けるフォアグラウンドプロセスです。したがってエージェントはこれをバックグラウンド(または別のシェル / ペイン)で起動し、コマンドの終了を待つのではなくセッションファイルを読んでください。このコマンドが自分から返ることはありません。 + 上記の起動シーケンスはすべてそのまま実行され、加えて次が行われます。 1. **プロジェクト内セッションファイル。** この起動用の CSRF トークンを、プロジェクト内の固有な JSON ファイル `/.arkor/agent/session--.json` に書き込みます。`.arkor/agent/` ディレクトリーはモード `0700`、ファイルはモード `0600` で作成されます。`.arkor/` は scaffold の時点で gitignore 済みです。内容は `{ "token": "", "url": "http://127.0.0.1:", "port": , "pid": }` です(`url` はサーバーがバインドした `127.0.0.1` リテラル。下記参照)。home のトークンと異なり、このファイルの書き込みに失敗すると **起動は中止され** exit code 1 になります。セッションファイルはエージェント唯一のトークン受け渡し経路であり、これが無いサーバーは呼び出し元から静かに使用不能になるためです。 @@ -99,7 +103,9 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ 2. CSRF トークンは `X-Arkor-Studio-Token` ヘッダーとして必須。ただし 1 つだけ例外があり、機密ゼロの `GET /api/status` プローブはトークン不要です(上記参照)。ジョブイベントストリームだけは `?studioToken=...` も受け付けます(`EventSource` はカスタムヘッダーを送れないため)。変更系ルートはクエリー文字列のトークンを受け付けません。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。 3. CORS は意図的に未設定。SPA は同一オリジンなので CORS は意味を持たず、`*` を反射すると preflight をスキップする「simple」なクロスオリジン POST(`text/plain`、`urlencoded`)を素通りさせてしまう。トークンが無ければミドルウェアが拒否します。 -これにより `arkor dev` は共有の開発環境でも安全です。別タブは meta を読めず、過去の起動の古いタブはトークンが一致せず、別オリジンの攻撃者ページはリクエストを偽造できません。 +これにより**ブラウザー**側の攻撃面は塞がれます。別タブは meta を読めず、過去の起動の古いタブはトークンが一致せず、別オリジンの攻撃者ページはリクエストを偽造できません。 + +ただしこれは、共有マシン上の**別のローカルユーザー**に対する防御ではありません。ループバックにソケットを開けるものは `GET /` で配信された `index.html` からトークンをそのまま読み取り、それを使って `/api/*` を叩けます。`arkor dev` はそのマシン上のすべてのローカルプロセスを信頼するものとして扱ってください。マルチユーザーのホストでは、他のユーザーが到達できるポートで起動しないでください。 ### ポート競合 diff --git a/docs/ja/concepts/studio.mdx b/docs/ja/concepts/studio.mdx index de380064..4c499e66 100644 --- a/docs/ja/concepts/studio.mdx +++ b/docs/ja/concepts/studio.mdx @@ -42,7 +42,7 @@ Arkor マネージドバックエンド(学習、推論) この分離があるからこそ、ブラウザー側で何もログインせずに Studio が動きます。CLI は `~/.arkor/credentials.json` ですでに認証情報を持っており、Studio はローカルで動いている前提のもとその認証情報を引き継ぎます。 -`~/.arkor/credentials.json` がない場合の処理はエントリーポイントが決めます。**`arkor dev`** は起動時に匿名セッションを初期化し、必要に応じてアップグレードできるよう `arkor login --oauth` を案内する 1 行のヒントを出します。OAuth フローを自動で起動することはありません。例外は初回起動時に `/v1/auth/cli/config` 自体へ到達できないケースで、同じトランスポートエラーが再スローされて `arkor dev` は fail-fast で終了します(具体的な復旧手順は [`arkor dev`](/ja/cli/dev) を参照)。**Studio サーバーの遅延初期化**(認証情報が無い状態で `/api/*` リクエストが届いたとき)も同じ匿名フォールバックを行います。アカウントセッションを使いたい場合は、Studio をクリックする前(あるいは後)に別途 `arkor login --oauth` を実行してください。認証情報ファイルは共有なので、Studio は次のリクエストでアカウントセッションを拾います。 +`~/.arkor/credentials.json` がない場合の処理はエントリーポイントが決めます。**`arkor dev`** は起動時に匿名セッションを初期化し、必要に応じてアップグレードできるよう `arkor login --oauth` を案内する 1 行のヒントを出します。OAuth フローを自動で起動することはありません。例外は初回起動時に `/v1/auth/cli/config` 自体へ到達できないケースです。このときトランスポートエラーは保持され、この起動が実際にサーブへ進んだときにのみ再スローされて `arkor dev` は exit 1 で終了します。一方、同じプロジェクトをサーブしている Arkor Studio が既にポートを握っている場合は接続経路が優先され、既存インスタンスへの接続はそれ自体に認証情報を必要としないため exit 0 で終了します(具体的な復旧手順は [`arkor dev`](/ja/cli/dev) を参照)。**Studio サーバーの遅延初期化**(認証情報が無い状態で `/api/*` リクエストが届いたとき)も同じ匿名フォールバックを行います。アカウントセッションを使いたい場合は、Studio をクリックする前(あるいは後)に別途 `arkor login --oauth` を実行してください。認証情報ファイルは共有なので、Studio は次のリクエストでアカウントセッションを拾います。 ## 実際に見えるもの diff --git a/docs/ja/quickstart.mdx b/docs/ja/quickstart.mdx index 5597f55e..2a17e7e2 100644 --- a/docs/ja/quickstart.mdx +++ b/docs/ja/quickstart.mdx @@ -75,6 +75,8 @@ bun create arkor my-arkor-app --template triage Claude Code は子プロセスに `CLAUDECODE=1` を渡し、対話プロンプトに応答できません。エージェントが隠れたデフォルトで黙ってプロジェクトを生成してしまうのを防ぐため、この環境変数下では `create-arkor` も厳格モードに切り替わります。下記のキュレーション済みフラグセットが必須となり、不足があれば実行ではなく、再実行用のコマンドを stderr に出して終了します。同じルールは [`arkor init`](/ja/cli/init#claude-code(claudecode=1)厳格モード) にも適用されますが、`create-arkor` だけ追加で `[dir]`(または `--name`)も必須となります。これは positional 省略時のフォールバック先 `arkor-project/` サブディレクトリが意図的に選ばれることはまずなく、ほぼ常に指定漏れだからです。ASCII の英数字を 1 文字も含まない入力(空、空白だけ、`!!!` のような記号だけ)も拒否されます。`sanitise()` が同じ generic フォールバックに collapse させてしまうためです。一方、サニタイズ結果がたまたま `arkor-project` になるような **意図的な名前**(例: `Arkor Project`、`arkor_project`、`Arkor.Project` のようにセパレータ文字が `-` に書き換えられるもの)は受け入れます。英数字が含まれていることがエージェントの明示的入力の証拠になるためです。 +`arkor dev` も同じ環境変数でゲートされますが形が異なります。キュレーション済みフラグの一覧も `-y`/`--yes` の逃げ道も無く、代わりに常に `--agent` を要求します(下記のステップ 3 を参照)。 + 必須フラグ(または `-y`/`--yes` で「すべてデフォルトで進める」にオプトイン): - `[dir]`(例 `my-arkor-app`)または `--name `: 明示的なプロジェクト名。ASCII の英数字を 1 文字も含まない入力(空、空白だけ、`!!!` のような記号だけ)は、`arkor-project` フォールバックに collapse させてしまうため拒否。`Arkor Project`、`arkor_project` のように **意図的でたまたま** サニタイズ結果が `arkor-project` になる入力は受け入れます。 @@ -146,6 +148,8 @@ bun dev これは `arkor dev` を実行し、Studio を `http://localhost:4000` で起動します。Studio は UI で、`arkor dev` 自体は学習を開始することも、トレーナーファイルを監視することもしません。 +`CLAUDECODE=1` の下ではこのステップは変わります。素の `arkor dev` は exit `1` で終了し、エージェントが操作できないブラウザー前提のサーバーを起動する代わりに `--agent` を要求します。`arkor dev --agent` を実行してください。同じ Studio をヘッドレスでサーブし、API トークンを保持する JSON セッションファイルを書き出します。[`arkor dev` § Claude Code(`CLAUDECODE=1`)厳格モード](/ja/cli/dev#claude-code(claudecode=1)厳格モード) を参照してください。 + ブラウザーで **Run training** をクリック。Studio はバックグラウンドでマネージドバックエンドにジョブを投入し、出力をページにストリーム表示します。最初に何らかの操作を行ったタイミングで匿名ワークスペースが自動作成されます。サインアップもクレジットカードも不要です。 学習が進行中に重要なビューは 3 つあります: diff --git a/docs/ja/sdk/create-arkor.mdx b/docs/ja/sdk/create-arkor.mdx index c0e82385..9a0b3cda 100644 --- a/docs/ja/sdk/create-arkor.mdx +++ b/docs/ja/sdk/create-arkor.mdx @@ -70,5 +70,5 @@ if (isArkor(value)) { ## 関連項目 - [`createTrainer`](/ja/sdk/create-trainer): ラップする `Trainer` -- [プロジェクト構成 § `src/arkor/`](/ja/concepts/project-structure#srcarkor): マニフェストが読まれる場所 +- [プロジェクト構成 § `src/arkor/`](/ja/concepts/project-structure#src/arkor/): マニフェストが読まれる場所 - [ロードマップ § Deploy と eval スロット](/ja/roadmap#deploy-and-eval-slots): 予約フィールドの方向性 diff --git a/docs/ja/sdk/trainer-control.mdx b/docs/ja/sdk/trainer-control.mdx index 23c7ada0..587b8c07 100644 --- a/docs/ja/sdk/trainer-control.mdx +++ b/docs/ja/sdk/trainer-control.mdx @@ -143,5 +143,5 @@ try { - [`createTrainer`](/ja/sdk/create-trainer): この `Trainer` を返す入力の型 - [ライフサイクルコールバック](/ja/sdk/callbacks): `wait()` 内から発火するもの -- [`runTrainer`](/ja/sdk/overview#補助ヘルパー上級者向け): `start()` + `wait()` をラップしてエントリー解決まで行うヘルパー +- [`runTrainer`](/ja/sdk/overview#補助ヘルパー(上級者向け)): `start()` + `wait()` をラップしてエントリー解決まで行うヘルパー - [プログラム実行レシピ](/ja/cookbook/programmatic-runs): サーバーやスクリプトに組み込む完全な配線 diff --git a/docs/ja/studio/overview.mdx b/docs/ja/studio/overview.mdx index ed0a3b90..a7fd7316 100644 --- a/docs/ja/studio/overview.mdx +++ b/docs/ja/studio/overview.mdx @@ -37,7 +37,7 @@ Arkor マネージドバックエンド `/api/*` リクエストごとに 3 つのチェックが走ります: 1. **Host ヘッダーのガード。** `127.0.0.1` と `localhost` のみ受理。`127.0.0.1` に DNS リバインディングされる悪意あるサイトに誘導された被害者でも、送信されるのは `Host: evil.com` で、ミドルウェアは HTTP 403 で拒否します。 -2. **起動ごとの CSRF トークン。** `arkor dev` は起動ごとに 32 バイトのトークン(base64url)を生成し、`` として `index.html` にインジェクトし、すべての `/api/*` 呼び出しに必須化します(ヘッダー `X-Arkor-Studio-Token`、または `EventSource` リクエスト用に `?studioToken=`)。クロスオリジンタブは meta を読めないので、preflight をスキップする「simple」なクロスオリジン POST も拒否されます。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。唯一の例外は `GET /api/status` で、これはトークン不要です(ループバック + Host ガードは適用)。機密ゼロでユーザーコードを実行しないため、`arkor dev` のポート競合プローブがトークンを送信せずに占有者を確認できます。 +2. **起動ごとの CSRF トークン。** `arkor dev` は起動ごとに 32 バイトのトークン(base64url)を生成し、`` として `index.html` にインジェクトし、すべての `/api/*` 呼び出しに必須化します(ヘッダー `X-Arkor-Studio-Token`)。`EventSource` はカスタムヘッダーを送れないため、ジョブイベントストリームだけは `?studioToken=` も受け付けます。ミューテーション系ルートはクエリ文字列のトークンを受け付けません。クロスオリジンタブは meta を読めないので、preflight をスキップする「simple」なクロスオリジン POST も拒否されます。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。唯一の例外は `GET /api/status` で、これはトークン不要です(ループバック + Host ガードは適用)。機密ゼロでユーザーコードを実行しないため、`arkor dev` のポート競合プローブがトークンを送信せずに占有者を確認できます。 3. **CORS なし。** SPA は同一オリジンなので CORS には意味がありません。`*` を反射すると「simple」なクロスオリジン POST(`text/plain`、`urlencoded`)を素通りさせてしまうので、トークンチェックがそれを拒否します。 トークンは `arkor dev` 起動のたびにローテートされるので、前回起動時の古いタブはリロードするまで HTTP 403 で失敗します。 diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 50b76772..8cce7bf4 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -75,6 +75,8 @@ bun create arkor my-arkor-app --template triage Claude Code spawns child processes with `CLAUDECODE=1`, and it cannot answer interactive prompts. To prevent the agent from silently scaffolding with hidden defaults, `create-arkor` flips into a strict mode under this env var: a curated set of flags becomes required (see the list below), and a missing one produces a stderr message listing the suggested re-invocation rather than running. The same rule applies to [`arkor init`](/cli/init#claude-code-claudecode=1-strict-mode), with one extra requirement here: `[dir]` (or `--name`) is also mandatory, since `create-arkor`'s default fallback for a missing positional is the generic `arkor-project/` subdirectory, almost always an oversight rather than an intentional choice. Inputs that contain no ASCII letter or digit (empty, whitespace-only, or punctuation-only like `!!!`) are rejected as well, because `sanitise()` would collapse them back to that same generic fallback. Deliberate names whose final slug *happens* to be `arkor-project` (for example `Arkor Project`, `arkor_project`, `Arkor.Project`, or anything else where a separator gets rewritten to `-`) are accepted: the alphanumeric content shows the agent typed a real name. +`arkor dev` is gated under the same env var but in a different shape: it has no curated flag list and no `-y`/`--yes` escape hatch, and instead always requires `--agent` (see step 3 below). + Required flags (or pass `-y`/`--yes` to opt back into "accept all defaults"): - `[dir]` (e.g. `my-arkor-app`) or `--name `: explicit project name. Values with no ASCII letter or digit (empty, whitespace-only, or punctuation-only like `!!!`) are rejected because they would collapse to the generic `arkor-project` fallback; deliberate inputs that *happen* to sanitise to `arkor-project` (`Arkor Project`, `arkor_project`, …) are accepted. @@ -146,6 +148,8 @@ bun dev This runs `arkor dev`, which boots Studio at `http://localhost:4000`. Studio is the UI; `arkor dev` itself does not start training and does not watch your trainer files. +Under `CLAUDECODE=1` this step is different: a plain `arkor dev` exits `1` and asks for `--agent` instead of starting a browser-oriented server the agent cannot drive. Run `arkor dev --agent`, which serves the same Studio headlessly and writes a JSON session file holding the API token. See [`arkor dev` § Claude Code (`CLAUDECODE=1`) strict mode](/cli/dev#claude-code-claudecode=1-strict-mode). + In the browser, click **Run training**. Studio submits the job to the managed backend in the background and streams the output back into the page. The first time you trigger anything, an anonymous workspace is created automatically: no signup, no credit card. Once a run is in flight, three views matter: diff --git a/docs/sdk/create-arkor.mdx b/docs/sdk/create-arkor.mdx index c475cc00..38144268 100644 --- a/docs/sdk/create-arkor.mdx +++ b/docs/sdk/create-arkor.mdx @@ -70,5 +70,5 @@ A small type guard exported alongside `createArkor` for code that consumes manif ## See also - [`createTrainer`](/sdk/create-trainer) for the `Trainer` you wrap -- [Project structure § `src/arkor/`](/concepts/project-structure#srcarkor) for where the manifest is read from +- [Project structure § `src/arkor/`](/concepts/project-structure#src/arkor/) for where the manifest is read from - [Roadmap § Deploy and eval slots](/roadmap#deploy-and-eval-slots) for the reserved fields' direction diff --git a/docs/studio/overview.mdx b/docs/studio/overview.mdx index 6e2d7d2d..1e8af7c4 100644 --- a/docs/studio/overview.mdx +++ b/docs/studio/overview.mdx @@ -60,7 +60,7 @@ These exist either at the SDK or HTTP-API level, but not as Studio UI today: | --- | --- | | Cancel or pause a running job | Call [`trainer.cancel()`](/sdk/trainer-control#cancel) from your own code. | | Pick an intermediate checkpoint adapter in the Playground | Use [`onCheckpoint({ infer })`](/sdk/callbacks) inside your trainer. The SDK's `infer` is bound to the just-saved checkpoint. | -| Filter / search / paginate the jobs list | Out of scope for the polling list view today. | +| Paginate the jobs list | Out of scope for the polling list view today, which assumes the server returns the full list. Search and status filtering **are** shipped (client-side, over that full list). | | Multiple trainers per project | `/api/manifest` returns a single `trainer`. The SDK [`createArkor`](/sdk/create-arkor) only accepts one. | | Tweak `temperature` / `topP` / `maxTokens` from Playground | The HTTP API ([`InferArgs`](/sdk/infer)) accepts these; pass them when calling `infer` from the SDK. | | Loss chart zoom, export, tooltip | The chart is a static SVG path. | diff --git a/e2e/studio/src/harness/studioServer.ts b/e2e/studio/src/harness/studioServer.ts index 81de2916..fb86ecd1 100644 --- a/e2e/studio/src/harness/studioServer.ts +++ b/e2e/studio/src/harness/studioServer.ts @@ -482,8 +482,9 @@ async function spawnStudio(opts: StartStudioOptions): Promise { /** * Fetch the served index.html once, parse the per-launch token out of * the injected `` tag, - * and return it. The Studio server side-effects the meta tag at - * request time (`server.ts:85-90`); reading + * and return it. The Studio server injects the meta tag at request time + * (`injectStudioToken` in `packages/arkor/src/studio/server.ts`; referenced by + * name rather than line number, which drifts); reading * `~/.arkor/studio-token` directly would couple to a persistence path * that's allowed to fail (CLI swallows errors when HOME is read-only). */ diff --git a/packages/arkor/README.md b/packages/arkor/README.md index df962f7c..f33a3f5d 100644 --- a/packages/arkor/README.md +++ b/packages/arkor/README.md @@ -87,7 +87,7 @@ login` / `arkor logout`. |---|---| | `arkor init` | Scaffold a project in the current directory | | `arkor login` / `logout` / `whoami` | OAuth (PKCE) / anonymous tokens | -| `arkor dev` | Launch the local Studio (hot reload + GUI) | +| `arkor dev` | Launch the local Studio (GUI; add `--agent` for coding agents). Does not watch your trainer files | | `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 | @@ -100,11 +100,18 @@ project's installed copy. Relative imports get inlined. `arkor dev` boots a Hono server on `127.0.0.1:4000` and serves a Vite + React SPA from the same origin. Two roles: -- **Hot reload** for the user's TypeScript so the UI reflects current - source without restarts. +- **Runs your TypeScript from source**: a run started with an explicit entry + file re-bundles it first, so you pick up edits without restarting `arkor + dev`. There is no file watcher, and a run started without an entry reuses + the existing `.arkor/build/index.mjs`. - **GUI operations**: running training, inspecting jobs, mid-training inference in a Playground. +For coding agents, `arkor dev --agent` serves the same Studio headlessly and +writes the per-launch token to a JSON session file under +`/.arkor/agent/`, printing its path to stdout. Under `CLAUDECODE=1` a +plain `arkor dev` refuses to start and asks for `--agent`. + Studio is loopback-only and per-launch CSRF-token-gated: - Every request, including static HTML, rejects `Host` values other than @@ -160,7 +167,9 @@ are being run and where they fail. Three events are emitted per invocation: - `cli_command_started` - `cli_command_completed` (includes `duration_ms`) - `cli_command_failed` (includes `duration_ms`, `error_name`, and the first - 200 chars of `error_message`) + 200 chars of `error_message`). That message is sent verbatim and is not + scrubbed, so it can contain local absolute paths (for example the project + directory or `~/.arkor/...`). Opt out below if that matters to you. Each event carries `command`, `sdk_version`, `node_version`, `platform`, and `auth_mode` (`oauth` / `anon` / `none`). The distinct ID is the `sub` claim diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 72859dde..6559d11e 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -10,7 +10,8 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import type * as FsPromises from "node:fs/promises"; +import { dirname, join } from "node:path"; import * as clack from "@clack/prompts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -35,6 +36,28 @@ vi.mock("open", () => ({ default: vi.fn(async () => undefined), })); +// Record `rename` calls so a test can pin the agent session file's atomic +// temp+rename publish. `vi.spyOn` cannot be used here (ESM module namespaces +// are non-configurable), and a `vi.fn` in the factory would be neutered by the +// `vi.restoreAllMocks()` in afterEach, so use a hoisted array plus a plain +// pass-through wrapper: behaviour is byte-identical to the real fs. +const { renameCalls } = vi.hoisted(() => ({ + renameCalls: [] as [string, string][], +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rename: async ( + from: Parameters[0], + to: Parameters[1], + ) => { + renameCalls.push([String(from), String(to)]); + return actual.rename(from, to); + }, + }; +}); + import { serve } from "@hono/node-server"; import open from "open"; @@ -828,8 +851,17 @@ describe("runDev", () => { // leave the CONTENT untouched (existence alone can't catch a clobber). mkdirSync(join(fakeHome, ".arkor"), { recursive: true }); writeFileSync(studioTokenPath(), "healthy-instance-token"); + // Pin the CLASS, not just the message: bin.ts prints ExpectedCliError + // stack-free, and port-in-use is the most common `arkor dev` failure, so + // downgrading it to a plain Error would dump a minified dist stack for a + // routine collision. A message-only assertion would not catch that. await expect(runDev({ port: 4206 })).rejects.toThrow( - /Port 4206 is already in use/, + expect.objectContaining({ + name: "ExpectedCliError", + message: expect.stringMatching( + /Port 4206 is already in use/, + ) as unknown as string, + }), ); // The healthy instance's token is untouched and no cleanup handler was // registered. @@ -947,8 +979,9 @@ describe("runDev", () => { expect(payload.port).toBe(4310); expect(payload.pid).toBe(process.pid); expect(payload.token).toMatch(/^[\w-]+$/); - // The home token is still written (user-facing contract: the Vite SPA - // workflow and the port-collision probe keep working in agent mode). + // The home token is still written in agent mode (user-facing contract: + // the Vite SPA dev workflow reads it). NOT for the port-collision probe: + // that hits the token-exempt /api/status and sends no token at all. expect(readFileSync(studioTokenPath(), "utf8")).toBe(payload.token); // Agent mode never opens a browser implicitly. expect(open).not.toHaveBeenCalled(); @@ -981,6 +1014,30 @@ describe("runDev", () => { expect(strays).toEqual([]); }); + it("publishes the session file atomically (temp file + rename, never a direct write)", async () => { + // The "no .tmp strays" assertion above passes vacuously if the atomic + // temp+rename is swapped for a plain writeFile to the final path, so it + // does NOT pin atomicity. Readers (the agent, a concurrent `ls -t`) must + // never observe a partially written session file, which is the whole + // reason for the staging write. Spy on rename to pin it: a direct write + // never calls rename and fails here. + renameCalls.length = 0; + const stdout = await runDevCapturingStdout({ + port: 4312, + agent: true, + cwd: projectDir, + }); + const sessionPath = sessionPathFrom(stdout); + const call = renameCalls.find((c) => c[1] === sessionPath); + expect(call).toBeDefined(); + // Staged under a .tmp sibling in the SAME directory: rename is only + // atomic within a filesystem, so a cross-device staging path would + // silently degrade to copy+unlink. + const from = String(call?.[0]); + expect(from).toMatch(/\.tmp$/); + expect(dirname(from)).toBe(dirname(sessionPath)); + }); + it("unlinks the session file from the exit cleanup and on signals", async () => { const stdout = await runDevCapturingStdout({ port: 4312, @@ -1259,6 +1316,25 @@ describe("runDev", () => { ); }); + it.runIf(process.platform === "linux")( + "falls back when the occupant reports a process-relative magic path (no /proc/self/cwd bypass)", + async () => { + // `/proc/self/cwd` is ABSOLUTE, so it clears the isAbsolute guard, but + // `realpathSync` resolves it against the PROBER. Without the + // process-relative carve-out it would resolve to our own projectRoot + // and adopt a hostile occupant that never knew where the project is. + // Same projectRoot === process.cwd() pinning rationale as the test + // above. Linux-only: /proc/self/cwd does not exist elsewhere, so the + // resolve would throw and the probe would fall back anyway, making the + // assertion pass for the wrong reason. + mockServeAddrInUse(); + mockProbe({ server: "arkor-studio", cwd: "/proc/self/cwd" }); + await expect( + runDev({ port: 4328, cwd: process.cwd() }), + ).rejects.toThrow(/Port 4328 is already in use/); + }, + ); + it("falls back when the occupant streams an over-cap body even if it WOULD otherwise match (byte cap)", async () => { mockServeAddrInUse(); // A 200 whose body has the right discriminator + cwd but is > 64 KiB. diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index 085ee276..1e31734c 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { readFileSync, realpathSync, unlinkSync } from "node:fs"; import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises"; import { constants as osConstants } from "node:os"; -import { dirname, isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, normalize } from "node:path"; import { ExpectedCliError } from "@arkor/cli-internal"; import { serve } from "@hono/node-server"; @@ -322,6 +322,26 @@ async function readCapped( return Buffer.concat(chunks).toString("utf8"); } +/** + * True for paths inside a kernel namespace whose symlinks resolve relative to + * the process doing the resolving (`/proc/self/cwd`, `/proc//cwd`, + * `/proc/thread-self/...`, `/dev/fd/`). Such a path is absolute but its + * `realpathSync` result describes US, not the peer that reported it, so it can + * never be trusted as a peer-supplied identity. Linux-only in practice; the + * check is a cheap no-op elsewhere. + */ +function isProcessRelativePath(p: string): boolean { + // Normalise separators so `/proc/self/../self/cwd` and Windows-style input + // cannot sneak past a naive prefix test. + const norm = normalize(p).replaceAll("\\", "/"); + return ( + norm === "/proc" || + norm.startsWith("/proc/") || + norm === "/dev/fd" || + norm.startsWith("/dev/fd/") + ); +} + /** * True when the busy `port` is held by a running Arkor Studio that serves * THIS project. Used by the normal-mode EADDRINUSE path so a plain @@ -382,8 +402,22 @@ async function probeExistingStudio( // `realpathSync` would otherwise resolve against THIS process's cwd // (= projectRoot) and so spuriously pass the same-project check. if (typeof b.cwd !== "string" || !isAbsolute(b.cwd)) return false; + // Reject process-relative magic symlinks before resolving. `/proc/self/cwd` + // (also `/proc//cwd`, `/proc/thread-self/cwd`, `/dev/fd/*`) is + // absolute, so it clears the check above, but `realpathSync` resolves it + // against the RESOLVING process: this CLI. A hostile occupant could + // therefore report `cwd: "/proc/self/cwd"` and have it resolve to our own + // projectRoot, passing the same-project check without ever knowing where + // the project lives. A real Studio always reports a concrete `trainCwd`, + // never one of these, so the carve-out costs nothing. (Adoption is + // unauthenticated by design, so the worst case here was still a nuisance + // redirect rather than code execution, but the documented invariant is + // "same project or no adoption" and this keeps it true.) + if (isProcessRelativePath(b.cwd)) return false; // Compare realpaths so a symlinked project root (e.g. macOS - // `/var` -> `/private/var`) still matches the same project. + // `/var` -> `/private/var`) still matches the same project. The remote + // value needs resolving too: the occupant reports its raw `trainCwd`, + // which on macOS is typically the unresolved `/var/...` form. return realpathSync(b.cwd) === realpathSync(projectRoot); } catch { return false; @@ -398,8 +432,10 @@ async function probeExistingStudio( * persistence: even when persistence failed (read-only `$HOME` on Docker), * a termination signal must still route through `process.exit` so that (a) it * reports the conventional `128 + signal` code and (b) the synchronous 'exit' - * event fires, which is what lets each `/api/train` child register its own - * kill hook (see studio/server.ts) and avoid being orphaned on `docker stop`. + * event fires, which is what runs the Studio server's `killLiveTrainChildren` + * hook (one shared 'exit' listener, attached while at least one `/api/train` + * child is live; see studio/server.ts) so those children are not orphaned on + * `docker stop`. * `cleanup` itself handles the token file (a no-op when none was written). */ function installShutdownHandlers(cleanup: () => void): void { diff --git a/packages/arkor/src/cli/commands/init.ts b/packages/arkor/src/cli/commands/init.ts index 37eb482e..4f4cb60b 100644 --- a/packages/arkor/src/cli/commands/init.ts +++ b/packages/arkor/src/cli/commands/init.ts @@ -236,7 +236,7 @@ export async function runInit(options: InitOptions): Promise { // tell the user to fix `.yarnrc.yml` before running `yarn // install`. Running install ourselves first would produce an // empty `node_modules` (yarn 4 PnP) and leave `arkor dev` / - // `arkor train` broken: the install becomes worse than + // `arkor start` broken: the install becomes worse than // useless. Skip and surface the manual-retry hint instead, so // the user fixes the config first and retries. ui.log.info( diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts index 8fceaac8..97ddfa82 100644 --- a/packages/arkor/src/cli/main.ts +++ b/packages/arkor/src/cli/main.ts @@ -268,7 +268,10 @@ export async function main(argv: string[]): Promise { program .command("dev") .description("Launch Arkor Studio locally") - .option("-p, --port ", "Port to bind (default: 4000)", "4000") + // No "(default: 4000)" in the description: Commander appends its own + // `(default: "4000")` from the third argument, and spelling it twice made + // `arkor dev --help` print the default twice on one line. + .option("-p, --port ", "Port to bind", "4000") .option("--open", "Open the Studio URL in a browser after starting") .option( "--agent", diff --git a/packages/arkor/src/studio/server.test.ts b/packages/arkor/src/studio/server.test.ts index e1534ce3..4690a1fa 100644 --- a/packages/arkor/src/studio/server.test.ts +++ b/packages/arkor/src/studio/server.test.ts @@ -293,6 +293,24 @@ describe("Studio server", () => { expect(res.status).toBe(403); }); + it("does not exempt NON-GET methods on /api/status from the token check", async () => { + // The exemption is scoped to `method === "GET" && path === "/api/status"`. + // The sibling test above pins only the PATH half; without this one the + // method half is silently removable (dropping `c.req.method === "GET"` + // leaves the whole suite green), which would hand an unauthenticated + // loopback peer any future non-GET handler mounted on this path. + const app = build(); + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + const res = await app.request("/api/status", { + method, + headers: { host: "127.0.0.1:4000" }, + }); + // 403 from the token middleware, never 404/405 from the router: the + // request must be rejected BEFORE routing. + expect({ method, status: res.status }).toEqual({ method, status: 403 }); + } + }); + it("returns safe metadata and the endpoint list", async () => { // No credentials are written and `autoAnonymous` is false: if the // handler ever touched the credential path, `getCredentials` would diff --git a/packages/arkor/src/studio/server.ts b/packages/arkor/src/studio/server.ts index 2843574c..453ff7eb 100644 --- a/packages/arkor/src/studio/server.ts +++ b/packages/arkor/src/studio/server.ts @@ -873,7 +873,7 @@ export function buildStudioApp(options: StudioServerOptions) { // above): anonymous credentials carry an `orgSlug` and we can // derive a `projectSlug` from the cwd basename, so we // bootstrap on demand. This mirrors `/api/inference/chat` and - // `arkor train`, which both call `ensureProjectState()` before + // `arkor start`, which both call `ensureProjectState()` before // issuing their first cloud call so a user can get something // done from a fresh `arkor dev` without first running // training. diff --git a/packages/cli-internal/src/claude-code.ts b/packages/cli-internal/src/claude-code.ts index 505e152b..1ce46f19 100644 --- a/packages/cli-internal/src/claude-code.ts +++ b/packages/cli-internal/src/claude-code.ts @@ -9,6 +9,14 @@ import { basename, resolve } from "node:path"; * produce a stderr message listing the suggested re-invocation rather than * running with hidden defaults. * + * `arkor dev` is a THIRD consumer with a different shape (see + * `formatClaudeCodeAgentModeMessage`): it has no curated flag list and no + * `--yes` escape hatch. Under `CLAUDECODE=1` a plain `arkor dev` always exits + * 1 and demands `--agent`, because the failure there is not "a prompt would + * hang" but "a long-running browser-oriented server is the wrong thing to + * hand an agent at all". Everything below about curated flags and `--yes` + * describes the scaffolders only. + * * "Curated" (not "every interactive prompt") because the runtime defaults * for a couple of decisions are well-understood enough that forcing them * would just add noise: diff --git a/packages/cli-internal/src/scaffold.ts b/packages/cli-internal/src/scaffold.ts index 59c73ff6..31891468 100644 --- a/packages/cli-internal/src/scaffold.ts +++ b/packages/cli-internal/src/scaffold.ts @@ -38,7 +38,7 @@ export interface ScaffoldOptions { * Plug'n'Play default. PnP omits `node_modules/` and requires a * runtime loader to resolve modules; the arkor runtime (esbuild → * `node ./.arkor/build/index.mjs`) doesn't load PnP, so a vanilla - * yarn-4 install would leave `arkor dev` / `arkor train` unable to + * yarn-4 install would leave `arkor dev` / `arkor start` unable to * find their dependencies. yarn 1.x ignores `.yarnrc.yml` (it reads * `.yarnrc`), so the file is harmless on the classic line. * @@ -452,7 +452,7 @@ interface YarnConfigPatchResult { * Set when the existing `.yarnrc.yml` had `nodeLinker:` pinned to * a value other than `node-modules` and we elected to keep it. * `scaffold()` turns this into a user-facing warning: the project - * will install but `arkor dev` / `arkor train` will fail until the + * will install but `arkor dev` / `arkor start` will fail until the * linker is changed. */ conflictingNodeLinker?: string; @@ -482,7 +482,7 @@ function buildYarnLinkerConflictWarning(existingValue: string): string { `Existing .yarnrc.yml pins \`nodeLinker: ${existingValue}\`. ` + `arkor's runtime can't resolve dependencies through anything ` + `other than \`nodeLinker: node-modules\`. \`arkor dev\` and ` + - `\`arkor train\` will fail until you change the value to ` + + `\`arkor start\` will fail until you change the value to ` + `\`node-modules\`.` ); } @@ -491,7 +491,7 @@ function buildYarnLinkerConflictWarning(existingValue: string): string { // existing project but no usable `nodeLinker: node-modules` setup. // arkor's runtime can't resolve dependencies through Plug'n'Play // (yarn-berry's default), so `yarn install` would land on PnP and -// `arkor dev` / `arkor train` would break. +// `arkor dev` / `arkor start` would break. // // Path-agnostic copy: emitted from BOTH the explicit `--use-yarn` // patch path (via `needsBerryCaveat`) AND the @@ -516,7 +516,7 @@ function buildYarnBerryCaveatAdvisory(): string { `yarn 2+ (yarn-berry) on an existing project, but ` + `\`nodeLinker: node-modules\` isn't set. arkor's runtime can't ` + `resolve dependencies through Plug'n'Play (yarn-berry's default), ` + - `so \`arkor dev\` and \`arkor train\` will fail until the linker ` + + `so \`arkor dev\` and \`arkor start\` will fail until the linker ` + `is set. Before running \`yarn install\`, add ` + `\`nodeLinker: node-modules\` to \`.yarnrc.yml\`.` ); diff --git a/packages/create-arkor/src/bin.ts b/packages/create-arkor/src/bin.ts index 40ce5508..f2d81344 100644 --- a/packages/create-arkor/src/bin.ts +++ b/packages/create-arkor/src/bin.ts @@ -543,7 +543,7 @@ export async function run(options: RunOptions): Promise { // tell the user to fix `.yarnrc.yml` before running `yarn // install`. Running install ourselves first would produce an // empty `node_modules` (yarn 4 PnP) and leave `arkor dev` / - // `arkor train` broken: the install becomes worse than + // `arkor start` broken: the install becomes worse than // useless. Skip and surface the manual-retry hint instead. // Round 40 follow-up #4 (Codex P2, PR #99): no shell-chain // separator works across all four supported shells (`&&` From 565b7b152fb17a70420d4bed1c662074bba9edea Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:41:19 -0700 Subject: [PATCH 17/19] fix(ENG-967): full-scope audit round 14 34 candidates, 21 survived adversarial verification. Two of them were errors I introduced in round 13. SECURITY (Windows-only, verified empirically rather than assumed) - server.ts: add a containment check to the token-FREE `GET *` static handler, which joined the request path onto assetsDir unchecked. I measured what actually reaches the handler on Hono 4.12 / Node 24 before claiming a vector: /../x -> c.req.path "/x" WHATWG URL parsing collapses it /%2e%2e/x -> c.req.path "/x" encoded dots are collapsed too /..%2fx -> c.req.path "/..%2fx" decodeURI keeps reserved %2F /..%5cx -> c.req.path "/..\\x" %5C decodes (not reserved) So this is NOT reachable on POSIX (a backslash is an ordinary filename char there); it escapes on Windows, where resolve() treats it as a separator. The test pins the contract everywhere and is mutation-detecting on Windows CI only, which the comment says outright rather than implying broader coverage. - dev.ts: my round-13 /proc/self/cwd carve-out was overstated. A prefix test cannot see through a symlink, so it is a cheap filter, not a boundary; the comment now says so and points at the real reason adoption is safe (the probe sends no token). Renamed to isUntrustedPeerPath and added UNC rejection, so a peer-supplied \\host\share cannot make us open an outbound SMB/NTLM connection from a blocking realpathSync outside the 1.5s abort budget. TESTS (both mutation-verified load-bearing) - The studio-token persistence test asserted neither the warn nor that persistence actually failed; it passed with the warn deleted. - /api/* token denial was only tested on single-segment paths, so narrowing the middleware to /api/:one left every nested route (job events, all deployment :id and key routes) unauthenticated with the suite green. DOCS (all verified against the code) - concepts/studio.mdx + README: the dev loop was documented backwards in BOTH directions. readManifestSummary calls runBuild, and RunTraining polls /api/manifest every 5s, so that poll rebuilds the very .arkor/build/index.mjs that Run training executes: edits go live in ~5s with no page refresh. The old "refresh the page or you run old code" advice and my round-13 "re-run arkor build" bullet were both wrong. - Telemetry never emits three events per invocation; a serving arkor dev emits only cli_command_started (longRunning suppresses completed). - "No credentials on file. Requesting an anonymous token." also prints when fetchCliConfig fails, not only on anon-only deployments. - studio/overview: the event log keeps 500 entries, not 50 (jobs.mdx and JobDetail.tsx both say 500). - Mirrored the round-13 search/filter sentence to the JA side; restored the section anchor the JA lifecycle page dropped. - Root README/README.ja.md now mention --agent and the CLAUDECODE=1 refusal; docs/cli/overview.mdx (EN+JA) now discloses that error_message is sent unscrubbed and can contain local paths. Also removed packages/arkor/src/studio/tmp-verify.test.ts, an untracked scratch file an audit agent left behind. Verified: typecheck 10/10, lint 7/7, 488 arkor + 226 cli-internal + 205 studio-app + 47 create-arkor unit, 16 e2e-studio, 109 e2e-cli, oxfmt + em-dash sweeps clean. --- README.ja.md | 2 +- README.md | 2 +- docs/cli/dev.mdx | 4 +- docs/cli/overview.mdx | 2 +- docs/concepts/studio.mdx | 2 +- docs/ja/cli/dev.mdx | 4 +- docs/ja/cli/overview.mdx | 2 +- docs/ja/concepts/lifecycle.mdx | 2 +- docs/ja/concepts/studio.mdx | 2 +- docs/ja/studio/overview.mdx | 4 +- docs/studio/overview.mdx | 2 +- packages/arkor/README.md | 18 ++++-- packages/arkor/src/cli/commands/dev.test.ts | 9 +++ packages/arkor/src/cli/commands/dev.ts | 54 ++++++++++------ packages/arkor/src/studio/server.test.ts | 68 +++++++++++++++++++++ packages/arkor/src/studio/server.ts | 27 +++++++- 16 files changed, 163 insertions(+), 41 deletions(-) diff --git a/README.ja.md b/README.ja.md index 560676c6..29bcf668 100644 --- a/README.ja.md +++ b/README.ja.md @@ -44,7 +44,7 @@ pnpm dev ``` **サインアップ不要:** -`arkor dev` は **Studio** と呼ばれるローカル Web UI を `http://localhost:4000` で開きます。初回起動時に使い捨ての匿名ワークスペースをプロビジョニングするので、すぐに実際のトレーニング実行を開始できます。 +`arkor dev` は **Studio** と呼ばれるローカル Web UI を `http://localhost:4000` で開きます。初回起動時に使い捨ての匿名ワークスペースをプロビジョニングするので、すぐに実際のトレーニング実行を開始できます。コーディングエージェントは `arkor dev --agent` を使ってください。同じ Studio をヘッドレスでサーブし、API トークンを `.arkor/agent/` 配下の JSON セッションファイルに書き出します。`CLAUDECODE=1` の下では素の `arkor dev` は起動を拒否し、このフラグを要求します。 後からアカウントに紐付けたい場合は `arkor login --oauth` を実行してください。 diff --git a/README.md b/README.md index 803457ae..8424f598 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,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. +`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. Coding agents should use `arkor dev --agent`, which serves the same Studio headlessly and writes an API token to a JSON session file under `.arkor/agent/`; under `CLAUDECODE=1` a plain `arkor dev` refuses to start and asks for the flag. Run `arkor login --oauth` later if you want to claim your work under an account. diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index f7d003e0..0889d91b 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -47,7 +47,7 @@ bun dev ### Launch sequence -1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; on anon-only deployments it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint because `arkor login --oauth` would fail there. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``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.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/cli/config` itself is unreachable, the deployment mode cannot be determined, so the same transport error is kept and re-raised **only if this launch goes on to serve** (serving needs credentials): `arkor dev` then exits 1 (restore connectivity and re-run). The failure is deliberately deferred rather than immediate so that the port-collision path below still works offline: connecting to an Arkor Studio that already serves this project needs no credentials of its own, so that case still prints `Arkor Studio already running on ` and exits 0. If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment) **and** the deployment advertises OAuth, it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in. Run `arkor login --oauth` and try again.``). The sign-in hint is conditional because on an anon-only deployment `arkor login --oauth` would itself fail; there the raw rejection surfaces instead (`Failed to acquire anonymous token (): `). +1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; otherwise it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint. "Otherwise" covers two cases, because the CLI treats an unknown deployment as anon-only: a genuine anon-only deployment (`/v1/auth/cli/config` advertised no OAuth), and a `/v1/auth/cli/config` call that failed outright, leaving OAuth availability unknown. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``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.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/cli/config` itself is unreachable, the deployment mode cannot be determined, so the same transport error is kept and re-raised **only if this launch goes on to serve** (serving needs credentials): `arkor dev` then exits 1 (restore connectivity and re-run). The failure is deliberately deferred rather than immediate so that the port-collision path below still works offline: connecting to an Arkor Studio that already serves this project needs no credentials of its own, so that case still prints `Arkor Studio already running on ` and exits 0. If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment) **and** the deployment advertises OAuth, it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in. Run `arkor login --oauth` and try again.``). The sign-in hint is conditional because on an anon-only deployment `arkor login --oauth` would itself fail; there the raw rejection surfaces instead (`Failed to acquire anonymous token (): `). Note that **every** one of these bootstrap failures is deferred the same way, not just the config-unreachable case: `arkor dev` captures whatever `ensureCredentialsForStudio` throws (the 4xx wrap, the raw rejection, and local `~/.arkor/credentials.json` write errors alike) and re-raises it only if this launch actually serves. On the port-collision connect path it is discarded, so a first run against an unreachable or sign-in-only deployment can still adopt a Studio that is already serving this project and exit 0. 2. **CSRF token.** A 32-byte token (base64url, ~43 chars) is generated for this launch. It is injected into `index.html` as `` so the same-origin SPA can read it. Cross-origin tabs cannot read the meta and are rejected by the `/api/*` middleware. @@ -130,7 +130,7 @@ The token file (`~/.arkor/studio-token`) is written only **after** the port bind | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.` | `/v1/auth/cli/config` already succeeded, but the follow-up `/v1/auth/anonymous` hit a transport error. The Studio server starts and will retry. | Bring connectivity back; the SPA recovers on its next `/api/credentials` poll without restarting `arkor dev`. | | `TypeError: fetch failed` (or an equivalent transport error, exit `1`) | `/v1/auth/cli/config` itself was unreachable, so the deployment mode could not be determined. The error is deferred and re-raised only if this launch actually serves; if the port is already held by an Arkor Studio for this project, the connect path wins instead and exits `0`. | 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 OAuth advertised in `/v1/auth/cli/config`). The CLI omits the `arkor login --oauth` hint because that command would fail there. | Nothing required. | +| `No credentials on file. Requesting an anonymous token.` | Same as above when OAuth is not known to be available: either an anon-only deployment (no OAuth advertised in `/v1/auth/cli/config`) or a `/v1/auth/cli/config` call that failed, which is treated the same way. The CLI omits the `arkor login --oauth` hint because it cannot promise that command would work. | 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. | | ``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/cli/overview.mdx b/docs/cli/overview.mdx index d6ff5269..a8455ea2 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -22,7 +22,7 @@ The `arkor` CLI is the command surface around an Arkor project. The [scaffolder] - **`--help` / `-h`** is available on every command; `arkor --help` prints the flag list and usage. - **`arkor --version` / `-V`** prints the installed SDK version (the same `SDK_VERSION` the package exports). - **Exit codes.** Successful commands exit 0. Most commands exit non-zero on failure (uncaught throws, build errors, etc.). `arkor whoami` is a deliberate exception: it prints the failure to stdout but still exits 0 for ordinary cloud-api errors (`Failed to fetch /v1/me ()`), and only sets `process.exitCode = 1` when the cloud-api responds with 426 (SDK too old). Wrapper scripts that need to detect a `whoami` failure should grep stdout, not rely on the exit code. -- **Telemetry.** Every command runs through a small instrumentation wrapper so usage events can be sent to PostHog. See [Environment variables](#environment-variables) below for the full opt-out / debug knobs. +- **Telemetry.** Every command runs through a small instrumentation wrapper so usage events can be sent to PostHog. A failing command also reports `error_name` and the first 200 characters of the error message, sent **verbatim and unscrubbed**, so it can contain local absolute paths (your project directory, `~/.arkor/...`). See [Environment variables](#environment-variables) below for the full opt-out / debug knobs. - **Deprecation notices.** When the cloud-api response carries `Deprecation: true`, the CLI prints a one-line warning at the end of the run with the suggested upgrade command. The `Warning` and `Sunset` headers (when present) format the message; they do not trigger the warning on their own. ## Environment variables diff --git a/docs/concepts/studio.mdx b/docs/concepts/studio.mdx index b20c4ea4..88c2b43a 100644 --- a/docs/concepts/studio.mdx +++ b/docs/concepts/studio.mdx @@ -14,7 +14,7 @@ Four jobs: 3. **Try a finished model.** A Playground page lets you pick the base model or the final adapter from any completed job and chat with it. The Playground does not load intermediate checkpoints; for mid-run inference, use [`onCheckpoint`](/concepts/lifecycle) callbacks in your trainer. 4. **Publish a model behind a `*.arkor.app` URL.** An Endpoints page creates a per-deployment subdomain that serves OpenAI-compatible chat completions for a chosen adapter or base model, plus the API keys that authenticate calls to it. The same actions are available programmatically via [`CloudApiClient`](/sdk/deployments). Studio is the interactive surface; the SDK is the lower-level one. -A note on the dev loop: Studio's `/api/manifest` endpoint rebuilds and re-imports your trainer on every request (with a cache-bust query, see `packages/arkor/src/studio/manifest.ts`), but the UI only fetches it when the Run training page mounts. So if you edit `src/arkor/` and stay on the same Run training page, the next click reuses the existing `.arkor/build/index.mjs` and runs your old code. Refresh the page (or run `arkor build` from the terminal) between edits and clicks to pick up the new code reliably. +A note on the dev loop: Studio's `/api/manifest` endpoint rebuilds and re-imports your trainer on every request (content-addressed, so an unedited reload reuses Node's ESM cache; see `packages/arkor/src/studio/manifest.ts`), and the Run training page polls it every 5 seconds. Because that rebuild writes the same `.arkor/build/index.mjs` that **Run training** executes, an edit under `src/arkor/` goes live within about 5 seconds with no page refresh. Note what this is not: it is a poll driven by the open page, not a filesystem watcher, so nothing rebuilds while Studio is closed, and `arkor start` from the terminal runs whatever artifact is on disk. ## Where Studio runs diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 7540f15c..438ba229 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -47,7 +47,7 @@ bun dev ### 起動シーケンス -1. **認証情報の初期化。** `~/.arkor/credentials.json` が無いとき、CLI は **常に匿名セッションの初期化を試みます**。`/v1/auth/cli/config` を呼び、続いて `/v1/auth/anonymous` から匿名トークンを要求します。初期化前のメッセージはデプロイが OAuth をアドバタイズしているかで分岐します。OAuth が設定されている場合は ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションを初期化します。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください)を出して、好きなタイミングで本物のアカウントへアップグレードできることを案内します。匿名専用デプロイでは代わりに `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、`arkor login --oauth` がそのデプロイでは失敗するため OAuth ヒントは省略されます。いずれの場合も OAuth フローを自動で起動することはありません。トークンが届くと `arkor dev` は ``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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`)を出します。デプロイが OAuth をアドバタイズしている場合に限り、成功メッセージと並んで 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` でサインインしてください)が発行され、アップグレード経路が発行時点で見えます。匿名専用デプロイでは `arkor login --oauth` を案内すると失敗するコマンドへユーザーを誘導してしまうので、warn は意図的に抑制されます。一過性のトランスポート障害(`fetch failed`)の扱いはタイミングで分かれます。`/v1/auth/cli/config` が成功してデプロイモードが特定済みのあと、`/v1/auth/anonymous` で同様の障害が出た場合のみ警告して続行し、Studio サーバーは初回の `/api/credentials` ヒットで再試行します。`/v1/auth/cli/config` 自体に到達できなかった場合はデプロイモードが特定できないため、そのトランスポートエラーは保持され、**この起動が実際にサーブへ進んだときにのみ**再スローされます(サーブには認証情報が必要)。その場合 `arkor dev` は exit 1 で終了するので、接続を回復してから再実行してください。即座に失敗させず意図的に遅延させているのは、下記のポート衝突の経路をオフラインでも機能させるためです。同じプロジェクトをサーブしている既存の Arkor Studio への接続はそれ自体に認証情報を必要としないので、そのケースでは引き続き `Arkor Studio already running on ` を出して exit 0 で終了します。`/v1/auth/anonymous` が 4xx で拒否され、**かつ**そのデプロイが OAuth をアドバタイズしている場合は、HTTP ステータスを含むエラーで `arkor login --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` を実行して再試行してください)。サインイン案内が条件付きなのは、匿名専用デプロイでは `arkor login --oauth` 自体が失敗するためで、その場合は生の拒否エラー(`Failed to acquire anonymous token (): `)がそのまま出ます。 +1. **認証情報の初期化。** `~/.arkor/credentials.json` が無いとき、CLI は **常に匿名セッションの初期化を試みます**。`/v1/auth/cli/config` を呼び、続いて `/v1/auth/anonymous` から匿名トークンを要求します。初期化前のメッセージはデプロイが OAuth をアドバタイズしているかで分岐します。OAuth が設定されている場合は ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションを初期化します。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください)を出して、好きなタイミングで本物のアカウントへアップグレードできることを案内します。それ以外の場合は代わりに `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、OAuth ヒントは省略されます。「それ以外」には 2 つのケースが含まれます。CLI は OAuth の可否が不明なデプロイを匿名専用として扱うためで、実際に匿名専用のデプロイ(`/v1/auth/cli/config` が OAuth をアドバタイズしなかった)と、`/v1/auth/cli/config` の呼び出し自体が失敗して可否を判断できなかった場合の両方です。いずれの場合も OAuth フローを自動で起動することはありません。トークンが届くと `arkor dev` は ``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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`)を出します。デプロイが OAuth をアドバタイズしている場合に限り、成功メッセージと並んで 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` でサインインしてください)が発行され、アップグレード経路が発行時点で見えます。匿名専用デプロイでは `arkor login --oauth` を案内すると失敗するコマンドへユーザーを誘導してしまうので、warn は意図的に抑制されます。一過性のトランスポート障害(`fetch failed`)の扱いはタイミングで分かれます。`/v1/auth/cli/config` が成功してデプロイモードが特定済みのあと、`/v1/auth/anonymous` で同様の障害が出た場合のみ警告して続行し、Studio サーバーは初回の `/api/credentials` ヒットで再試行します。`/v1/auth/cli/config` 自体に到達できなかった場合はデプロイモードが特定できないため、そのトランスポートエラーは保持され、**この起動が実際にサーブへ進んだときにのみ**再スローされます(サーブには認証情報が必要)。その場合 `arkor dev` は exit 1 で終了するので、接続を回復してから再実行してください。即座に失敗させず意図的に遅延させているのは、下記のポート衝突の経路をオフラインでも機能させるためです。同じプロジェクトをサーブしている既存の Arkor Studio への接続はそれ自体に認証情報を必要としないので、そのケースでは引き続き `Arkor Studio already running on ` を出して exit 0 で終了します。`/v1/auth/anonymous` が 4xx で拒否され、**かつ**そのデプロイが OAuth をアドバタイズしている場合は、HTTP ステータスを含むエラーで `arkor login --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` を実行して再試行してください)。サインイン案内が条件付きなのは、匿名専用デプロイでは `arkor login --oauth` 自体が失敗するためで、その場合は生の拒否エラー(`Failed to acquire anonymous token (): `)がそのまま出ます。 なお、これらのブートストラップ失敗は config 到達不可のケースだけでなく**すべて**同じ形で遅延されます。`arkor dev` は `ensureCredentialsForStudio` が投げたもの(4xx のラップ、生の拒否、ローカルの `~/.arkor/credentials.json` 書き込みエラーのいずれも)を捕捉し、この起動が実際にサーブしたときにのみ再スローします。ポート衝突の接続経路では破棄されるため、到達不可あるいはサインイン必須のデプロイに対する初回起動でも、同じプロジェクトを既にサーブしている Studio に接続して exit 0 で終了できます。 2. **CSRF トークン。** この起動用に 32 バイトのトークン(base64url、約 43 文字)を生成。同一オリジンの SPA が読めるよう `` として `index.html` にインジェクトされます。クロスオリジンタブはこの meta を読めず、`/api/*` のミドルウェアに拒否されます。 @@ -130,7 +130,7 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.`(`` に到達できませんでした(fetch 失敗)。Studio は起動を続け、初回の `/api/credentials` 受信時に再試行します) | `/v1/auth/cli/config` は成功したが、続く `/v1/auth/anonymous` がトランスポート障害で失敗。Studio サーバーは起動して再試行する。 | 接続を回復させれば `arkor dev` を再起動せずに次の `/api/credentials` ポーリングで SPA が回復。 | | `TypeError: fetch failed`(または同等のトランスポートエラー、exit `1`) | `/v1/auth/cli/config` 自体に届かなかったため、デプロイモードが特定できない。このエラーは遅延され、この起動が実際にサーブしたときにのみ再スローされる。ポートを同一プロジェクトの Arkor Studio が既に握っている場合は接続経路が優先され、exit `0` になる。 | 接続を回復してから `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` で OAuth がアドバタイズされていない)。`arkor login --oauth` は失敗するので OAuth ヒントは省かれる。 | 何もしなくてよい。 | +| `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します) | 同上だが OAuth が利用可能と判明していない場合。匿名専用デプロイ(`/v1/auth/cli/config` で OAuth がアドバタイズされていない)と、`/v1/auth/cli/config` の呼び出しが失敗したケースの両方が該当し、同じ扱いになる。動作を保証できないため 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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`) | 匿名初期化の完了後の情報行。クラウド側の識別子と、それを保持しているファイルの場所を明示する。 | 何もしなくてよい。別の環境から同じ匿名 ID を使いたいなら認証情報ファイルをバックアップ。 | | ``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 に残り、現状マイグレーション手段はない。 | | ``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/docs/ja/cli/overview.mdx b/docs/ja/cli/overview.mdx index 5b63cc9e..61c06ff5 100644 --- a/docs/ja/cli/overview.mdx +++ b/docs/ja/cli/overview.mdx @@ -22,7 +22,7 @@ description: "arkor CLI: 全コマンドリファレンス。" - **`--help` / `-h`** はすべてのコマンドで使えます。`arkor --help` でフラグ一覧と使い方が表示されます。 - **`arkor --version` / `-V`** はインストール済み SDK バージョン(パッケージがエクスポートしている `SDK_VERSION` と同じ)を表示します。 - **終了コード。** 成功は 0。ほとんどのコマンドは失敗時(未捕捉の throw、ビルドエラーなど)に非ゼロで終わります。`arkor whoami` は意図的な例外で、失敗を stdout に出しても通常のクラウド API エラー(`Failed to fetch /v1/me ()`、`/v1/me` の取得に失敗しました)では 0 で終了し、クラウド API が 426(SDK が古すぎる)を返したときだけ `process.exitCode = 1` を立てます。`whoami` の失敗を検知したいラッパースクリプトは終了コードではなく stdout を grep してください。 -- **テレメトリー。** すべてのコマンドは小さな計測ラッパー越しに走るので、利用イベントを PostHog に送れます。完全なオプトアウト/デバッグの仕組みは下記の [環境変数](#環境変数) を参照。 +- **テレメトリー。** すべてのコマンドは小さな計測ラッパー越しに走るので、利用イベントを PostHog に送れます。失敗時は `error_name` とエラーメッセージの先頭 200 文字も送られますが、これは**そのまま(スクラブ無しで)**送信されるため、ローカルの絶対パス(プロジェクトディレクトリーや `~/.arkor/...` など)を含み得ます。完全なオプトアウト/デバッグの仕組みは下記の [環境変数](#環境変数) を参照。 - **非推奨通知。** クラウド API のレスポンスに `Deprecation: true` が付いていると、CLI は実行終了時に推奨アップグレードコマンド付きの 1 行警告を出します。`Warning` と `Sunset` ヘッダー(あれば)はメッセージのフォーマットに使われ、それ単独で警告をトリガーすることはありません。 ## 環境変数 diff --git a/docs/ja/concepts/lifecycle.mdx b/docs/ja/concepts/lifecycle.mdx index 9fd77b21..9cc42757 100644 --- a/docs/ja/concepts/lifecycle.mdx +++ b/docs/ja/concepts/lifecycle.mdx @@ -116,7 +116,7 @@ onFailed: ({ job, error }) => { }, ``` -`onFailed` はバックエンドからの失敗報告専用です。コールバック内で throw した例外は `onFailed` を経由せず、あなたのエラーで `wait()` を即座に reject します。throw されたコールバックは SSE のトランスポート失敗としては扱われないため、再接続ループに送られたりリトライされたりしません。再接続ループが扱うのは一過性のストリーム障害(接続断、オープン時の `404`/`408`/`429`/`5xx` のようなリトライ可能な HTTP エラー)だけです。`401`/`403`/`410`/`426` のような恒久的ステータスは即座に reject します([トレーナー制御の再接続](/ja/sdk/trainer-control) 参照)。これは意図的な挙動です。失敗したイベントの `Last-Event-ID` は既に進んでいるため、リトライするとそのイベントをスキップし、あなたのエラーを握りつぶし、(終端の `training.completed` イベントの場合は)空の artifacts で `wait()` を解決してしまうからです。致命的でない処理が必要なら、コールバック内で catch して、外に伝播する前にどうするか(ログ、Abort、永続化)を決めてください。 +`onFailed` はバックエンドからの失敗報告専用です。コールバック内で throw した例外は `onFailed` を経由せず、あなたのエラーで `wait()` を即座に reject します。throw されたコールバックは SSE のトランスポート失敗としては扱われないため、再接続ループに送られたりリトライされたりしません。再接続ループが扱うのは一過性のストリーム障害(接続断、オープン時の `404`/`408`/`429`/`5xx` のようなリトライ可能な HTTP エラー)だけです。`401`/`403`/`410`/`426` のような恒久的ステータスは即座に reject します([トレーナー制御 § 再接続](/ja/sdk/trainer-control#再接続) 参照)。これは意図的な挙動です。失敗したイベントの `Last-Event-ID` は既に進んでいるため、リトライするとそのイベントをスキップし、あなたのエラーを握りつぶし、(終端の `training.completed` イベントの場合は)空の artifacts で `wait()` を解決してしまうからです。致命的でない処理が必要なら、コールバック内で catch して、外に伝播する前にどうするか(ログ、Abort、永続化)を決めてください。 ## 完成例 diff --git a/docs/ja/concepts/studio.mdx b/docs/ja/concepts/studio.mdx index 4c499e66..fe771e8e 100644 --- a/docs/ja/concepts/studio.mdx +++ b/docs/ja/concepts/studio.mdx @@ -14,7 +14,7 @@ Studio は `arkor dev` 実行時に立ち上がるローカル Web UI です。 3. **完成モデルを試す。** Playground ページでベースモデルや任意の完了済みジョブの最終アダプターを選んでチャットできます。中間チェックポイントは Playground からはロードしません。学習中の推論には [`onCheckpoint`](/ja/concepts/lifecycle) コールバックをトレーナーで使ってください。 4. **`*.arkor.app` URL でモデルを公開する。** Endpoints ページで OpenAI 互換 chat completions を提供する deployment 専用サブドメインを作成し、その API キーを発行・取り消しできます。同じ操作は [`CloudApiClient`](/ja/sdk/deployments) からプログラマティックにも可能で、Studio が対話的なインターフェイス、SDK が下位レイヤーという位置付けです。 -dev ループのメモ: Studio の `/api/manifest` エンドポイントはリクエストごとにトレーナーをリビルド・再 import しますが(キャッシュバストクエリ付き、`packages/arkor/src/studio/manifest.ts` を参照)、UI が fetch するのは Run training ページがマウントされたときだけです。`src/arkor/` を編集して同じ Run training ページに留まり続けると、次のクリックは既存の `.arkor/build/index.mjs` を再利用して古いコードで走ります。確実に新しいコードを取り込むには、編集とクリックの間にページをリロード(あるいはターミナルから `arkor build`)してください。 +dev ループのメモ: Studio の `/api/manifest` エンドポイントはリクエストごとにトレーナーをリビルド・再 import し(コンテンツアドレス方式なので、編集が無ければ Node の ESM キャッシュを再利用します。`packages/arkor/src/studio/manifest.ts` を参照)、Run training ページはこれを 5 秒ごとにポーリングします。このリビルドは **Run training** が実行するのと同じ `.arkor/build/index.mjs` に書き込むため、`src/arkor/` の編集はページをリロードしなくても約 5 秒で反映されます。ただしこれはファイルシステムのウォッチャーではなく、開いているページが駆動するポーリングです。Studio を閉じている間はリビルドされませんし、ターミナルからの `arkor start` はディスク上にある成果物をそのまま実行します。 ## Studio が動く場所 diff --git a/docs/ja/studio/overview.mdx b/docs/ja/studio/overview.mdx index a7fd7316..4a45dd2f 100644 --- a/docs/ja/studio/overview.mdx +++ b/docs/ja/studio/overview.mdx @@ -48,7 +48,7 @@ Arkor マネージドバックエンド | --- | --- | | **Run training** | Overview ページ (`#/`) のボタン。`POST /api/train` を呼ぶと `arkor start` がサブプロセスとして起動し、stdout/stderr が生のテキストとしてページにストリーミングされます。 | | **Jobs リスト** | `#/jobs`。5 秒間隔の自動ポーリングに加えて手動 Refresh ボタン、名前 / ID での検索、status フィルター(`All` / `Running` / `Completed` / `Queued` / `Failed` / `Cancelled`)を備えます。列: Status、Name(詳細へリンク)、Created、ID。 | -| **Job 詳細** | `#/jobs/:id`。ライブステータスバッジ、SVG の Loss チャート、生イベントログ(直近 50 行)。Server-Sent Events 経由で `/api/jobs/:id/events` からストリーム。 | +| **Job 詳細** | `#/jobs/:id`。ライブステータスバッジ、SVG の Loss チャート、生イベントログ(直近 500 件)。Server-Sent Events 経由で `/api/jobs/:id/events` からストリーム。 | | **Playground** | `#/playground` のチャット UI。2 モード: 1 つの対応ベースモデル、または任意の完了済みジョブの最終アダプター。 | | **Endpoints** | `#/endpoints` の `*.arkor.app` URL 管理。Deployment の作成、enabled / auth mode の切替、API キーの発行と revoke。SDK の [`CloudApiClient`](/ja/sdk/deployments) と等価で、UI で行えるすべての操作にプログラマティックな呼び出しが対応。 | @@ -60,7 +60,7 @@ Arkor マネージドバックエンド | --- | --- | | 動作中ジョブのキャンセル / 一時停止 | 自前コードから [`trainer.cancel()`](/ja/sdk/trainer-control#cancel) を呼ぶ。 | | Playground での中間チェックポイントアダプター選択 | トレーナー内で [`onCheckpoint({ infer })`](/ja/sdk/callbacks) を使う。SDK の `infer` は直前に保存されたチェックポイントに紐づく。 | -| Jobs リストのページング | 現状のポーリング一覧ビューでは対象外。サーバーが完全な一覧を返す前提。 | +| Jobs リストのページング | 現状のポーリング一覧ビューでは対象外。サーバーが完全な一覧を返す前提。検索と状態フィルターは**実装済み**です(その完全な一覧に対するクライアントサイド処理)。 | | プロジェクトごとの複数トレーナー | `/api/manifest` が単一の `trainer` を返す。SDK の [`createArkor`](/ja/sdk/create-arkor) も 1 つしか受け付けない。 | | Playground から `temperature` / `topP` / `maxTokens` を調整 | HTTP API([`InferArgs`](/ja/sdk/infer))はこれらを受け付ける。SDK から `infer` を呼ぶ際に渡してください。 | | Loss チャートのズーム、エクスポート、ツールチップ | チャートは静的な SVG パス。 | diff --git a/docs/studio/overview.mdx b/docs/studio/overview.mdx index 1e8af7c4..f4c7cbe5 100644 --- a/docs/studio/overview.mdx +++ b/docs/studio/overview.mdx @@ -48,7 +48,7 @@ The token is rotated on every `arkor dev` launch, so a stale tab from a previous | --- | --- | | **Run training** | A button on the Overview page (`#/`). It calls `POST /api/train`, which spawns `arkor start` and streams stdout/stderr back into the page as raw text. | | **Jobs list** | `#/jobs`. Auto-polled every 5 seconds with a manual Refresh button, search by name / ID, and a status filter (`All` / `Running` / `Completed` / `Queued` / `Failed` / `Cancelled`). Columns: Status, Name (links to detail), Created, ID. | -| **Job detail** | `#/jobs/:id`. Live status badge, an SVG loss chart, and a raw event log (last 50 lines). Streams from `/api/jobs/:id/events` via Server-Sent Events. | +| **Job detail** | `#/jobs/:id`. Live status badge, an SVG loss chart, and a raw event log (last 500 entries). Streams from `/api/jobs/:id/events` via Server-Sent Events. | | **Playground** | Chat UI on `#/playground`. Two modes: a single supported base model, or the final adapter from any completed job. | | **Endpoints** | `*.arkor.app` URL management on `#/endpoints`. Create deployments, toggle enabled / auth mode, issue and revoke API keys. The UI mirrors the SDK's [`CloudApiClient`](/sdk/deployments); every action has a programmatic equivalent. | diff --git a/packages/arkor/README.md b/packages/arkor/README.md index f33a3f5d..2f017606 100644 --- a/packages/arkor/README.md +++ b/packages/arkor/README.md @@ -100,10 +100,12 @@ project's installed copy. Relative imports get inlined. `arkor dev` boots a Hono server on `127.0.0.1:4000` and serves a Vite + React SPA from the same origin. Two roles: -- **Runs your TypeScript from source**: a run started with an explicit entry - file re-bundles it first, so you pick up edits without restarting `arkor - dev`. There is no file watcher, and a run started without an entry reuses - the existing `.arkor/build/index.mjs`. +- **Picks up source edits without a restart**: the Overview page polls + `/api/manifest` every 5s, and each call re-runs the esbuild bundle into + `.arkor/build/index.mjs` and re-imports it. Run training spawns `arkor start` + against that same artifact, so a trainer edit is live within ~5s (and a newly + added trainer lights up the button) with no page reload. This is a poll on + the open page, not a filesystem watcher: with Studio closed, nothing rebuilds. - **GUI operations**: running training, inspecting jobs, mid-training inference in a Playground. @@ -162,10 +164,14 @@ training from a custom Node script). `arkor start` uses it under the hood. ## Telemetry The CLI sends anonymous usage events to PostHog so we can see which commands -are being run and where they fail. Three events are emitted per invocation: +are being run and where they fail. An invocation emits `cli_command_started` +and then at most one terminal event, never all three: - `cli_command_started` -- `cli_command_completed` (includes `duration_ms`) +- `cli_command_completed` (includes `duration_ms`) on success. Suppressed for + long-running commands: a serving `arkor dev` emits only + `cli_command_started`, since it has no meaningful completion. (`arkor dev` + that instead connects to an already-running Studio and exits does emit it.) - `cli_command_failed` (includes `duration_ms`, `error_name`, and the first 200 chars of `error_message`). That message is sent verbatim and is not scrubbed, so it can contain local absolute paths (for example the project diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 6559d11e..565dd4df 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -708,12 +708,20 @@ describe("runDev", () => { const stdoutSpy = vi .spyOn(process.stdout, "write") .mockImplementation((() => true) as typeof process.stdout.write); + const warnSpy = vi.spyOn(clack.log, "warn").mockImplementation(() => {}); try { const sigtermBefore = process.listeners("SIGTERM").length; await expect(runDev({ port: 4203 })).resolves.toEqual({ adopted: false, }); expect(serve).toHaveBeenCalledTimes(1); + // Assert the failure actually happened AND was surfaced. Without these + // the test passes even if the warn is deleted, and even if the injected + // EACCES never fires (leaving the whole branch unexercised). + expect(existsSync(studioTokenPath())).toBe(false); + expect(warnSpy.mock.calls.map((c) => c[0]).join("\n")).toMatch( + /Could not write .*studio-token.*Vite SPA dev workflow/s, + ); // Regression (ENG-933 self-review): shutdown handlers must be installed // even when token persistence fails, so a SIGTERM (`docker stop`) still // routes through `process.exit` and fires 'exit' to reap any train @@ -733,6 +741,7 @@ describe("runDev", () => { } } finally { stdoutSpy.mockRestore(); + warnSpy.mockRestore(); // Restore writable for afterEach rmSync. chmodSync(join(fakeHome, ".arkor"), 0o755); } diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index 1e31734c..d84c9775 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -323,14 +323,33 @@ async function readCapped( } /** - * True for paths inside a kernel namespace whose symlinks resolve relative to - * the process doing the resolving (`/proc/self/cwd`, `/proc//cwd`, - * `/proc/thread-self/...`, `/dev/fd/`). Such a path is absolute but its - * `realpathSync` result describes US, not the peer that reported it, so it can - * never be trusted as a peer-supplied identity. Linux-only in practice; the - * check is a cheap no-op elsewhere. + * True for peer-reported paths we refuse to hand to `realpathSync` at all. + * + * Two families: + * + * - **Process-relative kernel namespaces** (`/proc/self/cwd`, + * `/proc//cwd`, `/proc/thread-self/...`, `/dev/fd/`). These are + * absolute, so they clear the `isAbsolute` check, but they resolve relative + * to the process doing the resolving: us. Left alone, a peer reporting + * `/proc/self/cwd` resolves to OUR project root and passes the same-project + * comparison without knowing anything about the project. + * - **UNC / network paths** (`\\host\share`, `//host/share`). Resolving one on + * Windows makes an outbound SMB connection, which both blocks the event loop + * outside the probe's 1.5s abort budget and can leak an NTLM handshake to a + * host the peer chose. + * + * This is a cheap filter on the obvious cases, NOT a security boundary: a + * prefix test cannot see through a symlink (an ordinary-looking path pointing + * at `/proc/self/cwd` still resolves to us). It cannot be made airtight, + * because any resolution we perform happens in our own namespace on a string + * the peer controls. That is acceptable because adoption is unauthenticated by + * design and its worst case is a nuisance redirect, never code execution: the + * probe sends no token, so a peer that wins this comparison still cannot obtain + * the CSRF token or reach `POST /api/train`. The same-project comparison exists + * to stop a DIFFERENT project's Studio (the common, benign collision) from + * being adopted, and for that non-adversarial job it is exact. */ -function isProcessRelativePath(p: string): boolean { +function isUntrustedPeerPath(p: string): boolean { // Normalise separators so `/proc/self/../self/cwd` and Windows-style input // cannot sneak past a naive prefix test. const norm = normalize(p).replaceAll("\\", "/"); @@ -338,7 +357,8 @@ function isProcessRelativePath(p: string): boolean { norm === "/proc" || norm.startsWith("/proc/") || norm === "/dev/fd" || - norm.startsWith("/dev/fd/") + norm.startsWith("/dev/fd/") || + norm.startsWith("//") ); } @@ -402,18 +422,12 @@ async function probeExistingStudio( // `realpathSync` would otherwise resolve against THIS process's cwd // (= projectRoot) and so spuriously pass the same-project check. if (typeof b.cwd !== "string" || !isAbsolute(b.cwd)) return false; - // Reject process-relative magic symlinks before resolving. `/proc/self/cwd` - // (also `/proc//cwd`, `/proc/thread-self/cwd`, `/dev/fd/*`) is - // absolute, so it clears the check above, but `realpathSync` resolves it - // against the RESOLVING process: this CLI. A hostile occupant could - // therefore report `cwd: "/proc/self/cwd"` and have it resolve to our own - // projectRoot, passing the same-project check without ever knowing where - // the project lives. A real Studio always reports a concrete `trainCwd`, - // never one of these, so the carve-out costs nothing. (Adoption is - // unauthenticated by design, so the worst case here was still a nuisance - // redirect rather than code execution, but the documented invariant is - // "same project or no adoption" and this keeps it true.) - if (isProcessRelativePath(b.cwd)) return false; + // Filter the peer-reported path shapes we will not resolve at all (magic + // process-relative namespaces, UNC). See `isUntrustedPeerPath` for why this + // is a cheap filter rather than a boundary: a real Studio never reports one + // of these, so it costs nothing, but it cannot see through a symlink and is + // not what makes adoption safe (the probe sending no token is). + if (isUntrustedPeerPath(b.cwd)) return false; // Compare realpaths so a symlinked project root (e.g. macOS // `/var` -> `/private/var`) still matches the same project. The remote // value needs resolving too: the occupant reports its raw `trainCwd`, diff --git a/packages/arkor/src/studio/server.test.ts b/packages/arkor/src/studio/server.test.ts index 4690a1fa..8efe4c44 100644 --- a/packages/arkor/src/studio/server.test.ts +++ b/packages/arkor/src/studio/server.test.ts @@ -156,6 +156,53 @@ describe("Studio server", () => { expect(await res.text()).toBe("console.log('studio')"); }); + it("refuses to serve files outside assetsDir (path traversal on the token-free GET *)", async () => { + // `GET *` is the one token-FREE route (the browser must be able to load + // the SPA before it can read the token out of it), so an unconstrained + // join would be an arbitrary local file read for anything that reaches the + // loopback port. Plant a secret one level above assetsDir and try to walk + // out to it. + // + // HONEST SCOPE: on POSIX this is a contract test, NOT a mutation-detecting + // one, and deleting the containment check in server.ts leaves it green. + // That is because nothing reaches the handler with a real `..` segment + // here (measured on Hono 4.12 / Node 24): + // + // /../x -> c.req.path "/x" URL parsing collapses it + // /%2e%2e/x -> c.req.path "/x" encoded dots are collapsed too + // /..%2fx -> c.req.path "/..%2fx" decodeURI keeps reserved %2F + // /..%5cx -> c.req.path "/..\x" %5C decodes, but on POSIX a + // backslash is an ordinary filename + // character, so it still cannot escape + // + // The guard earns its keep on WINDOWS, where `resolve` treats that decoded + // backslash as a separator and `/..%5cx` does escape. We cannot exercise + // win32 path semantics from a POSIX runner, so this test pins the + // observable contract on every platform and becomes mutation-detecting on + // Windows CI. + const secret = join(assetsDir, "..", "arkor-traversal-secret.txt"); + writeFileSync(secret, "TOP-SECRET"); + try { + const app = build(); + for (const path of [ + "/%2e%2e/arkor-traversal-secret.txt", + "/assets/%2e%2e/%2e%2e/arkor-traversal-secret.txt", + "/..%5carkor-traversal-secret.txt", + ]) { + const res = await app.request(path, { + headers: { host: "127.0.0.1:4000" }, + }); + const body = await res.text(); + expect({ path, leaked: body.includes("TOP-SECRET") }).toEqual({ + path, + leaked: false, + }); + } + } finally { + rmSync(secret, { force: true }); + } + }); + it("falls back to index.html for unknown extensionless paths (SPA hash router)", async () => { // Lines 404-407: the React app uses a router that produces paths like // /jobs/:id which aren't on disk. The handler must serve index.html so @@ -218,6 +265,27 @@ describe("Studio server", () => { expect(res.status).toBe(403); }); + it("rejects NESTED /api/* routes without a studio token", async () => { + // The deny-direction tests above all use single-segment paths + // (/api/credentials). Hono's `/api/*` matches multi-segment paths too, but + // nothing pinned that: narrowing the middleware pattern (e.g. to + // `/api/:route`) would leave every nested route unauthenticated with the + // rest of the suite still green. These are the RCE-adjacent and + // secret-bearing ones, so cover the shapes explicitly. + const app = build(); + for (const path of [ + "/api/jobs/some-id/events", + "/api/deployments/some-id", + "/api/deployments/some-id/keys", + "/api/deployments/some-id/keys/some-key", + ]) { + const res = await app.request(path, { + headers: { host: "127.0.0.1:4000" }, + }); + expect({ path, status: res.status }).toEqual({ path, status: 403 }); + } + }); + it("rejects /api/* with a wrong studio token", async () => { const app = build(); const res = await app.request("/api/credentials", { diff --git a/packages/arkor/src/studio/server.ts b/packages/arkor/src/studio/server.ts index 453ff7eb..1fa7066d 100644 --- a/packages/arkor/src/studio/server.ts +++ b/packages/arkor/src/studio/server.ts @@ -1142,10 +1142,35 @@ export function buildStudioApp(options: StudioServerOptions) { map: "application/json", }; + // `assetsDir` resolved once so the containment check below compares two + // absolute, normalised paths. + const assetsRoot = resolve(assetsDir); + async function readAsset(relPath: string): Promise { const cleaned = relPath.replace(/^\/+/, ""); + // Path-traversal guard. This route is the token-FREE `GET *` handler (the + // SPA has to be reachable before the browser can read the token out of it), + // so an unconstrained join here would be an arbitrary local file read for + // any process that can reach the loopback port. + // + // Measured behaviour of the shapes that look dangerous (Hono 4.12, Node 24): + // + // /../x -> c.req.path "/x" WHATWG URL parsing collapses it + // /%2e%2e/x -> c.req.path "/x" parser collapses encoded dots too + // /..%2fx -> c.req.path "/..%2fx" decodeURI keeps reserved %2F + // /..%5cx -> c.req.path "/..\x" %5C DOES decode (not reserved) + // + // So on POSIX nothing reaches here with a real `..` segment and this guard + // is belt-and-braces. The reachable vector is WINDOWS: there `resolve` + // treats the decoded backslash as a separator, so `/..%5cx` escapes. + // + // Resolve first, then require the result to stay inside `assetsRoot`. + const target = resolve(assetsRoot, cleaned); + if (target !== assetsRoot && !target.startsWith(assetsRoot + sep)) { + return null; + } try { - const file = await readFile(join(assetsDir, cleaned)); + const file = await readFile(target); const ext = cleaned.slice(cleaned.lastIndexOf(".") + 1); if (ext === "html") { const html = injectStudioToken(file.toString("utf8"), studioToken); From 3a7e289eb93eba3d108993dc0848d2a388f0228a Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:29:21 -0700 Subject: [PATCH 18/19] docs(ENG-967): correct the Studio dev-loop, shared-machine and bootstrap claims (full-scope round 15) Full-scope audit (pre-existing issues in scope) round 15. All claims below were re-verified against the code before editing. - Dev loop: the docs said edits are only picked up by reloading the Run training page. Wrong: Overview (`#/`, which hosts Run training) chains a 5s `/api/manifest` poll (Overview.tsx:40, RunTraining.tsx:51) and that rebuild writes the same `.arkor/build/index.mjs` Run training executes, so edits land in ~5s with no refresh. Corrected in concepts/project-structure, concepts/studio, quickstart and studio/jobs (EN + JA). - Shared machines: "You can run Studio safely on a shared dev machine" overstated the guarantee. Loopback + Host guard close the browser and remote surfaces, but any local process can `GET /` and read the token out of index.html. Now stated as trusting every local process (EN + JA). - Credential bootstrap: an unreachable /v1/auth/cli/config does not by itself stop the bootstrap (it only decides whether OAuth is advertised); bootstrap fails only if /v1/auth/anonymous also fails, and that failure is deferred and re-raised only if this launch serves, so the port-adopt path still exits 0 (EN + JA). - Documented the `128 + signal` exit codes (130/143/129) and the `~/.arkor/telemetry-id` file (0600, never created under DO_NOT_TRACK or ARKOR_TELEMETRY_DISABLED) (EN + JA). - JA studio/overview: scope the Host guard to ALL requests including static HTML, matching EN. typecheck 10/10, lint 7/7, unit 489 arkor + 226 cli-internal + 205 studio-app + 47 create-arkor, e2e-cli 113, e2e-studio 16, oxfmt clean, no em dashes. --- docs/cli/dev.mdx | 2 + docs/concepts/project-structure.mdx | 3 +- docs/concepts/studio.mdx | 6 +-- docs/ja/cli/dev.mdx | 2 + docs/ja/concepts/project-structure.mdx | 3 +- docs/ja/concepts/studio.mdx | 6 +-- docs/ja/quickstart.mdx | 4 +- docs/ja/studio/jobs.mdx | 4 +- docs/ja/studio/overview.mdx | 2 +- docs/quickstart.mdx | 4 +- docs/studio/jobs.mdx | 4 +- packages/arkor/src/cli/commands/dev.test.ts | 24 +++++++++++- packages/arkor/src/cli/commands/dev.ts | 21 +++++++---- packages/arkor/src/studio/server.test.ts | 41 ++++++++++++++++++++- 14 files changed, 98 insertions(+), 28 deletions(-) diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index 0889d91b..eca1ce4d 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -56,6 +56,8 @@ Note that **every** one of these bootstrap failures is deferred the same way, no When the process exits (normal exit, `SIGINT`, `SIGTERM`, or `SIGHUP`) the studio-token file is removed on a best-effort basis. A crash can leave the file on disk; the next `arkor dev` rotates it. +On a signal, `arkor dev` exits with the conventional `128 + signal` code rather than `0`, so a supervisor can tell signal termination from a clean exit: `SIGINT` gives `130`, `SIGTERM` gives `143`, `SIGHUP` gives `129`. + ### Agent mode (`--agent`) `arkor dev --agent` runs the exact same Studio server headlessly for coding agents (Claude Code and others) that operate Arkor through direct HTTP calls instead of the browser UI. The flag is a plain opt-in: no environment variable is required. diff --git a/docs/concepts/project-structure.mdx b/docs/concepts/project-structure.mdx index b2579752..ffbac125 100644 --- a/docs/concepts/project-structure.mdx +++ b/docs/concepts/project-structure.mdx @@ -90,12 +90,13 @@ Lives in your home directory, shared across all Arkor projects on the machine. - **`~/.arkor/credentials.json`**. Auth state. Either an Arkor Cloud OAuth token (written by `arkor login --oauth`, or by choosing `OAuth (browser)` in the interactive `arkor login` picker) or an anonymous token (created on first use if you never logged in). The file is tagged with a `mode` field of `"oauth"` or `"anon"` so the CLI knows which path you are on. `arkor login` writes only this file; it does **not** create `.arkor/state.json`. - **`~/.arkor/studio-token`** (transient). A per-launch CSRF token written by `arkor dev` (mode `0600`). Used by Studio to authorize calls back into the CLI's local server. Rotated on every `arkor dev` run. +- **`~/.arkor/telemetry-id`** (persistent). A locally generated UUID (mode `0600`) used as the telemetry distinct ID when you are not signed in. Created on the first command that sends telemetry, and never created or read when `DO_NOT_TRACK=1` or `ARKOR_TELEMETRY_DISABLED=1` is set. Safe to delete: the next run generates a new one. `arkor logout` deletes `credentials.json` only after confirmation (or with `--force`). If the file holds anonymous credentials, deleting it permanently loses access to that anonymous identity unless you backed up the file yourself. It does **not** touch the project-local `.arkor/state.json`; if you then start a fresh anonymous session, that leftover file blocks the write paths until you delete it or sign in (see the `.arkor/state.json` note above). The studio token is removed on process exit on a best-effort basis (it may linger if `arkor dev` crashes) and is rotated on the next launch. ## What the CLI looks for -- `arkor dev` starts the Studio web server at `127.0.0.1:4000` and writes `~/.arkor/studio-token`. It does not file-watch `src/arkor/`. Studio's `/api/manifest` endpoint does call `runBuild` and re-imports through a content-addressed copy of the bundle (so unedited reloads reuse Node's module cache and edits bust it), but only when the Studio UI fetches it (currently on Run training page mount). If you stay on that page across multiple runs the build artifact is reused; refresh the Run training page (or run `arkor build` from the terminal) between edits to pick up changes. `arkor dev` does not write `.arkor/state.json` on its own. +- `arkor dev` starts the Studio web server at `127.0.0.1:4000` and writes `~/.arkor/studio-token`. It does not file-watch `src/arkor/`. Studio's `/api/manifest` endpoint does call `runBuild` and re-imports through a content-addressed copy of the bundle (so unedited reloads reuse Node's module cache and edits bust it), driven by the Studio UI: the Overview page (`#/`) polls it every 5 seconds while it is open. Because that rebuild writes the same `.arkor/build/index.mjs` that **Run training** executes, edits are picked up within about 5 seconds with no page refresh. With Studio closed nothing rebuilds, so a terminal `arkor start` runs whatever artifact is on disk. `arkor dev` does not write `.arkor/state.json` on its own. - `arkor build` reads `src/arkor/index.ts` (or the entry you pass) and writes `.arkor/build/index.mjs`. You only need to call this directly outside the Studio dev loop (e.g. CI, or right before `arkor start` from a script). - `arkor start` resolves the entry through the runner, rebuilds `.arkor/build/index.mjs` if it is missing or you passed an explicit entry, and runs it (which calls `trainer.start()` and `trainer.wait()`). Triggering training from Studio internally spawns `arkor start`. diff --git a/docs/concepts/studio.mdx b/docs/concepts/studio.mdx index 88c2b43a..fddcbe8e 100644 --- a/docs/concepts/studio.mdx +++ b/docs/concepts/studio.mdx @@ -14,7 +14,7 @@ Four jobs: 3. **Try a finished model.** A Playground page lets you pick the base model or the final adapter from any completed job and chat with it. The Playground does not load intermediate checkpoints; for mid-run inference, use [`onCheckpoint`](/concepts/lifecycle) callbacks in your trainer. 4. **Publish a model behind a `*.arkor.app` URL.** An Endpoints page creates a per-deployment subdomain that serves OpenAI-compatible chat completions for a chosen adapter or base model, plus the API keys that authenticate calls to it. The same actions are available programmatically via [`CloudApiClient`](/sdk/deployments). Studio is the interactive surface; the SDK is the lower-level one. -A note on the dev loop: Studio's `/api/manifest` endpoint rebuilds and re-imports your trainer on every request (content-addressed, so an unedited reload reuses Node's ESM cache; see `packages/arkor/src/studio/manifest.ts`), and the Run training page polls it every 5 seconds. Because that rebuild writes the same `.arkor/build/index.mjs` that **Run training** executes, an edit under `src/arkor/` goes live within about 5 seconds with no page refresh. Note what this is not: it is a poll driven by the open page, not a filesystem watcher, so nothing rebuilds while Studio is closed, and `arkor start` from the terminal runs whatever artifact is on disk. +A note on the dev loop: Studio's `/api/manifest` endpoint rebuilds and re-imports your trainer on every request (content-addressed, so an unedited reload reuses Node's ESM cache; see `packages/arkor/src/studio/manifest.ts`), and the Overview page (`#/`), which hosts the **Run training** button, polls it every 5 seconds. Because that rebuild writes the same `.arkor/build/index.mjs` that **Run training** executes, an edit under `src/arkor/` goes live within about 5 seconds with no page refresh. Note what this is not: it is a poll driven by the open page, not a filesystem watcher, so nothing rebuilds while Studio is closed, and `arkor start` from the terminal runs whatever artifact is on disk. ## Where Studio runs @@ -24,7 +24,7 @@ When you start `arkor dev`, the CLI: 2. Serves a Vite + React SPA from the same origin so the UI talks to the CLI through `/api/*` on loopback. 3. Issues a per-launch CSRF token (saved to `~/.arkor/studio-token` with mode `0600`) and requires it on every `/api/*` request, except the secrets-free `GET /api/status` probe (which is token-exempt so the port-collision check can confirm an occupant without transmitting the token). -The server only binds to loopback and rejects requests with a non-loopback `Host` header. There is no public URL, no inbound connection, and the token rotates every time you start `arkor dev`. You can run Studio safely on a shared dev machine without worrying about exposing your training data. +The server only binds to loopback and rejects requests with a non-loopback `Host` header. There is no public URL, no inbound connection, and the token rotates every time you start `arkor dev`. That closes the **browser** attack surface and any remote one. It is not a defence against another **local user** on a shared machine: anything that can open a loopback socket can `GET /` and read the token out of the served `index.html`. On a multi-user host, treat `arkor dev` as trusting every local process (see [`arkor dev`](/cli/dev) for the full model). ## How Studio fits with the managed backend @@ -42,7 +42,7 @@ Arkor managed backend (training, inference) That separation is why Studio works without you logging into anything in the browser: the CLI already has your credentials in `~/.arkor/credentials.json`, and Studio inherits them by virtue of running locally. -If `~/.arkor/credentials.json` is missing, the entry point decides what to do. **`arkor dev`** bootstraps an anonymous session at launch and prints a one-line hint pointing at `arkor login --oauth` so you can upgrade to a real account whenever you want; it never auto-launches the OAuth flow. The one outage case where it does not bootstrap is when `/v1/auth/cli/config` itself is unreachable on first run: the transport error is kept and re-raised only if this launch goes on to serve, in which case `arkor dev` exits 1. If instead the port is already held by an Arkor Studio serving this project, the connect path wins and the launch still exits 0, because adopting a running instance needs no credentials of its own (see [`arkor dev`](/cli/dev) for the exact recovery story). The **Studio server's lazy bootstrap** (when an `/api/*` request arrives before credentials are on disk) does the same anonymous fallback. To use an account session, run `arkor login --oauth` separately before (or after) clicking around in Studio; the credentials file is shared, so Studio picks up the account session on its next request. +If `~/.arkor/credentials.json` is missing, the entry point decides what to do. **`arkor dev`** bootstraps an anonymous session at launch and prints a one-line hint pointing at `arkor login --oauth` so you can upgrade to a real account whenever you want; it never auto-launches the OAuth flow. An unreachable `/v1/auth/cli/config` does **not** by itself stop the bootstrap: that call only decides whether the deployment advertises OAuth, so a failure is swallowed and the CLI still asks `/v1/auth/anonymous` for a token. Bootstrap only fails when that second call fails too. When it does, and the config call had already failed (so OAuth availability is unknown), the error is kept and re-raised only if this launch goes on to serve, in which case `arkor dev` exits 1. If instead the port is already held by an Arkor Studio serving this project, the connect path wins and the launch still exits 0, because adopting a running instance needs no credentials of its own (see [`arkor dev`](/cli/dev) for the exact recovery story). The **Studio server's lazy bootstrap** (when an `/api/*` request arrives before credentials are on disk) does the same anonymous fallback. To use an account session, run `arkor login --oauth` separately before (or after) clicking around in Studio; the credentials file is shared, so Studio picks up the account session on its next request. ## What you actually see diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 438ba229..95a7e910 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -56,6 +56,8 @@ bun dev プロセス終了時(通常終了、`SIGINT`、`SIGTERM`、`SIGHUP`)に studio-token ファイルはベストエフォートで削除されます。クラッシュするとファイルがディスク上に残ることがあり、その場合は次回 `arkor dev` がローテートします。 +シグナルで終了する場合、`arkor dev` は `0` ではなく慣例的な `128 + シグナル番号` で終了するため、スーパーバイザーはシグナル終了と正常終了を区別できます。`SIGINT` は `130`、`SIGTERM` は `143`、`SIGHUP` は `129` です。 + ### エージェントモード(`--agent`) `arkor dev --agent` は、ブラウザー UI ではなく HTTP を直接叩いて Arkor を操作するコーディングエージェント(Claude Code など)向けに、まったく同じ Studio サーバーをヘッドレスで起動します。フラグは純粋なオプトインで、環境変数は不要です。 diff --git a/docs/ja/concepts/project-structure.mdx b/docs/ja/concepts/project-structure.mdx index 6fd117e5..2e02aaad 100644 --- a/docs/ja/concepts/project-structure.mdx +++ b/docs/ja/concepts/project-structure.mdx @@ -83,12 +83,13 @@ export default {}; - **`~/.arkor/credentials.json`**。認証状態。`arkor login --oauth` または対話的な `arkor login` のピッカーで `OAuth (browser)` を選んだ場合に書かれる Arkor Cloud OAuth トークンか、未ログインで初使用時に作られる匿名トークンのいずれか。ファイルは `mode` フィールドが `"oauth"` か `"anon"` でタグ付けされており、CLI はどのモードにいるかを把握します。`arkor login` はこのファイルだけを書きます。`.arkor/state.json` は **作成しません**。 - **`~/.arkor/studio-token`**(一時的)。`arkor dev` が起動ごとに書く CSRF トークン(モード `0600`)。Studio が CLI のローカルサーバーへの呼び出しを認可するのに使います。`arkor dev` 起動のたびにローテートされます。 +- **`~/.arkor/telemetry-id`**(永続)。サインインしていないときにテレメトリーの distinct ID として使う、ローカル生成の UUID(モード `0600`)。テレメトリーを送信する最初のコマンドで作成されます。`DO_NOT_TRACK=1` または `ARKOR_TELEMETRY_DISABLED=1` が設定されている場合は作成も読み取りもされません。削除しても安全で、次回実行時に新しい ID が生成されます。 `arkor logout` は確認後(または `--force` 指定時)に `credentials.json` を削除します。匿名認証情報が入っている場合、そのファイルを削除すると、バックアップしていない限り同じ匿名 ID には戻れません。プロジェクトローカルの `.arkor/state.json` には**触れません**。そのまま新しい匿名セッションを始めると、残ったファイルが write 経路をブロックするので、削除するかサインインが必要です(上の `.arkor/state.json` の注記を参照)。Studio トークンはプロセス終了時にベストエフォートで削除され(`arkor dev` がクラッシュすると残る場合あり)、次回起動時にローテートされます。 ## CLI が探すもの -- `arkor dev` は Studio の Web サーバーを `127.0.0.1:4000` で起動し、`~/.arkor/studio-token` を書きます。`src/arkor/` をファイル監視はしません。Studio の `/api/manifest` エンドポイントは確かに `runBuild` を呼び、bundle の content-addressed コピー経由で再 import します(無編集の再読込は Node のモジュールキャッシュを再利用し、編集はキャッシュをバストします)が、それは Studio UI が fetch したときだけです(現状は Run training ページのマウント時)。同じページに留まったまま学習を複数回実行すると、ビルド成果物は再利用されます。編集した後は Run training ページをリロード(あるいはターミナルから `arkor build`)して変更を取り込んでください。`arkor dev` は単独で `.arkor/state.json` を書きません。 +- `arkor dev` は Studio の Web サーバーを `127.0.0.1:4000` で起動し、`~/.arkor/studio-token` を書きます。`src/arkor/` をファイル監視はしません。Studio の `/api/manifest` エンドポイントは確かに `runBuild` を呼び、bundle の content-addressed コピー経由で再 import します(無編集の再読込は Node のモジュールキャッシュを再利用し、編集はキャッシュをバストします)。これは Studio UI が駆動します。Overview ページ(`#/`)が開いている間、5 秒ごとにポーリングされます。このリビルドは **Run training** が実行するのと同じ `.arkor/build/index.mjs` に書き込むため、編集はページをリロードしなくても約 5 秒で反映されます。Studio を閉じている間はリビルドされないので、ターミナルからの `arkor start` はディスク上の成果物をそのまま実行します。`arkor dev` は単独で `.arkor/state.json` を書きません。 - `arkor build` は `src/arkor/index.ts`(または渡したエントリー)を読んで `.arkor/build/index.mjs` を書きます。Studio の dev ループの外側で直接呼ぶ必要があるのは CI や、スクリプトから `arkor start` の直前に走らせる場合などです。 - `arkor start` はランナー経由でエントリーを解決し、`.arkor/build/index.mjs` がない、もしくは明示的なエントリーを渡したときはリビルドして実行します(これが `trainer.start()` と `trainer.wait()` を呼びます)。Studio から学習をトリガーすると内部的に `arkor start` がサブプロセスとして起動されます。 diff --git a/docs/ja/concepts/studio.mdx b/docs/ja/concepts/studio.mdx index fe771e8e..6d2fbc67 100644 --- a/docs/ja/concepts/studio.mdx +++ b/docs/ja/concepts/studio.mdx @@ -14,7 +14,7 @@ Studio は `arkor dev` 実行時に立ち上がるローカル Web UI です。 3. **完成モデルを試す。** Playground ページでベースモデルや任意の完了済みジョブの最終アダプターを選んでチャットできます。中間チェックポイントは Playground からはロードしません。学習中の推論には [`onCheckpoint`](/ja/concepts/lifecycle) コールバックをトレーナーで使ってください。 4. **`*.arkor.app` URL でモデルを公開する。** Endpoints ページで OpenAI 互換 chat completions を提供する deployment 専用サブドメインを作成し、その API キーを発行・取り消しできます。同じ操作は [`CloudApiClient`](/ja/sdk/deployments) からプログラマティックにも可能で、Studio が対話的なインターフェイス、SDK が下位レイヤーという位置付けです。 -dev ループのメモ: Studio の `/api/manifest` エンドポイントはリクエストごとにトレーナーをリビルド・再 import し(コンテンツアドレス方式なので、編集が無ければ Node の ESM キャッシュを再利用します。`packages/arkor/src/studio/manifest.ts` を参照)、Run training ページはこれを 5 秒ごとにポーリングします。このリビルドは **Run training** が実行するのと同じ `.arkor/build/index.mjs` に書き込むため、`src/arkor/` の編集はページをリロードしなくても約 5 秒で反映されます。ただしこれはファイルシステムのウォッチャーではなく、開いているページが駆動するポーリングです。Studio を閉じている間はリビルドされませんし、ターミナルからの `arkor start` はディスク上にある成果物をそのまま実行します。 +dev ループのメモ: Studio の `/api/manifest` エンドポイントはリクエストごとにトレーナーをリビルド・再 import し(コンテンツアドレス方式なので、編集が無ければ Node の ESM キャッシュを再利用します。`packages/arkor/src/studio/manifest.ts` を参照)、**Run training** ボタンを持つ Overview ページ(`#/`)はこれを 5 秒ごとにポーリングします。このリビルドは **Run training** が実行するのと同じ `.arkor/build/index.mjs` に書き込むため、`src/arkor/` の編集はページをリロードしなくても約 5 秒で反映されます。ただしこれはファイルシステムのウォッチャーではなく、開いているページが駆動するポーリングです。Studio を閉じている間はリビルドされませんし、ターミナルからの `arkor start` はディスク上にある成果物をそのまま実行します。 ## Studio が動く場所 @@ -24,7 +24,7 @@ dev ループのメモ: Studio の `/api/manifest` エンドポイントはリ 2. 同一オリジンで Vite + React の SPA を提供し、UI はループバックの `/api/*` 経由で CLI と話します。 3. 起動ごとに CSRF トークンを発行し(`~/.arkor/studio-token` にモード `0600` で保存)、`GET /api/status`(機密ゼロのプローブ。ポート競合チェックがトークンを送らずに占有者を確認できるようトークン不要)を除くすべての `/api/*` リクエストにこのトークンを要求します。 -サーバーはループバックにのみバインドし、ループバック以外の `Host` ヘッダーのリクエストは拒否します。公開 URL もインバウンド接続もなく、トークンは `arkor dev` のたびにローテートされます。共有の開発環境でも学習データが漏れる心配なく安全に Studio を動かせます。 +サーバーはループバックにのみバインドし、ループバック以外の `Host` ヘッダーのリクエストは拒否します。公開 URL もインバウンド接続もなく、トークンは `arkor dev` のたびにローテートされます。これで**ブラウザー**側の攻撃面とリモートからの攻撃面は塞がれます。ただし共有マシン上の**別のローカルユーザー**に対する防御ではありません。ループバックにソケットを開けるものは `GET /` で配信された `index.html` からトークンを読み取れます。マルチユーザーのホストでは、`arkor dev` はローカルの全プロセスを信頼するものとして扱ってください(詳細は [`arkor dev`](/ja/cli/dev) を参照)。 ## マネージドバックエンドとの関係 @@ -42,7 +42,7 @@ Arkor マネージドバックエンド(学習、推論) この分離があるからこそ、ブラウザー側で何もログインせずに Studio が動きます。CLI は `~/.arkor/credentials.json` ですでに認証情報を持っており、Studio はローカルで動いている前提のもとその認証情報を引き継ぎます。 -`~/.arkor/credentials.json` がない場合の処理はエントリーポイントが決めます。**`arkor dev`** は起動時に匿名セッションを初期化し、必要に応じてアップグレードできるよう `arkor login --oauth` を案内する 1 行のヒントを出します。OAuth フローを自動で起動することはありません。例外は初回起動時に `/v1/auth/cli/config` 自体へ到達できないケースです。このときトランスポートエラーは保持され、この起動が実際にサーブへ進んだときにのみ再スローされて `arkor dev` は exit 1 で終了します。一方、同じプロジェクトをサーブしている Arkor Studio が既にポートを握っている場合は接続経路が優先され、既存インスタンスへの接続はそれ自体に認証情報を必要としないため exit 0 で終了します(具体的な復旧手順は [`arkor dev`](/ja/cli/dev) を参照)。**Studio サーバーの遅延初期化**(認証情報が無い状態で `/api/*` リクエストが届いたとき)も同じ匿名フォールバックを行います。アカウントセッションを使いたい場合は、Studio をクリックする前(あるいは後)に別途 `arkor login --oauth` を実行してください。認証情報ファイルは共有なので、Studio は次のリクエストでアカウントセッションを拾います。 +`~/.arkor/credentials.json` がない場合の処理はエントリーポイントが決めます。**`arkor dev`** は起動時に匿名セッションを初期化し、必要に応じてアップグレードできるよう `arkor login --oauth` を案内する 1 行のヒントを出します。OAuth フローを自動で起動することはありません。`/v1/auth/cli/config` へ到達できないこと自体は初期化を止め**ません**。この呼び出しはデプロイが OAuth をアドバタイズしているかを判定するだけなので、失敗しても握り潰され、CLI は `/v1/auth/anonymous` にトークンを要求します。初期化が失敗するのは、この 2 回目の呼び出しも失敗したときだけです。そのとき config の呼び出しも既に失敗していれば(OAuth の可否が不明なので)、トランスポートエラーは保持され、この起動が実際にサーブへ進んだときにのみ再スローされて `arkor dev` は exit 1 で終了します。一方、同じプロジェクトをサーブしている Arkor Studio が既にポートを握っている場合は接続経路が優先され、既存インスタンスへの接続はそれ自体に認証情報を必要としないため exit 0 で終了します(具体的な復旧手順は [`arkor dev`](/ja/cli/dev) を参照)。**Studio サーバーの遅延初期化**(認証情報が無い状態で `/api/*` リクエストが届いたとき)も同じ匿名フォールバックを行います。アカウントセッションを使いたい場合は、Studio をクリックする前(あるいは後)に別途 `arkor login --oauth` を実行してください。認証情報ファイルは共有なので、Studio は次のリクエストでアカウントセッションを拾います。 ## 実際に見えるもの diff --git a/docs/ja/quickstart.mdx b/docs/ja/quickstart.mdx index 2a17e7e2..726114dd 100644 --- a/docs/ja/quickstart.mdx +++ b/docs/ja/quickstart.mdx @@ -158,7 +158,7 @@ bun dev - **Loss チャートとイベントログ。** マネージド GPU から進捗がストリームされるにつれて、Loss(学習中のモデルの誤差を示す数値で、低いほどモデルが正解に近づいているサイン)の曲線が更新され、ログのテールに学習イベントが表示されます。最初の学習はテンプレートにより 7〜12 分かかります。 - **Playground。** ジョブが完了したら、最終アダプターをセレクターから選んでチャット。モード切替でベースモデルとアダプターを行き来できます。学習中に中間チェックポイントで推論を走らせたい場合は Studio ではなく `onCheckpoint` コールバックを使ってください。 -学習の合間に `src/arkor/` を編集した場合は、Run training ページをリロード(または `arkor build` を実行)してから次のクリックをすると、新しいコードが動きます。 +学習の合間に `src/arkor/` を編集した場合、Overview ページを開いたままなら約 5 秒で自動的に反映されます(マニフェストのポーリングがビルドを再実行します)。リロードは不要です。 ## 4. アカウントに紐づける(任意) @@ -197,5 +197,5 @@ bun arkor login --oauth ## 5. 次に読むもの - **コンセプト。** [Concepts](/ja/concepts/overview) を読み、`createArkor`、`createTrainer`、ライフサイクルコールバック、Studio のメンタルモデルを構築してください。 -- **トレーナーをカスタマイズ。** `src/arkor/trainer.ts` を開き、`lora.r` や `maxSteps` を調整したり、コールバックを追加したり。Run training ページをリロード(または `arkor build`)してから次のクリックで反映されます。 +- **トレーナーをカスタマイズ。** `src/arkor/trainer.ts` を開き、`lora.r` や `maxSteps` を調整したり、コールバックを追加したり。Overview ページを開いたままなら、約 5 秒後の次のクリックで反映されます。 - **別のテンプレートを試す。** プロジェクト生成ツールを別の `--template` で再実行して比較してみてください。 diff --git a/docs/ja/studio/jobs.mdx b/docs/ja/studio/jobs.mdx index 56683075..6a3793da 100644 --- a/docs/ja/studio/jobs.mdx +++ b/docs/ja/studio/jobs.mdx @@ -9,7 +9,7 @@ description: "Studio の Overview / Jobs / Job 詳細ページ: 学習をトリ ## Run training -Run training パネルはページ読み込み時に `/api/manifest` を 1 度呼びます。レスポンスはプロジェクトの `createArkor({ trainer })` のサマリで、これをもとにアクションをラベル付けします: +Run training パネルは Overview ページが開いている間、`/api/manifest` を 5 秒ごとにポーリングします。レスポンスはプロジェクトの `createArkor({ trainer })` のサマリで、これをもとにアクションをラベル付けします: - トレーナーが見つかれば `Run training: `。 - バンドルは import されたが何も露出しない場合 `No trainer in src/arkor/index.ts yet. Add createTrainer(...) and pass it to createArkor.`(`src/arkor/index.ts` にトレーナーがまだありません。`createTrainer(...)` を追加して `createArkor` に渡してください)。 @@ -19,7 +19,7 @@ Run training パネルはページ読み込み時に `/api/manifest` を 1 度 クリックすると Studio は `POST /api/train` を送ります。バックエンドはサブプロセスで `arkor start` を起動し、stdout / stderr を生テキストとしてストリーミングで返します。整形済みログボックスは自動スクロールし、表示されるのはターミナルで `arkor start` を走らせたときの出力そのものです。 -トレーナー選択やフラグを渡す入力フォームはありません: Studio は常に `createArkor` で登録されたトレーナーを走らせ、`arkor start` は `.arkor/build/index.mjs` があれば再利用します。同じページで複数回クリックする間に `src/arkor/` の編集は自動では拾われません。編集の合間に Run training ページをリロード(あるいはターミナルから `arkor build`)してから次のクリックをしてください。具体的なリビルドルールは [CLI § build / start](/ja/cli/build-and-start) を参照。 +トレーナー選択やフラグを渡す入力フォームはありません: Studio は常に `createArkor` で登録されたトレーナーを走らせ、`arkor start` は `.arkor/build/index.mjs` があれば再利用します。編集の合間にリロードする必要はありません。マニフェストのポーリングごとにビルドが再実行されるため、`src/arkor/` の編集は約 5 秒で次のクリックに反映されます。具体的なリビルドルールは [CLI § build / start](/ja/cli/build-and-start) を参照。 ## Jobs リスト diff --git a/docs/ja/studio/overview.mdx b/docs/ja/studio/overview.mdx index 4a45dd2f..63135bd3 100644 --- a/docs/ja/studio/overview.mdx +++ b/docs/ja/studio/overview.mdx @@ -36,7 +36,7 @@ Arkor マネージドバックエンド `/api/*` リクエストごとに 3 つのチェックが走ります: -1. **Host ヘッダーのガード。** `127.0.0.1` と `localhost` のみ受理。`127.0.0.1` に DNS リバインディングされる悪意あるサイトに誘導された被害者でも、送信されるのは `Host: evil.com` で、ミドルウェアは HTTP 403 で拒否します。 +1. **Host ヘッダーのガード。** 静的 HTML を含む**すべてのリクエスト**について、`127.0.0.1` と `localhost` のみ受理します。`127.0.0.1` に DNS リバインディングされる悪意あるサイトに誘導された被害者でも、送信されるのは `Host: evil.com` なので、トークンを埋め込んだ HTML を配信する前に HTTP 403 で拒否します。 2. **起動ごとの CSRF トークン。** `arkor dev` は起動ごとに 32 バイトのトークン(base64url)を生成し、`` として `index.html` にインジェクトし、すべての `/api/*` 呼び出しに必須化します(ヘッダー `X-Arkor-Studio-Token`)。`EventSource` はカスタムヘッダーを送れないため、ジョブイベントストリームだけは `?studioToken=` も受け付けます。ミューテーション系ルートはクエリ文字列のトークンを受け付けません。クロスオリジンタブは meta を読めないので、preflight をスキップする「simple」なクロスオリジン POST も拒否されます。比較には `timingSafeEqual` を使用しているため、タイミング攻撃に対して安全です。唯一の例外は `GET /api/status` で、これはトークン不要です(ループバック + Host ガードは適用)。機密ゼロでユーザーコードを実行しないため、`arkor dev` のポート競合プローブがトークンを送信せずに占有者を確認できます。 3. **CORS なし。** SPA は同一オリジンなので CORS には意味がありません。`*` を反射すると「simple」なクロスオリジン POST(`text/plain`、`urlencoded`)を素通りさせてしまうので、トークンチェックがそれを拒否します。 diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 8cce7bf4..a74e36d0 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -158,7 +158,7 @@ Once a run is in flight, three views matter: - **Loss chart and event log.** As the run streams from the managed GPU, the loss curve updates and the log tail shows training events. The first run takes 7 to 12 minutes depending on the template. - **Playground.** After a job completes, pick the final adapter from the selector and chat with it. Use the mode toggle to switch between the base model and the adapter. To run inference on intermediate checkpoints while a run is still in flight, use `onCheckpoint` callbacks instead of Studio. -If you edit `src/arkor/` between runs, refresh the Run training page (or run `arkor build`) before the next click so the new code is what runs. +If you edit `src/arkor/` between runs, the change is picked up automatically within about 5 seconds while the Overview page is open (its manifest poll re-runs the build). No refresh needed. ## 4. Tie runs to an account (optional) @@ -197,5 +197,5 @@ A few caveats. Running `arkor login` without a flag opens an interactive picker ## 5. Where to go next - **Concepts.** Read [Concepts](/concepts/overview) to build a mental model of `createArkor`, `createTrainer`, the lifecycle callbacks, and Studio. -- **Customize the trainer.** Open `src/arkor/trainer.ts` and tweak `lora.r`, `maxSteps`, or add more callbacks. Refresh the Run training page (or run `arkor build`) before the next click so your edits land. +- **Customize the trainer.** Open `src/arkor/trainer.ts` and tweak `lora.r`, `maxSteps`, or add more callbacks. With the Overview page open, your edits land on the next click about 5 seconds later. - **Try another template.** Re-run the scaffolder with a different `--template` to compare. diff --git a/docs/studio/jobs.mdx b/docs/studio/jobs.mdx index 20235081..48f776d8 100644 --- a/docs/studio/jobs.mdx +++ b/docs/studio/jobs.mdx @@ -9,7 +9,7 @@ The training surface is split across three routes: **Overview** (`#/`) hosts the ## Run training -The Run training panel calls `/api/manifest` once when the page loads. The response is the project's `createArkor({ trainer })` summary, which the panel uses to label the action: +The Run training panel polls `/api/manifest` every 5 seconds while the Overview page is open. The response is the project's `createArkor({ trainer })` summary, which the panel uses to label the action: - `Run training: ` once a trainer is found. - `No trainer in src/arkor/index.ts yet. Add createTrainer(...) and pass it to createArkor.` if the bundle imported but exposed nothing. @@ -19,7 +19,7 @@ The button is disabled while a run is in flight and while no trainer has resolve When you click it, Studio sends `POST /api/train`. The backend spawns `arkor start` in a subprocess and streams its stdout / stderr back as raw text. The pre-formatted log box auto-scrolls; what you see is exactly what the spawned `arkor start` would print in a terminal. -There is no input form for picking the trainer or passing flags: Studio always runs the trainer registered through `createArkor`, and `arkor start` reuses `.arkor/build/index.mjs` if it already exists. Edits to `src/arkor/` are not picked up automatically across multiple clicks on the same page; reload the Run training page (or run `arkor build` from a terminal) between edits and the next click. See [CLI § build / start](/cli/build-and-start) for the precise rebuild rules. +There is no input form for picking the trainer or passing flags: Studio always runs the trainer registered through `createArkor`, and `arkor start` reuses `.arkor/build/index.mjs` if it already exists. You do not need to reload between edits: each manifest poll re-runs the build, so an edit to `src/arkor/` reaches the next click within about 5 seconds. See [CLI § build / start](/cli/build-and-start) for the precise rebuild rules. ## Jobs list diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 565dd4df..296854a5 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -639,7 +639,14 @@ describe("runDev", () => { expect(existsSync(studioTokenPath())).toBe(true); const contents = readFileSync(studioTokenPath(), "utf8"); expect(contents).toMatch(/^[\w-]+$/); - // Atomic write (PR #193 review): the temp staging file must be gone. + // Atomic write (PR #193 review). "No .tmp stray" alone does NOT pin this: + // a plain direct write leaves no stray either. Assert the rename that makes + // the publish atomic, so swapping temp+rename for a direct write fails here + // (same reasoning as the agent session file's atomicity test below). + const homeRename = renameCalls.find((c) => c[1] === studioTokenPath()); + expect(homeRename).toBeDefined(); + expect(String(homeRename?.[0])).toMatch(/\.tmp$/); + expect(dirname(String(homeRename?.[0]))).toBe(dirname(studioTokenPath())); const strays = readdirSync(join(fakeHome, ".arkor")).filter((f) => f.includes(".tmp"), ); @@ -988,6 +995,21 @@ describe("runDev", () => { expect(payload.port).toBe(4310); expect(payload.pid).toBe(process.pid); expect(payload.token).toMatch(/^[\w-]+$/); + // The SERVER must be given that same agent-facing URL, not the localhost + // display form: `/api/status` echoes it, and that echo is what an agent + // reads back. Drive the real app through the handler runDev passed to + // `serve`, so passing the display url here fails. + const served = vi.mocked(serve).mock.calls[0]?.[0] as { + fetch: (req: Request) => Promise; + }; + const statusRes = await served.fetch( + new Request("http://127.0.0.1:4310/api/status", { + headers: { host: "127.0.0.1:4310" }, + }), + ); + const statusBody = (await statusRes.json()) as Record; + expect(statusBody.mode).toBe("agent"); + expect(statusBody.url).toBe("http://127.0.0.1:4310"); // The home token is still written in agent mode (user-facing contract: // the Vite SPA dev workflow reads it). NOT for the port-collision probe: // that hits the token-exempt /api/status and sends no token at all. diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index d84c9775..2d24e5b4 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -50,10 +50,14 @@ export interface DevOptions { * advertises OAuth, surface a hint pointing at `arkor login --oauth` so * the user can upgrade to a real session whenever they want, but don't * block the Studio launch on it. - * - On anonymous-bootstrap network failure, warn and continue: the Studio - * server is built with `autoAnonymous` enabled, so it will retry on the - * first `/api/credentials` hit. This keeps `arkor dev` usable when the - * cloud-api is momentarily down. + * - On anonymous-bootstrap network failure, warn and continue **only when the + * deployment mode is known** (i.e. `/v1/auth/cli/config` had already + * succeeded): the Studio server is built with `autoAnonymous` enabled, so it + * will retry on the first `/api/credentials` hit. This keeps `arkor dev` + * usable when the cloud-api is momentarily down. If the config call ALSO + * failed we cannot tell whether `/v1/auth/anonymous` is even enabled here, + * so the error is rethrown instead of starting a Studio that may never + * recover. See the `deploymentModeKnown` branch below. */ export async function ensureCredentialsForStudio(): Promise { if (await readCredentials()) return; @@ -392,10 +396,11 @@ async function probeExistingStudio( // A real /api/status is terminal and never redirects. `redirect: // "manual"` stops an untrusted port occupant from bouncing this // CLI-side GET to an arbitrary URL, removing that blind-request - // surface. Under Node/undici the 3xx then comes back as an ordinary - // response carrying the real status (NOT the browser's opaqueredirect - // filtering, which only applies to navigate-mode requests), so `res.ok` - // is false and the check below rejects it before any body is read. + // surface. Measured on Node 24 / undici: the 3xx then comes back as an + // ordinary response carrying the real status (`type: "basic"`, body + // readable), not a synthesised opaque one, so `res.ok` is false and the + // check below rejects it before any body is read. Either way the guard + // holds; only the reason the response is unusable would differ. redirect: "manual", }, ); diff --git a/packages/arkor/src/studio/server.test.ts b/packages/arkor/src/studio/server.test.ts index 8efe4c44..8ced09b0 100644 --- a/packages/arkor/src/studio/server.test.ts +++ b/packages/arkor/src/studio/server.test.ts @@ -265,6 +265,39 @@ describe("Studio server", () => { expect(res.status).toBe(403); }); + it("never emits CORS headers (the SPA is same-origin by design)", async () => { + // Third pillar of the Studio CSRF model, alongside the Host guard and the + // token, and the only one with no test until now. Reflecting `*` would let + // a "simple" cross-origin POST (text/plain, urlencoded) skip preflight and + // reach a handler; the token check would still reject it, but adding CORS + // would remove a whole layer for no benefit, since the SPA is same-origin. + // Assert the absence directly so a future `app.use(cors())` fails here. + const app = build(); + const responses = [ + // Static HTML, an authorised /api/* call, and a rejected one: a CORS + // middleware could plausibly be mounted on any of these paths. + await app.request("/", { headers: { host: "127.0.0.1:4000" } }), + await app.request("/api/status", { headers: { host: "127.0.0.1:4000" } }), + await app.request("/api/credentials", { + headers: { host: "127.0.0.1:4000", origin: "http://evil.example" }, + }), + await app.request("/api/credentials", { + method: "OPTIONS", + headers: { + host: "127.0.0.1:4000", + origin: "http://evil.example", + "access-control-request-method": "POST", + }, + }), + ]; + for (const res of responses) { + const cors = [...res.headers.keys()].filter((h) => + h.toLowerCase().startsWith("access-control-"), + ); + expect(cors).toEqual([]); + } + }); + it("rejects NESTED /api/* routes without a studio token", async () => { // The deny-direction tests above all use single-segment paths // (/api/credentials). Hono's `/api/*` matches multi-segment paths too, but @@ -433,7 +466,11 @@ describe("Studio server", () => { studioToken: STUDIO_TOKEN, cwd: trainCwd, mode: "agent", - url: "http://localhost:4123", + // The 127.0.0.1 LITERAL, matching what runDev actually passes + // (`agentUrl`). Seeding the `localhost` form here would make this test + // assert exactly the shape StudioServerOptions.url documents as wrong, + // and an agent HTTP client without Happy-Eyeballs may not reach it. + url: "http://127.0.0.1:4123", }); const res = await app.request("/api/status", { headers: { @@ -444,7 +481,7 @@ describe("Studio server", () => { expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.mode).toBe("agent"); - expect(body.url).toBe("http://localhost:4123"); + expect(body.url).toBe("http://127.0.0.1:4123"); }); it("never includes the studio token or credential material in the body", async () => { From 682cab83932e101274c6d70f66a237a6ec8e333b Mon Sep 17 00:00:00 2001 From: k-taro56 <121674121+k-taro56@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:26:18 -0700 Subject: [PATCH 19/19] fix(ENG-967): full-scope audit round 16 Ten survivors from the round-16 audit (pre-existing issues in scope). Every code fix below is mutation-verified: the production change was reverted and the new test confirmed to fail. Code: - projectState: the project-slug dash-trim ran BEFORE the 40-char truncation, so the cut could re-introduce the trailing hyphen the trim exists to remove (a directory whose 40th char is a separator yielded `<39 chars>-`). Truncate first, then trim. Regression test added. Tests that were not load-bearing: - The entire /api/train child-reaping mechanism was uncovered: deleting the refcounted `process.on("exit", killLiveTrainChildren)` attach, the kill itself, or the detach left server.test.ts green, even though dev.ts's shutdown handlers rely on it to avoid orphaning a training child on `docker stop`. Added a test with a long-lived child; all three mutations now fail it. - isUntrustedPeerPath: only the /proc branch was exercised (and only on Linux). The /dev/fd and UNC branches cannot be driven through the probe into a false adoption, so they are now pinned by direct unit tests, including near-miss paths (/procfs-notreally, /dev/fdisk) that a raw-prefix test would wrongly reject. Documented that the UNC branch is Windows-only because path.posix.normalize collapses a leading //. - The double-cleanup assertion was `not.toThrow()`, which cannot detect removal of the `cleaned` guard (every fs call in the closure is already individually try/caught). Re-create the token with our OWN content so the ownership check cannot mask it, then assert the second call leaves it alone. - e2e runCli gained an optional timeout that SIGKILLs the child. The CLAUDECODE gate test spawns the one command that serves forever; without a cap a gate regression hung the suite and left a server bound to port 4000. Docs (EN + JA, all claims re-verified against code): - concepts/studio scoped the deferred bootstrap re-raise to one sub-case, contradicting cli/dev's "every bootstrap failure is deferred". - The fatal `TypeError: fetch failed` needs BOTH calls to fail, not just an unreachable /v1/auth/cli/config (that alone is never fatal). - The wrapped 4xx message is conditional on the deployment advertising OAuth; added the raw `Failed to acquire anonymous token` row it falls back to. - Added the post-bind warnings the Errors table omitted (`Studio server error after startup`, `Could not set permissions`). EN/JA row parity 19/19. - project-structure claimed `arkor dev` unconditionally starts a server and writes the token; noted the adopt path exits 0 without either. typecheck 10/10, lint 7/7, unit 494 arkor + 226 cli-internal + 205 studio-app + 47 create-arkor, e2e-cli 113, e2e-studio 16, oxfmt clean, no em dashes. --- docs/cli/dev.mdx | 9 ++- docs/concepts/project-structure.mdx | 2 +- docs/concepts/studio.mdx | 2 +- docs/ja/cli/dev.mdx | 9 ++- docs/ja/concepts/project-structure.mdx | 2 +- docs/ja/concepts/studio.mdx | 2 +- e2e/cli/src/arkor-dev.test.ts | 15 ++-- e2e/cli/src/spawn-cli.ts | 46 +++++++++++- packages/arkor/src/cli/commands/dev.test.ts | 72 ++++++++++++++++++- packages/arkor/src/cli/commands/dev.ts | 13 +++- packages/arkor/src/core/projectState.test.ts | 41 +++++++++++ packages/arkor/src/core/projectState.ts | 8 ++- packages/arkor/src/studio/server.test.ts | 76 ++++++++++++++++++++ 13 files changed, 274 insertions(+), 23 deletions(-) diff --git a/docs/cli/dev.mdx b/docs/cli/dev.mdx index eca1ce4d..6938428e 100644 --- a/docs/cli/dev.mdx +++ b/docs/cli/dev.mdx @@ -47,7 +47,7 @@ bun dev ### Launch sequence -1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; otherwise it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint. "Otherwise" covers two cases, because the CLI treats an unknown deployment as anon-only: a genuine anon-only deployment (`/v1/auth/cli/config` advertised no OAuth), and a `/v1/auth/cli/config` call that failed outright, leaving OAuth availability unknown. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``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.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/cli/config` itself is unreachable, the deployment mode cannot be determined, so the same transport error is kept and re-raised **only if this launch goes on to serve** (serving needs credentials): `arkor dev` then exits 1 (restore connectivity and re-run). The failure is deliberately deferred rather than immediate so that the port-collision path below still works offline: connecting to an Arkor Studio that already serves this project needs no credentials of its own, so that case still prints `Arkor Studio already running on ` and exits 0. If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment) **and** the deployment advertises OAuth, it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in. Run `arkor login --oauth` and try again.``). The sign-in hint is conditional because on an anon-only deployment `arkor login --oauth` would itself fail; there the raw rejection surfaces instead (`Failed to acquire anonymous token (): `). +1. **Credential bootstrap.** If `~/.arkor/credentials.json` does not exist, the CLI always tries to bootstrap an anonymous session: it calls `/v1/auth/cli/config`, then requests an anonymous token from `/v1/auth/anonymous`. The pre-bootstrap line depends on whether the deployment advertises OAuth: when OAuth is configured the CLI prints ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.`` so you can upgrade to a real account whenever you want; otherwise it prints `No credentials on file. Requesting an anonymous token.` instead, omitting the OAuth hint. "Otherwise" covers two cases, because the CLI treats an unknown deployment as anon-only: a genuine anon-only deployment (`/v1/auth/cli/config` advertised no OAuth), and a `/v1/auth/cli/config` call that failed outright, leaving OAuth availability unknown. Either way, it never auto-launches the OAuth flow. Once the token lands, `arkor dev` prints ``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.`` (the path is the resolved `credentialsPath()`, typically `~/.arkor/credentials.json` on Linux and macOS). Only when the deployment advertises OAuth, a follow-up warn (``Anonymous sessions aren't guaranteed to persist. Sign in with `arkor login --oauth` to tie future work to your Arkor Cloud account.``) fires alongside the success line so the upgrade hint is visible at issuance time. On anon-only deployments the warn is suppressed because pointing at `arkor login --oauth` would surface a command that fails. Transport failures (`fetch failed`) are handled differently depending on when they hit. If `/v1/auth/cli/config` already succeeded and `/v1/auth/anonymous` then fails the same way, the CLI warns and continues; the Studio server retries on the first `/api/credentials` hit. If `/v1/auth/anonymous` fails that way **and** `/v1/auth/cli/config` was already unreachable, the deployment mode cannot be determined, so the warn-and-continue fallback is not safe to take: that transport error is kept and re-raised **only if this launch goes on to serve** (serving needs credentials), and `arkor dev` then exits 1 (restore connectivity and re-run). Note that an unreachable `/v1/auth/cli/config` on its own is never fatal: that call only decides whether OAuth is advertised, so the CLI swallows its failure and still asks `/v1/auth/anonymous` for a token. The failure is deliberately deferred rather than immediate so that the port-collision path below still works offline: connecting to an Arkor Studio that already serves this project needs no credentials of its own, so that case still prints `Arkor Studio already running on ` and exits 0. If `/v1/auth/anonymous` is rejected with a 4xx (for example because anonymous sign-in is disabled on this deployment) **and** the deployment advertises OAuth, it surfaces an error wrapping the HTTP status and pointing at `arkor login --oauth` (full message: ``Failed to bootstrap an anonymous session (HTTP ). This deployment may require sign-in. Run `arkor login --oauth` and try again.``). The sign-in hint is conditional because on an anon-only deployment `arkor login --oauth` would itself fail; there the raw rejection surfaces instead (`Failed to acquire anonymous token (): `). Note that **every** one of these bootstrap failures is deferred the same way, not just the config-unreachable case: `arkor dev` captures whatever `ensureCredentialsForStudio` throws (the 4xx wrap, the raw rejection, and local `~/.arkor/credentials.json` write errors alike) and re-raises it only if this launch actually serves. On the port-collision connect path it is discarded, so a first run against an unreachable or sign-in-only deployment can still adopt a Studio that is already serving this project and exit 0. 2. **CSRF token.** A 32-byte token (base64url, ~43 chars) is generated for this launch. It is injected into `index.html` as `` so the same-origin SPA can read it. Cross-origin tabs cannot read the meta and are rejected by the `/api/*` middleware. @@ -130,12 +130,15 @@ The token file (`~/.arkor/studio-token`) is written only **after** the port bind | `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.` (exit `1`) | The Claude Code strict gate: `CLAUDECODE=1` is set and `--agent` was not passed. No server was started and no token or session file was written. | Re-run as `arkor dev --agent` and use the printed session file. | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.` (exit `1`) | Agent mode could not create `.arkor/agent/` or the session file (read-only project dir, permission problem). Unlike the home token this is fatal: the file is the agent's only token channel. | Make the project directory writable (check `.arkor/` permissions) and re-run. | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.` | `/v1/auth/cli/config` already succeeded, but the follow-up `/v1/auth/anonymous` hit a transport error. The Studio server starts and will retry. | Bring connectivity back; the SPA recovers on its next `/api/credentials` poll without restarting `arkor dev`. | -| `TypeError: fetch failed` (or an equivalent transport error, exit `1`) | `/v1/auth/cli/config` itself was unreachable, so the deployment mode could not be determined. The error is deferred and re-raised only if this launch actually serves; if the port is already held by an Arkor Studio for this project, the connect path wins instead and exits `0`. | Restore connectivity and re-run `arkor dev`. | +| `TypeError: fetch failed` (or an equivalent transport error, exit `1`) | **Both** calls failed: `/v1/auth/anonymous` hit a transport error *and* `/v1/auth/cli/config` was already unreachable, so the deployment mode could not be determined and the retry-on-first-request fallback is not safe to take. (A transport failure on `/v1/auth/anonymous` alone, after a successful config call, is only the warn-and-continue row above.) The error is deferred and re-raised only if this launch actually serves; if the port is already held by an Arkor Studio for this project, the connect path wins instead and exits `0`. | 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 when OAuth is not known to be available: either an anon-only deployment (no OAuth advertised in `/v1/auth/cli/config`) or a `/v1/auth/cli/config` call that failed, which is treated the same way. The CLI omits the `arkor login --oauth` hint because it cannot promise that command would work. | 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. | -| ``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`. | +| ``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 **and** the deployment advertises OAuth, so anonymous bootstrap cannot proceed but signing in can. | Run `arkor login --oauth` to complete the browser flow, then re-run `arkor dev`. | +| `Failed to acquire anonymous token (): ` (exit `1`) | The raw rejection, surfaced when the wrap above does not apply: a 4xx on a deployment that does **not** advertise OAuth (so pointing at `arkor login --oauth` would just fail), or any 5xx. | For a 5xx, retry: it is usually transient. For a 4xx on an anon-only deployment, the deployment is not accepting anonymous sign-in; contact whoever operates it. | +| `Studio server error after startup: ` (warning, server keeps running) | The HTTP server emitted an error **after** a successful bind (a client-side socket reset, for example). It is logged rather than fatal so a single bad connection cannot take Studio down. | Usually nothing. If it repeats, restart `arkor dev` and check for a proxy or client hammering the port. | +| `Could not set permissions on : ` (warning) | `chmod` to `0600`/`0700` failed on the studio token, the `.arkor/agent` directory, or the agent session file (common on Windows and on filesystems without POSIX modes). The file is still written. | Safe to ignore on Windows. On POSIX, check the filesystem and the parent directory's ownership if you rely on the tight modes. | | `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. | | HTTP 403 with `{ "error": "Missing or invalid studio token" }` (in browser devtools) | The CSRF token in the page does not match the current launch. Usually a stale tab from a previous `arkor dev`. | Reload the tab. Token rotates on every launch. | diff --git a/docs/concepts/project-structure.mdx b/docs/concepts/project-structure.mdx index ffbac125..14249b03 100644 --- a/docs/concepts/project-structure.mdx +++ b/docs/concepts/project-structure.mdx @@ -96,7 +96,7 @@ Lives in your home directory, shared across all Arkor projects on the machine. ## What the CLI looks for -- `arkor dev` starts the Studio web server at `127.0.0.1:4000` and writes `~/.arkor/studio-token`. It does not file-watch `src/arkor/`. Studio's `/api/manifest` endpoint does call `runBuild` and re-imports through a content-addressed copy of the bundle (so unedited reloads reuse Node's module cache and edits bust it), driven by the Studio UI: the Overview page (`#/`) polls it every 5 seconds while it is open. Because that rebuild writes the same `.arkor/build/index.mjs` that **Run training** executes, edits are picked up within about 5 seconds with no page refresh. With Studio closed nothing rebuilds, so a terminal `arkor start` runs whatever artifact is on disk. `arkor dev` does not write `.arkor/state.json` on its own. +- `arkor dev` starts the Studio web server at `127.0.0.1:4000` and writes `~/.arkor/studio-token`. (One exception: if the port is already held by an Arkor Studio serving this same project, the launch connects to it and exits `0` instead, starting no server and leaving the existing instance's token untouched. See [`arkor dev`](/cli/dev).) It does not file-watch `src/arkor/`. Studio's `/api/manifest` endpoint does call `runBuild` and re-imports through a content-addressed copy of the bundle (so unedited reloads reuse Node's module cache and edits bust it), driven by the Studio UI: the Overview page (`#/`) polls it every 5 seconds while it is open. Because that rebuild writes the same `.arkor/build/index.mjs` that **Run training** executes, edits are picked up within about 5 seconds with no page refresh. With Studio closed nothing rebuilds, so a terminal `arkor start` runs whatever artifact is on disk. `arkor dev` does not write `.arkor/state.json` on its own. - `arkor build` reads `src/arkor/index.ts` (or the entry you pass) and writes `.arkor/build/index.mjs`. You only need to call this directly outside the Studio dev loop (e.g. CI, or right before `arkor start` from a script). - `arkor start` resolves the entry through the runner, rebuilds `.arkor/build/index.mjs` if it is missing or you passed an explicit entry, and runs it (which calls `trainer.start()` and `trainer.wait()`). Triggering training from Studio internally spawns `arkor start`. diff --git a/docs/concepts/studio.mdx b/docs/concepts/studio.mdx index fddcbe8e..571d5739 100644 --- a/docs/concepts/studio.mdx +++ b/docs/concepts/studio.mdx @@ -42,7 +42,7 @@ Arkor managed backend (training, inference) That separation is why Studio works without you logging into anything in the browser: the CLI already has your credentials in `~/.arkor/credentials.json`, and Studio inherits them by virtue of running locally. -If `~/.arkor/credentials.json` is missing, the entry point decides what to do. **`arkor dev`** bootstraps an anonymous session at launch and prints a one-line hint pointing at `arkor login --oauth` so you can upgrade to a real account whenever you want; it never auto-launches the OAuth flow. An unreachable `/v1/auth/cli/config` does **not** by itself stop the bootstrap: that call only decides whether the deployment advertises OAuth, so a failure is swallowed and the CLI still asks `/v1/auth/anonymous` for a token. Bootstrap only fails when that second call fails too. When it does, and the config call had already failed (so OAuth availability is unknown), the error is kept and re-raised only if this launch goes on to serve, in which case `arkor dev` exits 1. If instead the port is already held by an Arkor Studio serving this project, the connect path wins and the launch still exits 0, because adopting a running instance needs no credentials of its own (see [`arkor dev`](/cli/dev) for the exact recovery story). The **Studio server's lazy bootstrap** (when an `/api/*` request arrives before credentials are on disk) does the same anonymous fallback. To use an account session, run `arkor login --oauth` separately before (or after) clicking around in Studio; the credentials file is shared, so Studio picks up the account session on its next request. +If `~/.arkor/credentials.json` is missing, the entry point decides what to do. **`arkor dev`** bootstraps an anonymous session at launch and prints a one-line hint pointing at `arkor login --oauth` so you can upgrade to a real account whenever you want; it never auto-launches the OAuth flow. An unreachable `/v1/auth/cli/config` does **not** by itself stop the bootstrap: that call only decides whether the deployment advertises OAuth, so a failure is swallowed and the CLI still asks `/v1/auth/anonymous` for a token. Bootstrap only fails when that second call fails too (and even then, a plain transport blip is survivable: if the config call had succeeded, the CLI warns and lets the Studio server retry on its first `/api/credentials` hit). Whenever bootstrap does fail, the error is kept and re-raised only if this launch goes on to serve, in which case `arkor dev` exits 1. That deferral covers **every** bootstrap failure, not just the config-unreachable one. If instead the port is already held by an Arkor Studio serving this project, the connect path wins and the launch still exits 0, because adopting a running instance needs no credentials of its own (see [`arkor dev`](/cli/dev) for the exact recovery story). The **Studio server's lazy bootstrap** (when an `/api/*` request arrives before credentials are on disk) does the same anonymous fallback. To use an account session, run `arkor login --oauth` separately before (or after) clicking around in Studio; the credentials file is shared, so Studio picks up the account session on its next request. ## What you actually see diff --git a/docs/ja/cli/dev.mdx b/docs/ja/cli/dev.mdx index 95a7e910..3276d9fb 100644 --- a/docs/ja/cli/dev.mdx +++ b/docs/ja/cli/dev.mdx @@ -47,7 +47,7 @@ bun dev ### 起動シーケンス -1. **認証情報の初期化。** `~/.arkor/credentials.json` が無いとき、CLI は **常に匿名セッションの初期化を試みます**。`/v1/auth/cli/config` を呼び、続いて `/v1/auth/anonymous` から匿名トークンを要求します。初期化前のメッセージはデプロイが OAuth をアドバタイズしているかで分岐します。OAuth が設定されている場合は ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションを初期化します。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください)を出して、好きなタイミングで本物のアカウントへアップグレードできることを案内します。それ以外の場合は代わりに `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、OAuth ヒントは省略されます。「それ以外」には 2 つのケースが含まれます。CLI は OAuth の可否が不明なデプロイを匿名専用として扱うためで、実際に匿名専用のデプロイ(`/v1/auth/cli/config` が OAuth をアドバタイズしなかった)と、`/v1/auth/cli/config` の呼び出し自体が失敗して可否を判断できなかった場合の両方です。いずれの場合も OAuth フローを自動で起動することはありません。トークンが届くと `arkor dev` は ``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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`)を出します。デプロイが OAuth をアドバタイズしている場合に限り、成功メッセージと並んで 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` でサインインしてください)が発行され、アップグレード経路が発行時点で見えます。匿名専用デプロイでは `arkor login --oauth` を案内すると失敗するコマンドへユーザーを誘導してしまうので、warn は意図的に抑制されます。一過性のトランスポート障害(`fetch failed`)の扱いはタイミングで分かれます。`/v1/auth/cli/config` が成功してデプロイモードが特定済みのあと、`/v1/auth/anonymous` で同様の障害が出た場合のみ警告して続行し、Studio サーバーは初回の `/api/credentials` ヒットで再試行します。`/v1/auth/cli/config` 自体に到達できなかった場合はデプロイモードが特定できないため、そのトランスポートエラーは保持され、**この起動が実際にサーブへ進んだときにのみ**再スローされます(サーブには認証情報が必要)。その場合 `arkor dev` は exit 1 で終了するので、接続を回復してから再実行してください。即座に失敗させず意図的に遅延させているのは、下記のポート衝突の経路をオフラインでも機能させるためです。同じプロジェクトをサーブしている既存の Arkor Studio への接続はそれ自体に認証情報を必要としないので、そのケースでは引き続き `Arkor Studio already running on ` を出して exit 0 で終了します。`/v1/auth/anonymous` が 4xx で拒否され、**かつ**そのデプロイが OAuth をアドバタイズしている場合は、HTTP ステータスを含むエラーで `arkor login --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` を実行して再試行してください)。サインイン案内が条件付きなのは、匿名専用デプロイでは `arkor login --oauth` 自体が失敗するためで、その場合は生の拒否エラー(`Failed to acquire anonymous token (): `)がそのまま出ます。 +1. **認証情報の初期化。** `~/.arkor/credentials.json` が無いとき、CLI は **常に匿名セッションの初期化を試みます**。`/v1/auth/cli/config` を呼び、続いて `/v1/auth/anonymous` から匿名トークンを要求します。初期化前のメッセージはデプロイが OAuth をアドバタイズしているかで分岐します。OAuth が設定されている場合は ``No credentials on file. Bootstrapping an anonymous session. Run `arkor login --oauth` to sign in to your account instead.``(認証情報ファイルがありません。匿名セッションを初期化します。アカウントでサインインしたい場合は `arkor login --oauth` を実行してください)を出して、好きなタイミングで本物のアカウントへアップグレードできることを案内します。それ以外の場合は代わりに `No credentials on file. Requesting an anonymous token.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、OAuth ヒントは省略されます。「それ以外」には 2 つのケースが含まれます。CLI は OAuth の可否が不明なデプロイを匿名専用として扱うためで、実際に匿名専用のデプロイ(`/v1/auth/cli/config` が OAuth をアドバタイズしなかった)と、`/v1/auth/cli/config` の呼び出し自体が失敗して可否を判断できなかった場合の両方です。いずれの場合も OAuth フローを自動で起動することはありません。トークンが届くと `arkor dev` は ``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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`)を出します。デプロイが OAuth をアドバタイズしている場合に限り、成功メッセージと並んで 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` でサインインしてください)が発行され、アップグレード経路が発行時点で見えます。匿名専用デプロイでは `arkor login --oauth` を案内すると失敗するコマンドへユーザーを誘導してしまうので、warn は意図的に抑制されます。一過性のトランスポート障害(`fetch failed`)の扱いはタイミングで分かれます。`/v1/auth/cli/config` が成功してデプロイモードが特定済みのあと、`/v1/auth/anonymous` で同様の障害が出た場合のみ警告して続行し、Studio サーバーは初回の `/api/credentials` ヒットで再試行します。`/v1/auth/anonymous` が同様に失敗し、**かつ** `/v1/auth/cli/config` にも既に到達できていなかった場合は、デプロイモードが特定できないため警告して続行するフォールバックが安全に取れません。このときそのトランスポートエラーは保持され、**この起動が実際にサーブへ進んだときにのみ**再スローされます(サーブには認証情報が必要)。その場合 `arkor dev` は exit 1 で終了するので、接続を回復してから再実行してください。なお `/v1/auth/cli/config` に到達できないこと自体は決して致命的ではありません。この呼び出しは OAuth がアドバタイズされているかを判定するだけなので、失敗しても握り潰され、CLI は引き続き `/v1/auth/anonymous` にトークンを要求します。即座に失敗させず意図的に遅延させているのは、下記のポート衝突の経路をオフラインでも機能させるためです。同じプロジェクトをサーブしている既存の Arkor Studio への接続はそれ自体に認証情報を必要としないので、そのケースでは引き続き `Arkor Studio already running on ` を出して exit 0 で終了します。`/v1/auth/anonymous` が 4xx で拒否され、**かつ**そのデプロイが OAuth をアドバタイズしている場合は、HTTP ステータスを含むエラーで `arkor login --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` を実行して再試行してください)。サインイン案内が条件付きなのは、匿名専用デプロイでは `arkor login --oauth` 自体が失敗するためで、その場合は生の拒否エラー(`Failed to acquire anonymous token (): `)がそのまま出ます。 なお、これらのブートストラップ失敗は config 到達不可のケースだけでなく**すべて**同じ形で遅延されます。`arkor dev` は `ensureCredentialsForStudio` が投げたもの(4xx のラップ、生の拒否、ローカルの `~/.arkor/credentials.json` 書き込みエラーのいずれも)を捕捉し、この起動が実際にサーブしたときにのみ再スローします。ポート衝突の接続経路では破棄されるため、到達不可あるいはサインイン必須のデプロイに対する初回起動でも、同じプロジェクトを既にサーブしている Studio に接続して exit 0 で終了できます。 2. **CSRF トークン。** この起動用に 32 バイトのトークン(base64url、約 43 文字)を生成。同一オリジンの SPA が読めるよう `` として `index.html` にインジェクトされます。クロスオリジンタブはこの meta を読めず、`/api/*` のミドルウェアに拒否されます。 @@ -130,12 +130,15 @@ Studio サーバーはすべての `/api/*` リクエストに 3 つのチェッ | `arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.`(exit `1`) | Claude Code の厳格ゲート。`CLAUDECODE=1` が設定されていて `--agent` が渡されていない。サーバーは起動されず、トークンファイルもセッションファイルも書き込まれていない。 | `arkor dev --agent` で再実行し、出力されたセッションファイルを使う。 | | `Could not write the agent session file under /.arkor/agent (...). Agent mode requires it; aborting.`(exit `1`) | エージェントモードが `.arkor/agent/` またはセッションファイルを作成できなかった(読み取り専用のプロジェクトディレクトリー、権限の問題)。home のトークンと異なりこれは致命的で、ファイルはエージェント唯一のトークン経路。 | プロジェクトディレクトリーを書き込み可能にして(`.arkor/` の権限を確認)再実行。 | | `Could not reach (fetch failed). Studio will keep running and retry on first /api/credentials hit.`(`` に到達できませんでした(fetch 失敗)。Studio は起動を続け、初回の `/api/credentials` 受信時に再試行します) | `/v1/auth/cli/config` は成功したが、続く `/v1/auth/anonymous` がトランスポート障害で失敗。Studio サーバーは起動して再試行する。 | 接続を回復させれば `arkor dev` を再起動せずに次の `/api/credentials` ポーリングで SPA が回復。 | -| `TypeError: fetch failed`(または同等のトランスポートエラー、exit `1`) | `/v1/auth/cli/config` 自体に届かなかったため、デプロイモードが特定できない。このエラーは遅延され、この起動が実際にサーブしたときにのみ再スローされる。ポートを同一プロジェクトの Arkor Studio が既に握っている場合は接続経路が優先され、exit `0` になる。 | 接続を回復してから `arkor dev` を再実行。 | +| `TypeError: fetch failed`(または同等のトランスポートエラー、exit `1`) | **両方**の呼び出しが失敗した場合。`/v1/auth/anonymous` がトランスポートエラーになり、*かつ* `/v1/auth/cli/config` にも既に到達できていなかったため、デプロイモードが特定できず「初回リクエスト時に再試行」のフォールバックを安全に取れない。(config が成功したうえでの `/v1/auth/anonymous` 単独のトランスポート障害は、上の警告して続行する行のケース。)このエラーは遅延され、この起動が実際にサーブしたときにのみ再スローされる。ポートを同一プロジェクトの Arkor Studio が既に握っている場合は接続経路が優先され、exit `0` になる。 | 接続を回復してから `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.`(認証情報ファイルがありません。匿名トークンを要求します) | 同上だが OAuth が利用可能と判明していない場合。匿名専用デプロイ(`/v1/auth/cli/config` で OAuth がアドバタイズされていない)と、`/v1/auth/cli/config` の呼び出しが失敗したケースの両方が該当し、同じ扱いになる。動作を保証できないため 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 でセッション間でこのクライアントを識別します。同じ匿名 ID を維持するには認証情報ファイルを保持してください。パスは `credentialsPath()` の解決結果で、Linux と macOS では通常 `~/.arkor/credentials.json`) | 匿名初期化の完了後の情報行。クラウド側の識別子と、それを保持しているファイルの場所を明示する。 | 何もしなくてよい。別の環境から同じ匿名 ID を使いたいなら認証情報ファイルをバックアップ。 | | ``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 に残り、現状マイグレーション手段はない。 | -| ``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` を再実行。 | +| ``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 で拒否され、**かつ**そのデプロイが OAuth をアドバタイズしている。匿名では進めないが、サインインすれば進める。 | `arkor login --oauth` でブラウザーフローを完了してから `arkor dev` を再実行。 | +| `Failed to acquire anonymous token (): `(exit `1`) | 上のラップが適用されないときに出る生の拒否。OAuth をアドバタイズしていないデプロイでの 4xx(`arkor login --oauth` を案内しても失敗するだけなので)、または 5xx。 | 5xx なら一過性のことが多いので再試行。匿名専用デプロイでの 4xx はそのデプロイが匿名サインインを受け付けていないので、運用者に問い合わせを。 | +| `Studio server error after startup: `(警告、サーバーは継続) | バインド成功**後**に HTTP サーバーがエラーを発火した(クライアント側のソケットリセットなど)。1 本の不良接続で Studio 全体を落とさないよう、致命扱いせずログに出す。 | 通常は対処不要。繰り返す場合は `arkor dev` を再起動し、ポートを叩くプロキシやクライアントがないか確認。 | +| `Could not set permissions on : `(警告) | studio トークン、`.arkor/agent` ディレクトリー、エージェントセッションファイルへの `0600` / `0700` の `chmod` が失敗した(Windows や POSIX モードを持たないファイルシステムでよくある)。ファイル自体は書き込まれている。 | Windows では無視して問題ない。POSIX で厳格なモードに依存している場合は、ファイルシステムと親ディレクトリーの所有者を確認。 | | `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` バインドのシェルは設計通り拒否されます。 | | HTTP 403 with `{ "error": "Missing or invalid studio token" }`(studio token が欠けているか無効です。ブラウザーの devtools で確認) | ページ内の CSRF トークンが現在の起動と一致しない。たいていは前回 `arkor dev` の古いタブ。 | タブをリロード。トークンは起動ごとにローテート。 | diff --git a/docs/ja/concepts/project-structure.mdx b/docs/ja/concepts/project-structure.mdx index 2e02aaad..5fa4797c 100644 --- a/docs/ja/concepts/project-structure.mdx +++ b/docs/ja/concepts/project-structure.mdx @@ -89,7 +89,7 @@ export default {}; ## CLI が探すもの -- `arkor dev` は Studio の Web サーバーを `127.0.0.1:4000` で起動し、`~/.arkor/studio-token` を書きます。`src/arkor/` をファイル監視はしません。Studio の `/api/manifest` エンドポイントは確かに `runBuild` を呼び、bundle の content-addressed コピー経由で再 import します(無編集の再読込は Node のモジュールキャッシュを再利用し、編集はキャッシュをバストします)。これは Studio UI が駆動します。Overview ページ(`#/`)が開いている間、5 秒ごとにポーリングされます。このリビルドは **Run training** が実行するのと同じ `.arkor/build/index.mjs` に書き込むため、編集はページをリロードしなくても約 5 秒で反映されます。Studio を閉じている間はリビルドされないので、ターミナルからの `arkor start` はディスク上の成果物をそのまま実行します。`arkor dev` は単独で `.arkor/state.json` を書きません。 +- `arkor dev` は Studio の Web サーバーを `127.0.0.1:4000` で起動し、`~/.arkor/studio-token` を書きます。(例外が 1 つあります。同じプロジェクトをサーブしている Arkor Studio が既にそのポートを握っている場合、この起動はそこへ接続して exit `0` で終了し、サーバーは起動せず既存インスタンスのトークンにも触れません。[`arkor dev`](/ja/cli/dev) を参照。)`src/arkor/` をファイル監視はしません。Studio の `/api/manifest` エンドポイントは確かに `runBuild` を呼び、bundle の content-addressed コピー経由で再 import します(無編集の再読込は Node のモジュールキャッシュを再利用し、編集はキャッシュをバストします)。これは Studio UI が駆動します。Overview ページ(`#/`)が開いている間、5 秒ごとにポーリングされます。このリビルドは **Run training** が実行するのと同じ `.arkor/build/index.mjs` に書き込むため、編集はページをリロードしなくても約 5 秒で反映されます。Studio を閉じている間はリビルドされないので、ターミナルからの `arkor start` はディスク上の成果物をそのまま実行します。`arkor dev` は単独で `.arkor/state.json` を書きません。 - `arkor build` は `src/arkor/index.ts`(または渡したエントリー)を読んで `.arkor/build/index.mjs` を書きます。Studio の dev ループの外側で直接呼ぶ必要があるのは CI や、スクリプトから `arkor start` の直前に走らせる場合などです。 - `arkor start` はランナー経由でエントリーを解決し、`.arkor/build/index.mjs` がない、もしくは明示的なエントリーを渡したときはリビルドして実行します(これが `trainer.start()` と `trainer.wait()` を呼びます)。Studio から学習をトリガーすると内部的に `arkor start` がサブプロセスとして起動されます。 diff --git a/docs/ja/concepts/studio.mdx b/docs/ja/concepts/studio.mdx index 6d2fbc67..61c7aaea 100644 --- a/docs/ja/concepts/studio.mdx +++ b/docs/ja/concepts/studio.mdx @@ -42,7 +42,7 @@ Arkor マネージドバックエンド(学習、推論) この分離があるからこそ、ブラウザー側で何もログインせずに Studio が動きます。CLI は `~/.arkor/credentials.json` ですでに認証情報を持っており、Studio はローカルで動いている前提のもとその認証情報を引き継ぎます。 -`~/.arkor/credentials.json` がない場合の処理はエントリーポイントが決めます。**`arkor dev`** は起動時に匿名セッションを初期化し、必要に応じてアップグレードできるよう `arkor login --oauth` を案内する 1 行のヒントを出します。OAuth フローを自動で起動することはありません。`/v1/auth/cli/config` へ到達できないこと自体は初期化を止め**ません**。この呼び出しはデプロイが OAuth をアドバタイズしているかを判定するだけなので、失敗しても握り潰され、CLI は `/v1/auth/anonymous` にトークンを要求します。初期化が失敗するのは、この 2 回目の呼び出しも失敗したときだけです。そのとき config の呼び出しも既に失敗していれば(OAuth の可否が不明なので)、トランスポートエラーは保持され、この起動が実際にサーブへ進んだときにのみ再スローされて `arkor dev` は exit 1 で終了します。一方、同じプロジェクトをサーブしている Arkor Studio が既にポートを握っている場合は接続経路が優先され、既存インスタンスへの接続はそれ自体に認証情報を必要としないため exit 0 で終了します(具体的な復旧手順は [`arkor dev`](/ja/cli/dev) を参照)。**Studio サーバーの遅延初期化**(認証情報が無い状態で `/api/*` リクエストが届いたとき)も同じ匿名フォールバックを行います。アカウントセッションを使いたい場合は、Studio をクリックする前(あるいは後)に別途 `arkor login --oauth` を実行してください。認証情報ファイルは共有なので、Studio は次のリクエストでアカウントセッションを拾います。 +`~/.arkor/credentials.json` がない場合の処理はエントリーポイントが決めます。**`arkor dev`** は起動時に匿名セッションを初期化し、必要に応じてアップグレードできるよう `arkor login --oauth` を案内する 1 行のヒントを出します。OAuth フローを自動で起動することはありません。`/v1/auth/cli/config` へ到達できないこと自体は初期化を止め**ません**。この呼び出しはデプロイが OAuth をアドバタイズしているかを判定するだけなので、失敗しても握り潰され、CLI は `/v1/auth/anonymous` にトークンを要求します。初期化が失敗するのは、この 2 回目の呼び出しも失敗したときだけです(しかもそのときでも、単なるトランスポート障害なら復帰可能です。config の呼び出しが成功していれば警告のみで続行し、Studio サーバーが初回の `/api/credentials` で再試行します)。初期化が実際に失敗した場合、そのエラーは保持され、この起動が実際にサーブへ進んだときにのみ再スローされて `arkor dev` は exit 1 で終了します。この遅延は config 到達不能のケースだけでなく、**すべて**の初期化失敗に適用されます。一方、同じプロジェクトをサーブしている Arkor Studio が既にポートを握っている場合は接続経路が優先され、既存インスタンスへの接続はそれ自体に認証情報を必要としないため exit 0 で終了します(具体的な復旧手順は [`arkor dev`](/ja/cli/dev) を参照)。**Studio サーバーの遅延初期化**(認証情報が無い状態で `/api/*` リクエストが届いたとき)も同じ匿名フォールバックを行います。アカウントセッションを使いたい場合は、Studio をクリックする前(あるいは後)に別途 `arkor login --oauth` を実行してください。認証情報ファイルは共有なので、Studio は次のリクエストでアカウントセッションを拾います。 ## 実際に見えるもの diff --git a/e2e/cli/src/arkor-dev.test.ts b/e2e/cli/src/arkor-dev.test.ts index 880089a1..591d2818 100644 --- a/e2e/cli/src/arkor-dev.test.ts +++ b/e2e/cli/src/arkor-dev.test.ts @@ -22,10 +22,17 @@ describe("arkor dev × CLAUDECODE strict gate (E2E)", () => { it("exits 1 with the --agent re-invocation block under CLAUDECODE=1 without --agent", async () => { const dir = makeTempDir("arkor-dev-e2e-"); try { - const result = await runCli(ARKOR_BIN, ["dev"], dir, { - HOME: dir, - CLAUDECODE: "1", - }); + // Bounded: if the gate ever regresses, `arkor dev` serves forever. + // Without a cap this test would hang until vitest's timeout AND leave a + // live server bound to port 4000 on the runner. 30s is far above a + // normal exit-1 (tens of ms) and well under the vitest file timeout. + const result = await runCli( + ARKOR_BIN, + ["dev"], + dir, + { HOME: dir, CLAUDECODE: "1" }, + 30_000, + ); expect(result.code).toBe(1); expect(result.stderr).toContain( "arkor dev: CLAUDECODE=1 detected. Interactive Studio use is disabled.", diff --git a/e2e/cli/src/spawn-cli.ts b/e2e/cli/src/spawn-cli.ts index 7c6bac7c..6fbde9e1 100644 --- a/e2e/cli/src/spawn-cli.ts +++ b/e2e/cli/src/spawn-cli.ts @@ -174,6 +174,15 @@ export async function runCli( argv: string[], cwd: string, extraEnv: NodeJS.ProcessEnv = {}, + /** + * Hard wall-clock cap. `runCli` waits for process exit, so a command that + * is supposed to terminate but regresses into a long-running server (the + * `arkor dev` strict gate is the live example) would otherwise hang until + * vitest's own timeout AND leave the child running, still holding whatever + * port it bound. With this set the child is killed and the call rejects + * with an explicit message. Omit it for commands that exit on their own. + */ + timeoutMs?: number, ): Promise { // Only the darwin + CI combination can ever fire the retry, so only // that combination pays the snapshot cost. Linux/Windows CI jobs and @@ -182,7 +191,7 @@ export async function runCli( const canRetry = process.platform === "darwin" && Boolean(process.env.CI); const snapshotDir = canRetry ? snapshotCwd(cwd) : undefined; try { - let result = await runCliOnce(binPath, argv, cwd, extraEnv); + let result = await runCliOnce(binPath, argv, cwd, extraEnv, timeoutMs); if ( snapshotDir !== undefined && shouldRetryAfterSigkill(result, process.platform, Boolean(process.env.CI)) @@ -191,7 +200,7 @@ export async function runCli( `[runCli] retrying after SIGKILL at ${result.elapsedMs}ms (${binPath} ${argv.join(" ")})\n`, ); restoreFromSnapshot(cwd, snapshotDir); - result = await runCliOnce(binPath, argv, cwd, extraEnv); + result = await runCliOnce(binPath, argv, cwd, extraEnv, timeoutMs); } return result; } finally { @@ -250,6 +259,7 @@ function runCliOnce( argv: string[], cwd: string, extraEnv: NodeJS.ProcessEnv, + timeoutMs?: number, ): Promise { // `pnpm test` propagates the workspace's pnpm config to children as // `npm_config_*` / `pnpm_config_*` env vars (e.g. minimumReleaseAge from @@ -449,16 +459,48 @@ function runCliOnce( reject(err instanceof Error ? err : new Error(String(err))); return; } + // Wall-clock guard. SIGKILL directly rather than SIGTERM-then-escalate: + // the point is to stop a child that is NOT going to exit on its own, and + // a graceful stop would let `arkor dev`'s shutdown handlers run and mask + // the hang as a normal exit. + let timedOut = false; + const timer = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + }, timeoutMs); + const clearTimer = (): void => { + if (timer !== undefined) clearTimeout(timer); + }; const out: Buffer[] = []; const err: Buffer[] = []; child.stdout?.on("data", (c: Buffer) => out.push(c)); child.stderr?.on("data", (c: Buffer) => err.push(c)); child.on("error", (err) => { + clearTimer(); cleanup(); reject(err); }); child.on("close", (code, signal) => { + clearTimer(); cleanup(); + if (timedOut) { + reject( + new Error( + `runCli timed out after ${String(timeoutMs)}ms and killed the child: ` + + `${binPath} ${argv.join(" ")}\n` + + `stdout so far: ${Buffer.concat(out).toString("utf8")}\n` + + `stderr so far: ${Buffer.concat(err).toString("utf8")}`, + ), + ); + return; + } resolve({ code: code ?? -1, signal, diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts index 296854a5..611026c2 100644 --- a/packages/arkor/src/cli/commands/dev.test.ts +++ b/packages/arkor/src/cli/commands/dev.test.ts @@ -72,7 +72,7 @@ import { type AnonymousCredentials, } from "../../core/credentials"; -import { ensureCredentialsForStudio, runDev } from "./dev"; +import { ensureCredentialsForStudio, isUntrustedPeerPath, runDev } from "./dev"; let fakeHome: string; const ORIG_HOME = process.env.HOME; @@ -106,6 +106,63 @@ afterEach(() => { rmSync(fakeHome, { recursive: true, force: true }); }); +describe("isUntrustedPeerPath", () => { + // The adoption filter's /proc family is exercised end to end by the + // "no /proc/self/cwd bypass" test below, but the /dev/fd and UNC branches + // cannot be: they never resolve to the project root, so a probe using them + // falls back for the wrong reason and the branch could be deleted unnoticed. + // Pin them directly instead. + it("rejects the process-relative /proc family, including traversal", () => { + for (const p of [ + "/proc", + "/proc/self/cwd", + "/proc/1234/cwd", + "/proc/thread-self/cwd", + // `..` traversal must not sneak past the prefix test. + "/proc/self/../self/cwd", + ]) { + expect({ p, rejected: isUntrustedPeerPath(p) }).toEqual({ + p, + rejected: true, + }); + } + }); + + it("rejects the /dev/fd family", () => { + for (const p of ["/dev/fd", "/dev/fd/1"]) { + expect({ p, rejected: isUntrustedPeerPath(p) }).toEqual({ + p, + rejected: true, + }); + } + }); + + it("accepts ordinary absolute project paths, including near-miss prefixes", () => { + for (const p of [ + "/home/u/proj", + "/", + // Near misses: the guard must match path SEGMENTS, not raw prefixes, + // or these legitimate directories would be refused. + "/procfs-notreally", + "/dev/fdisk", + "/home/u/proc", + ]) { + expect({ p, rejected: isUntrustedPeerPath(p) }).toEqual({ + p, + rejected: false, + }); + } + }); + + it.runIf(process.platform === "win32")("rejects UNC paths on Windows", () => { + // Windows-only on purpose: `path.posix.normalize` collapses a leading + // `//` to `/`, so this branch cannot fire on POSIX. Asserting it there + // would encode the opposite of the real behaviour. + expect(isUntrustedPeerPath("//host/share")).toBe(true); + expect(isUntrustedPeerPath(String.raw`\\host\share`)).toBe(true); + }); +}); + describe("ensureCredentialsForStudio", () => { it("returns immediately when credentials already exist", async () => { const creds: AnonymousCredentials = { @@ -922,6 +979,7 @@ describe("runDev", () => { stdoutSpy.mockRestore(); } expect(existsSync(studioTokenPath())).toBe(true); + const ourToken = readFileSync(studioTokenPath(), "utf8"); // Pull the most-recently-registered exit listener and invoke it; that // exercises the unlinkSync(path) branch of installShutdownHandlers. @@ -930,9 +988,17 @@ describe("runDev", () => { cleanup(); expect(existsSync(studioTokenPath())).toBe(false); - // A second invocation must short-circuit (the `cleaned` guard) so it - // doesn't throw on the now-missing file. + // A second invocation must short-circuit on the `cleaned` guard: the body + // must not run twice. Asserting only `not.toThrow()` could not detect the + // guard's removal, because every fs call inside the cleanup closure is + // already individually try/caught, so a second run is silent either way. + // Re-create the file with OUR OWN token instead: the ownership check would + // then happily match, so `cleaned` is the only thing left that can save it. + // Without the guard the second call deletes this file. + writeFileSync(studioTokenPath(), ourToken); expect(() => cleanup()).not.toThrow(); + expect(existsSync(studioTokenPath())).toBe(true); + expect(readFileSync(studioTokenPath(), "utf8")).toBe(ourToken); }); describe("agent mode", () => { diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts index 2d24e5b4..a51cbb8f 100644 --- a/packages/arkor/src/cli/commands/dev.ts +++ b/packages/arkor/src/cli/commands/dev.ts @@ -352,10 +352,19 @@ async function readCapped( * the CSRF token or reach `POST /api/train`. The same-project comparison exists * to stop a DIFFERENT project's Studio (the common, benign collision) from * being adopted, and for that non-adversarial job it is exact. + * + * Exported for tests only. Only the `/proc/self/cwd` family is reachable as an + * end-to-end adoption bypass (it is the one that resolves to our own cwd); the + * `/dev/fd` and UNC branches resolve to something that could never equal the + * project root, so they cannot be driven through `probeExistingStudio` into a + * false adoption and are covered by direct unit tests instead. */ -function isUntrustedPeerPath(p: string): boolean { +export function isUntrustedPeerPath(p: string): boolean { // Normalise separators so `/proc/self/../self/cwd` and Windows-style input - // cannot sneak past a naive prefix test. + // cannot sneak past a naive prefix test. Note the UNC branch below is + // effectively Windows-only: `path.posix.normalize` collapses a leading `//` + // to `/`, so on POSIX it never fires. That is fine (UNC resolution is a + // Windows hazard), but do not read it as cross-platform coverage. const norm = normalize(p).replaceAll("\\", "/"); return ( norm === "/proc" || diff --git a/packages/arkor/src/core/projectState.test.ts b/packages/arkor/src/core/projectState.test.ts index bbb92ef5..4ceae8a6 100644 --- a/packages/arkor/src/core/projectState.test.ts +++ b/packages/arkor/src/core/projectState.test.ts @@ -320,6 +320,47 @@ describe("ensureProjectState", () => { } }); + it("does not emit a trailing hyphen when the 40-char truncation lands on a separator", async () => { + // Regression: the dash-trim used to run BEFORE the 40-char truncation, so + // a basename whose 40th character is a separator got the hyphen put back + // by the cut (`<39 chars>-tail` -> `<39 chars>-`). Slugs feed a URL path, + // so a trailing hyphen is both ugly and a needless 409-collision risk. + const captured: { name: string; slug: string }[] = []; + const createProject = vi + .fn() + .mockImplementation(async (input: { name: string; slug: string }) => { + captured.push({ name: input.name, slug: input.slug }); + return { + project: { id: "pid", slug: input.slug, name: input.name }, + }; + }); + const client = fakeClient({ + createProject: + createProject as unknown as CloudApiClient["createProject"], + }); + + const parent = mkdtempSync(join(tmpdir(), "trunc-")); + const { mkdirSync } = await import("node:fs"); + // 39 safe chars, then the separator that lands exactly on the boundary. + const longName = `${"a".repeat(39)}-tail`; + const dir = join(parent, longName); + mkdirSync(dir); + + try { + const state = await ensureProjectState({ + cwd: dir, + client, + credentials: anonCreds, + }); + expect(state.projectSlug).toBe("a".repeat(39)); + expect(state.projectSlug).not.toMatch(/-$/); + expect(state.projectSlug.length).toBeLessThanOrEqual(40); + expect(captured[0]?.slug).toBe("a".repeat(39)); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + it("falls back to 'project' as the basename when cwd has no extractable component", async () => { // `"".split(/[/\\]/).filter(Boolean).pop()` is `undefined`. Without // the `?? "project"` fallback, basename would be undefined and slug diff --git a/packages/arkor/src/core/projectState.ts b/packages/arkor/src/core/projectState.ts index 14799236..47c17dc5 100644 --- a/packages/arkor/src/core/projectState.ts +++ b/packages/arkor/src/core/projectState.ts @@ -154,9 +154,13 @@ export async function ensureProjectState( // unambiguously O(n). let start = 0; while (start < dashy.length && dashy[start] === "-") start++; - let end = dashy.length; + // Truncate to the 40-char budget BEFORE the trailing-dash trim, not after. + // Trimming first lets the cut re-introduce the very hyphen the trim exists + // to remove: a directory whose 40th character is a separator (e.g. + // `<39 chars>-tail`) yielded the slug `<39 chars>-`. + let end = Math.min(dashy.length, start + 40); while (end > start && dashy[end - 1] === "-") end--; - const projectSlug = dashy.slice(start, end).slice(0, 40) || "project"; + const projectSlug = dashy.slice(start, end) || "project"; let project: { id: string; slug: string }; try { diff --git a/packages/arkor/src/studio/server.test.ts b/packages/arkor/src/studio/server.test.ts index 8ced09b0..5bb31e1b 100644 --- a/packages/arkor/src/studio/server.test.ts +++ b/packages/arkor/src/studio/server.test.ts @@ -823,6 +823,82 @@ process.exit(0); expect(text).toContain("exit=0"); }); + // The whole child-reaping mechanism (`liveTrainChildren`, the refcounted + // `process.on("exit", killLiveTrainChildren)` attach, and the + // `detachKillHook` release) had no coverage: deleting any part of it left + // this suite green, even though dev.ts's shutdown handlers depend on it to + // avoid orphaning a training child (and leaking a GPU) on `docker stop`. + it("attaches ONE refcounted exit hook while a child is live, kills the child through it, and detaches at zero", async () => { + await writeCredentials(ANON_CREDS); + // A child that would outlive the request unless something kills it. + const sleepBin = join(trainCwd, "sleep-bin.mjs"); + writeFileSync( + sleepBin, + 'process.stdout.write("[sleeping]\\n");\nsetTimeout(() => { process.exit(0); }, 30_000);\n', + ); + const app = buildStudioApp({ + baseUrl: "http://mock", + assetsDir, + autoAnonymous: false, + studioToken: STUDIO_TOKEN, + cwd: trainCwd, + binPath: sleepBin, + }); + + const before = process.listeners("exit").length; + const res = await app.request("/api/train", { + method: "POST", + headers: { + host: "127.0.0.1:4000", + "x-arkor-studio-token": STUDIO_TOKEN, + "content-type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(200); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let seen = ""; + try { + // Read until the child has actually started (proves it is spawned and + // therefore registered) rather than sleeping a fixed interval. + while (!seen.includes("[sleeping]")) { + const { value, done } = await reader.read(); + if (done) break; + seen += decoder.decode(value, { stream: true }); + } + expect(seen).toContain("[sleeping]"); + + // Exactly one hook, not one per spawn (a per-spawn listener would trip + // MaxListenersExceededWarning past 10 concurrent runs). + const during = process.listeners("exit"); + expect(during.length).toBe(before + 1); + + // Invoking the hook must kill the live child. Without it the child + // would sit in its 30s timer and orphan. + const hook = during.at(-1) as () => void; + hook(); + + // Drain: the stream terminates because the child died. + while (true) { + const { value, done } = await reader.read(); + if (done) break; + seen += decoder.decode(value, { stream: true }); + } + // Killed by signal, so not a clean exit=0. + expect(seen).not.toContain("exit=0"); + } finally { + reader.releaseLock(); + } + + // ...and the hook is released once no child is live, so `arkor dev` does + // not accumulate listeners across runs. + await vi.waitFor(() => { + expect(process.listeners("exit").length).toBe(before); + }); + }); + it("surfaces ENOENT-grade errors when binPath does not exist", async () => { await writeCredentials(ANON_CREDS); const app = buildStudioApp({