diff --git a/AGENTS.md b/AGENTS.md
index 01635ce7..8c60f3c3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -67,13 +67,14 @@ 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` (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 ``.
-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.
@@ -115,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..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` を実行してください。
@@ -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..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.
@@ -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 b3172940..6938428e 100644
--- a/docs/cli/dev.mdx
+++ b/docs/cli/dev.mdx
@@ -39,48 +39,106 @@ 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 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. |
## Behavior
### 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; 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.
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.
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.
+
+`--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.
+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` 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) 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.
+
+### 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` 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.
+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:
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.
+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
-`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 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.
+**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 |
| --- | --- | --- |
-| ``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 `. |
+| `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. |
| `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`) | **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 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`. |
+| ``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. |
@@ -131,8 +189,43 @@ 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** (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)
+TOKEN=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token' "$SESSION_FILE")
+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
- [`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..a8455ea2 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. |
@@ -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
@@ -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/concepts/project-structure.mdx b/docs/concepts/project-structure.mdx
index ee65622e..14249b03 100644
--- a/docs/concepts/project-structure.mdx
+++ b/docs/concepts/project-structure.mdx
@@ -82,6 +82,7 @@ You should not commit this directory.
One caveat after `arkor logout`: logout removes only `credentials.json`, so a `.arkor/state.json` left by the previous anonymous identity survives. The next anonymous session gets a new organization and **ignores** the stale file on read paths (lists stay empty, Studio still opens), but the write paths above refuse to reuse or overwrite it (it may be a hand-maintained OAuth scope) and fail with a 409 / error telling you to delete `.arkor/state.json` to start a fresh anonymous project, or `arkor login` with the account that owns it.
- **`.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)
@@ -89,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`. (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 03907deb..571d5739 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 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
@@ -22,9 +22,9 @@ 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.
+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
@@ -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
@@ -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. 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/guides/cli/dev.mdx b/docs/guides/cli/dev.mdx
index 9e67f309..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.
@@ -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 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
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/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 1b8a2c68..3276d9fb 100644
--- a/docs/ja/cli/dev.mdx
+++ b/docs/ja/cli/dev.mdx
@@ -39,48 +39,106 @@ 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` のプレーンな 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` を併用する。 |
## 振る舞い
### 起動シーケンス
-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.`(認証情報ファイルがありません。匿名トークンを要求します)を出し、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/*` のミドルウェアに拒否されます。
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 ワークフローだけです。
プロセス終了時(通常終了、`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 サーバーをヘッドレスで起動します。フラグは純粋なオプトインで、環境変数は不要です。
+
+`--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 になります。セッションファイルはエージェント唯一のトークン受け渡し経路であり、これが無いサーバーは呼び出し元から静かに使用不能になるためです。
+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/*` ルートです(ループバック + 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 チェックが安全でないためです。下記のように最新ファイルを選べば、自分の起動より前にクラッシュしたセッションの残骸を掴むことはありません。自分より後に起動してクラッシュしたセッションだけがより新しいファイルを残し得るので、その点でも出力されたパスを使うのが確実です。
+
+エージェントモードでも Studio SPA は引き続き配信されます。エージェントが作業している間、人間は出力された URL をブラウザーで開けますし、下の「ループバックと CSRF モデル」のガードもすべてそのまま適用されます。
+
+### Claude Code(`CLAUDECODE=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.
+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 つのチェックを課します。
1. `Host` ヘッダーは `127.0.0.1` か `localhost`(DNS リバインディング対策)。
-2. CSRF トークンは `X-Arkor-Studio-Token` ヘッダーか `?studioToken=...`(カスタムヘッダーを送れない `EventSource` 用)として必須。比較には `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 を読めず、過去の起動の古いタブはトークンが一致せず、別オリジンの攻撃者ページはリクエストを偽造できません。
+これにより**ブラウザー**側の攻撃面は塞がれます。別タブは meta を読めず、過去の起動の古いタブはトークンが一致せず、別オリジンの攻撃者ページはリクエストを偽造できません。
+
+ただしこれは、共有マシン上の**別のローカルユーザー**に対する防御ではありません。ループバックにソケットを開けるものは `GET /` で配信された `index.html` からトークンをそのまま読み取り、それを使って `/api/*` を叩けます。`arkor dev` はそのマシン上のすべてのローカルプロセスを信頼するものとして扱ってください。マルチユーザーのホストでは、他のユーザーが到達できるポートで起動しないでください。
### ポート競合
-`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 は占有者をプローブします。トークン不要の `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 つ目のインスタンスが依存するトークンを上書き・削除することはありません。
+**採用の信頼モデル。** 接続採用は、占有者の自己申告する `/api/status`(トークン不要)だけを信頼し、相手を**認証しません**。共有のマルチユーザーホストでは、ローカルのプロセスが先にポートをバインドして一致する `/api/status` を返せば、あなたの `arkor dev` に「already running」と表示させて終了させたり(自分の起動を妨害される程度)、`--open` 時にそのローカルオリジンでブラウザーを開かせることができます。これは意図した安全側のトレードオフです。プローブは**トークンを一切送らない**ので、偽装者が CSRF トークンを得たり RCE 相当の `POST /api/train` に到達することは決してできません。最悪でもリダイレクトや妨害で、コード実行には至りません。信頼できない共有環境で接続動作を望まない場合は、空いている `--port` を明示してください。
+
## エラー
| 症状 | 意味 | 対処 |
| --- | --- | --- |
-| ``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 `。 |
+| `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/` の権限を確認)再実行。 |
| `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/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.`(認証情報ファイルがありません。匿名トークンを要求します) | 同上だが匿名専用デプロイの場合(`/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` を再実行。 |
+| ``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` の古いタブ。 | タブをリロード。トークンは起動ごとにローテート。 |
@@ -131,8 +189,43 @@ 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 する場合は辞書順の先頭ではなく **最新**(`ls -t`)を選んでください。クラッシュした前回セッションが古い `session-*.json` を残していることがあり、それを拾うと死んだトークンを掴みます。単一セッションの通常ケースでは最新があなたのライブセッションですが、同一プロジェクトで `arkor dev --agent` を同時に 2 つ動かす場合は、出力されたパスだけが一意です。
+
+```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")
+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 で実際に何が起きるか
- [`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..61c06ff5 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` でピッカーをスキップして使い捨てトークン。 |
@@ -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` ヘッダー(あれば)はメッセージのフォーマットに使われ、それ単独で警告をトリガーすることはありません。
## 環境変数
@@ -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/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/project-structure.mdx b/docs/ja/concepts/project-structure.mdx
index a4ea74f1..5fa4797c 100644
--- a/docs/ja/concepts/project-structure.mdx
+++ b/docs/ja/concepts/project-structure.mdx
@@ -75,6 +75,7 @@ export default {};
`arkor logout` 後の注意点: logout は `credentials.json` だけを削除するため、前の匿名 ID が残した `.arkor/state.json` はそのまま残ります。次の匿名セッションは新しい組織になり、read 経路ではこの古いファイルを**無視**します(一覧は空のまま、Studio も開けます)が、上記の write 経路は(手動管理された OAuth スコープの可能性があるため)それを再利用も上書きもせず、409/エラーで「`.arkor/state.json` を削除して新しい匿名プロジェクトを開始するか、そのプロジェクトを所有するアカウントで `arkor login` してください」と案内して失敗します。
- **`.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/`(ユーザーごと)
@@ -82,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` を書きます。(例外が 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 12f176e2..61c7aaea 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** ボタンを持つ Overview ページ(`#/`)はこれを 5 秒ごとにポーリングします。このリビルドは **Run training** が実行するのと同じ `.arkor/build/index.mjs` に書き込むため、`src/arkor/` の編集はページをリロードしなくても約 5 秒で反映されます。ただしこれはファイルシステムのウォッチャーではなく、開いているページが駆動するポーリングです。Studio を閉じている間はリビルドされませんし、ターミナルからの `arkor start` はディスク上にある成果物をそのまま実行します。
## Studio が動く場所
@@ -22,9 +22,9 @@ 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 を動かせます。
+サーバーはループバックにのみバインドし、ループバック以外の `Host` ヘッダーのリクエストは拒否します。公開 URL もインバウンド接続もなく、トークンは `arkor dev` のたびにローテートされます。これで**ブラウザー**側の攻撃面とリモートからの攻撃面は塞がれます。ただし共有マシン上の**別のローカルユーザー**に対する防御ではありません。ループバックにソケットを開けるものは `GET /` で配信された `index.html` からトークンを読み取れます。マルチユーザーのホストでは、`arkor dev` はローカルの全プロセスを信頼するものとして扱ってください(詳細は [`arkor dev`](/ja/cli/dev) を参照)。
## マネージドバックエンドとの関係
@@ -32,7 +32,7 @@ Studio は CLI の動作を可視化するための画面にすぎません。CL
```
Studio(ブラウザータブ)
- │ ループバック上の /api/*、CSRF トークン必須
+ │ ループバック上の /api/*、CSRF トークン必須(GET /api/status を除く)
▼
arkor CLI(ローカル)
│ 認証付き HTTPS
@@ -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` へ到達できないこと自体は初期化を止め**ません**。この呼び出しはデプロイが 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/docs/ja/guides/cli/dev.mdx b/docs/ja/guides/cli/dev.mdx
index 472f6bd4..ae2182ed 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` は、占有者が同一プロジェクトを提供する Studio のときに起動中のインスタンスへ接続してそのまま終了します(そうでなければ通常の port-in-use エラーになります)。詳細は [`arkor dev` リファレンス](/ja/cli/dev)の「エージェントモード」を参照してください。
+
## リファレンス
全フラグ、ループバックと CSRF のセキュリティモデル、トラブルシューティング(ポート競合、匿名初期化、トークン期限、403 の理由)は [`arkor dev` リファレンス](/ja/cli/dev) を参照してください。
diff --git a/docs/ja/quickstart.mdx b/docs/ja/quickstart.mdx
index 5597f55e..726114dd 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 つあります:
@@ -154,7 +158,7 @@ bun dev
- **Loss チャートとイベントログ。** マネージド GPU から進捗がストリームされるにつれて、Loss(学習中のモデルの誤差を示す数値で、低いほどモデルが正解に近づいているサイン)の曲線が更新され、ログのテールに学習イベントが表示されます。最初の学習はテンプレートにより 7〜12 分かかります。
- **Playground。** ジョブが完了したら、最終アダプターをセレクターから選んでチャット。モード切替でベースモデルとアダプターを行き来できます。学習中に中間チェックポイントで推論を走らせたい場合は Studio ではなく `onCheckpoint` コールバックを使ってください。
-学習の合間に `src/arkor/` を編集した場合は、Run training ページをリロード(または `arkor build` を実行)してから次のクリックをすると、新しいコードが動きます。
+学習の合間に `src/arkor/` を編集した場合、Overview ページを開いたままなら約 5 秒で自動的に反映されます(マニフェストのポーリングがビルドを再実行します)。リロードは不要です。
## 4. アカウントに紐づける(任意)
@@ -193,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/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/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 486e9534..63135bd3 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
@@ -36,8 +36,8 @@ 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` を使用しているため、タイミング攻撃に対して安全です。
+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`)を素通りさせてしまうので、トークンチェックがそれを拒否します。
トークンは `arkor dev` 起動のたびにローテートされるので、前回起動時の古いタブはリロードするまで HTTP 403 で失敗します。
@@ -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/quickstart.mdx b/docs/quickstart.mdx
index 50b76772..a74e36d0 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:
@@ -154,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)
@@ -193,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/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/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/docs/studio/overview.mdx b/docs/studio/overview.mdx
index 8c7af2ed..f4c7cbe5 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
@@ -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.
@@ -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. |
@@ -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/cli/src/arkor-dev.test.ts b/e2e/cli/src/arkor-dev.test.ts
new file mode 100644
index 00000000..591d2818
--- /dev/null
+++ b/e2e/cli/src/arkor-dev.test.ts
@@ -0,0 +1,91 @@
+// 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 { existsSync } from "node:fs";
+import { join } from "node:path";
+
+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 {
+ // 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.",
+ );
+ 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");
+ // 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);
+ }
+ });
+
+ 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 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.
+ 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 {
+ 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/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/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/seedFixture.ts b/e2e/studio/src/harness/seedFixture.ts
index d730c63e..82560b3f 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,11 +16,24 @@ 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 {
- 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/e2e/studio/src/harness/studioServer.ts b/e2e/studio/src/harness/studioServer.ts
index 899ef63a..fb86ecd1 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,30 @@ 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.
+//
+// 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;
@@ -68,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;
@@ -187,43 +218,118 @@ interface SpawnedStudio {
kill: () => Promise;
stderr: TailBuffer;
stdout: TailBuffer;
+ /** Agent-mode session file path (see `StudioHandle.sessionFile`). */
+ 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_")) {
+ 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;
+ });
+ // 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 });
+ });
+ });
+}
+
+async function spawnStudio(opts: StartStudioOptions): Promise {
+ const port = await getEphemeralPort();
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"],
- 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),
},
);
@@ -276,17 +382,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 +427,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 +454,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,14 +463,28 @@ 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 };
}
/**
* 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).
*/
@@ -391,12 +511,12 @@ 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()`
- // 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);
@@ -408,5 +528,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..d5ae3a1e
--- /dev/null
+++ b/e2e/studio/src/specs/agent.spec.ts
@@ -0,0 +1,155 @@
+import { existsSync, readFileSync, statSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+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-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.
+
+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");
+ // 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.
+ const port = new URL(agentStudio.url).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
+ // 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 is token-exempt and returns safe agent metadata", async ({
+ agentStudio,
+ }) => {
+ // 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");
+ 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(
+ readSession(agentStudio.sessionFile!).token,
+ );
+ });
+
+ test("a token-guarded route still 403s without the token header", async ({
+ agentStudio,
+ }) => {
+ // 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);
+ });
+
+ 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 }) => {
+ // 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();
+ 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 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 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`);
+ 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/README.md b/packages/arkor/README.md
index 9b2d1083..2f017606 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,20 @@ 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.
+- **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.
+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
@@ -115,6 +124,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
@@ -149,12 +164,18 @@ 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`)
+ 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/bin.ts b/packages/arkor/src/bin.ts
index 651b09ef..25ee4577 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 if (err instanceof Error && err.name === "ProjectStateMismatchError") {
// A recoverable, expected setup conflict (a stale anonymous
// `.arkor/state.json` after `arkor logout`): print the actionable
diff --git a/packages/arkor/src/cli/commands/dev.test.ts b/packages/arkor/src/cli/commands/dev.test.ts
index bc479e31..611026c2 100644
--- a/packages/arkor/src/cli/commands/dev.test.ts
+++ b/packages/arkor/src/cli/commands/dev.test.ts
@@ -6,10 +6,12 @@ import {
readdirSync,
readFileSync,
rmSync,
+ symlinkSync,
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";
@@ -34,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";
@@ -48,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;
@@ -82,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 = {
@@ -615,7 +696,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"),
);
@@ -664,7 +752,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();
}
@@ -682,10 +772,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.toBeUndefined();
+ 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
@@ -705,6 +805,7 @@ describe("runDev", () => {
}
} finally {
stdoutSpy.mockRestore();
+ warnSpy.mockRestore();
// Restore writable for afterEach rmSync.
chmodSync(join(fakeHome, ".arkor"), 0o755);
}
@@ -777,7 +878,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();
}
@@ -821,8 +924,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.
@@ -867,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.
@@ -875,8 +988,633 @@ 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", () => {
+ 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;
+ };
+ // 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-]+$/);
+ // 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.
+ expect(readFileSync(studioTokenPath(), "utf8")).toBe(payload.token);
+ // Agent mode never opens a browser implicitly.
+ expect(open).not.toHaveBeenCalled();
+ });
+
+ 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;
+ }
+ const stdout = await runDevCapturingStdout({
+ port: 4311,
+ agent: true,
+ cwd: projectDir,
+ });
+ const sessionPath = sessionPathFrom(stdout);
+ const { statSync } = await import("node:fs");
+ 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);
+ // 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([]);
+ });
+
+ 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,
+ 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 (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;
+ }
+ // 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 {
+ // 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.toMatchObject({
+ name: "ExpectedCliError",
+ message: expect.stringMatching(/Could not write the agent session/),
+ });
+ } 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);
+ });
+
+ 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,
+ cwd: projectDir,
+ });
+ const sessionPath = sessionPathFrom(stdout);
+ const dir = join(projectDir, ".arkor", "agent");
+ // 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(`${sessionPath}.tmp`)).toBe(false);
+ // The co-located same-pid session is untouched.
+ expect(existsSync(otherSameP)).toBe(true);
+ });
+
+ 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 });
+ 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);
+ });
+ });
+
+ 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((() => {
+ 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);
+ }
+
+ /** 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 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;
+ const stdoutSpy = vi
+ .spyOn(process.stdout, "write")
+ .mockImplementation((() => true) as typeof process.stdout.write);
+ try {
+ 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",
+ );
+ } finally {
+ stdoutSpy.mockRestore();
+ }
+ // 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 | undefined,
+ ];
+ 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 (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);
+ });
+
+ it("honors --open against the existing instance", async () => {
+ mockServeAddrInUse();
+ 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, cwd: projectDir }),
+ ).resolves.toEqual({ adopted: true });
+ } finally {
+ stdoutSpy.mockRestore();
+ }
+ 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.
+ 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 reports a RELATIVE cwd (no `.`-bypass of the project match)", async () => {
+ // 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: process.cwd() })).rejects.toThrow(
+ /Port 4327 is already in use/,
+ );
+ });
+
+ 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.
+ // 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
+ // `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/,
+ );
+ });
+
+ it("falls back when the probe request itself fails (timeout / refused)", async () => {
+ mockServeAddrInUse();
+ 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, 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();
+ 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 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 () => {
+ // 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/),
+ },
+ );
+ });
+
+ 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();
+ 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) => {
+ // 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 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 {
+ timeoutSpy.mockRestore();
+ }
+ });
+
+ 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 });
+ // 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;
+ 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();
+ }
+ expect(closeSpy).toHaveBeenCalled();
+ });
});
});
diff --git a/packages/arkor/src/cli/commands/dev.ts b/packages/arkor/src/cli/commands/dev.ts
index 0ad076ad..a51cbb8f 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 { readFileSync, 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 } from "node:path";
+import { dirname, isAbsolute, join, normalize } from "node:path";
+import { ExpectedCliError } from "@arkor/cli-internal";
import { serve } from "@hono/node-server";
import open from "open";
@@ -25,6 +26,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;
}
/**
@@ -35,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;
@@ -129,8 +148,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 },
);
@@ -202,6 +223,235 @@ async function persistStudioToken(token: string): Promise {
return path;
}
+interface AgentSessionPayload {
+ token: string;
+ url: string;
+ port: number;
+ pid: number;
+}
+
+/** `/.arkor/agent`. */
+function agentDirFor(projectRoot: string): string {
+ return join(projectRoot, ".arkor", "agent");
+}
+
+/**
+ * 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 ensureAgentDir(projectRoot: string): Promise {
+ const dir = agentDirFor(projectRoot);
+ await mkdir(join(projectRoot, ".arkor"), { recursive: true });
+ await mkdir(dir, { recursive: true });
+ try {
+ await chmod(dir, 0o700);
+ } catch (err) {
+ ui.log.warn(
+ `Could not set permissions on ${dir}: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ }
+ return dir;
+}
+
+/**
+ * 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,
+ });
+ 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;
+ }
+}
+
+/**
+ * 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");
+}
+
+/**
+ * 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.
+ *
+ * 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.
+ */
+export function isUntrustedPeerPath(p: string): boolean {
+ // Normalise separators so `/proc/self/../self/cwd` and Windows-style input
+ // 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" ||
+ norm.startsWith("/proc/") ||
+ norm === "/dev/fd" ||
+ norm.startsWith("/dev/fd/") ||
+ norm.startsWith("//")
+ );
+}
+
+/**
+ * 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,
+): Promise {
+ try {
+ const res = await fetch(
+ new URL("/api/status", `http://127.0.0.1:${port}`),
+ {
+ 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, removing that blind-request
+ // 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",
+ },
+ );
+ if (!res.ok) return false;
+ // 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;
+ // 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;
+ // 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`,
+ // which on macOS is typically the unresolved `/var/...` form.
+ return realpathSync(b.cwd) === realpathSync(projectRoot);
+ } catch {
+ return false;
+ }
+}
+
/**
* Install the process-lifetime shutdown handlers, running `cleanup` (once)
* on normal exit and on SIGINT/SIGTERM/SIGHUP before re-exiting.
@@ -210,8 +460,10 @@ async function persistStudioToken(token: string): Promise {
* 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 {
@@ -235,28 +487,88 @@ function installShutdownHandlers(cleanup: () => void): void {
}
}
-export async function runDev(options: DevOptions = {}): Promise {
- await ensureCredentialsForStudio();
+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 {
+ // 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;
+ 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).
+ // 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");
- // `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`
// 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
+ // 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",
+ // 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,
+ });
await new Promise((resolve, reject) => {
// Tracks whether the listener has BOUND (the `listening` callback fired)
@@ -307,13 +619,88 @@ export async function runDev(options: DevOptions = {}): Promise {
} 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
+ }
+ }
+ }
});
- // 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 () => {
+ // 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
+ // 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 {
+ await ensureAgentDir(projectRoot);
+ // 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,
+ // 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,
+ });
+ } catch (err) {
+ try {
+ server.close();
+ } catch {
+ // best-effort
+ }
+ // 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;
+ }
+ }
+ // 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) {
@@ -324,6 +711,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,14 +748,53 @@ 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.`,
- ),
+ // 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 above.
+ 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) {
+ // 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-
+ // 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(port, projectRoot);
+ if (ok) {
+ try {
+ server.close();
+ } catch {
+ // best-effort
+ }
+ adopted = true;
+ process.stdout.write(`Arkor Studio already running on ${url}\n`);
+ resolve();
+ return;
+ }
+ reject(portInUse);
+ })();
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,
+ }),
+ );
});
});
@@ -369,4 +805,6 @@ export async function runDev(options: DevOptions = {}): Promise {
// fall through
}
}
+
+ return { adopted };
}
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.test.ts b/packages/arkor/src/cli/main.test.ts
index 24bac45d..6c3dab28 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),
}));
@@ -63,6 +78,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;
@@ -196,14 +213,129 @@ 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("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 expect(main(["dev", "--port", "not-a-number"])).rejects.toThrow(
+ /port/i,
+ );
+ 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("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", "080", "00"]) {
+ 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("falls back to port 4000 when --port is non-numeric", async () => {
- // Branch coverage for the `Number(opts.port) || 4000` defaulting.
- await main(["dev", "--port", "not-a-number"]);
- expect(runDev).toHaveBeenCalledWith({ port: 4000, open: 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("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 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"]);
+ 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 () => {
diff --git a/packages/arkor/src/cli/main.ts b/packages/arkor/src/cli/main.ts
index 14c390a3..97ddfa82 100644
--- a/packages/arkor/src/cli/main.ts
+++ b/packages/arkor/src/cli/main.ts
@@ -1,5 +1,7 @@
import {
ClaudeCodeStrictExit,
+ ExpectedCliError,
+ formatClaudeCodeAgentModeMessage,
formatClaudeCodeMissingMessage,
isClaudeCode,
missingClaudeCodeFlags,
@@ -22,6 +24,33 @@ 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 {
+ // 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 (!/^[1-9]\d*$/.test(raw) || !Number.isInteger(n) || n < 1 || n > 65_535) {
+ throw new ExpectedCliError(
+ `--port must be a plain decimal 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);
@@ -239,20 +268,45 @@ 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")
- .action(
- withTelemetry(
+ .option(
+ "--agent",
+ "Run headlessly for coding agents: write a JSON session token file to .arkor/agent/ and print its path",
+ )
+ .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 }) => {
- await runDev({
- port: Number(opts.port) || 4000,
+ 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
+ // --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();
+ }
+ 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/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/core/telemetry.test.ts b/packages/arkor/src/core/telemetry.test.ts
index 538a1a4d..dd2b8e8e 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,58 @@ 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. 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,
+ });
+ await wrapped();
+ expect(captureMock).toHaveBeenCalledTimes(1);
+ 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/arkor/src/core/telemetry.ts b/packages/arkor/src/core/telemetry.ts
index 06f3cf74..da72420a 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,21 @@ export function withTelemetry(
}
try {
await handler(...args);
- if (identity && !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,
duration_ms: Date.now() - start,
diff --git a/packages/arkor/src/studio/server.test.ts b/packages/arkor/src/studio/server.test.ts
index c8989460..5bb31e1b 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,60 @@ 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
+ // 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", {
@@ -263,6 +364,143 @@ describe("Studio server", () => {
expect(res.status).toBe(403);
});
+ describe("GET /api/status", () => {
+ 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(200);
+ });
+
+ 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" },
+ });
+ expect(res.status).toBe(403);
+ });
+
+ 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("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
+ // 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" },
+ });
+ 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");
+ // 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");
+ });
+
+ 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",
+ // 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: {
+ 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://127.0.0.1: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(
@@ -585,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({
diff --git a/packages/arkor/src/studio/server.ts b/packages/arkor/src/studio/server.ts
index 3c3aae70..1fa7066d 100644
--- a/packages/arkor/src/studio/server.ts
+++ b/packages/arkor/src/studio/server.ts
@@ -71,11 +71,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;
/**
@@ -90,6 +91,21 @@ 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";
+ /**
+ * 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;
}
function tokensMatch(provided: string, expected: string): boolean {
@@ -194,7 +210,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 =
@@ -270,6 +298,48 @@ export function buildStudioApp(options: StudioServerOptions) {
// validate against one identity and send with another. State is only ever
// ignored, never deleted, so a hand-maintained OAuth scope is preserved.
+ // 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). 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) =>
+ 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,
@@ -803,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.
@@ -1072,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);
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..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:
@@ -275,3 +283,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/errors.ts b/packages/cli-internal/src/errors.ts
new file mode 100644
index 00000000..4b2a8a34
--- /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, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "ExpectedCliError";
+ }
+}
diff --git a/packages/cli-internal/src/index.ts b/packages/cli-internal/src/index.ts
index 7fd486a7..1314721a 100644
--- a/packages/cli-internal/src/index.ts
+++ b/packages/cli-internal/src/index.ts
@@ -27,11 +27,13 @@ export {
type NodeModulesSnapshot,
} from "./install";
export { gitInitialCommit, isInGitRepo, type InitialCommitResult } from "./git";
+export { ExpectedCliError } from "./errors";
export { sanitise } from "./sanitise";
export {
ClaudeCodeStrictExit,
isClaudeCode,
missingClaudeCodeFlags,
+ formatClaudeCodeAgentModeMessage,
formatClaudeCodeMissingMessage,
type ClaudeCodeOptionsCheck,
type MissingClaudeCodeFlag,
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 (`&&`