diff --git a/README.en.md b/README.en.md
index e66d9d09c..19c0c4f92 100644
--- a/README.en.md
+++ b/README.en.md
@@ -38,7 +38,16 @@
---
-## 30-second quickstart
+## Quick Start
+
+Requirements: Node.js >= 22.13.0, npm >= 10, and Bun >= 1.2.0. The Hub uses
+`Bun.serve` / `bun:sqlite`, so Bun is required rather than optional:
+
+```bash
+curl -fsSL https://bun.sh/install | bash
+# Open a new shell, then verify:
+bun --version && bunx --version
+```
```bash
# Install one global package
@@ -70,7 +79,7 @@ anet project restart # restart cwd nodes against the new version
Full cross-version migration reference: [Upgrade Guide](https://anet.sh/en/guide/upgrade).
-Prereq: Node.js ≥ 22.13.0 (required by `@inquirer/prompts` and friends; older versions trip `EBADENGINE` warnings during install but still work).
+On the first `anet node start`, the CLI fetches `agent-node` through `npx` when no global binary exists; a second global install is not required. SDK runtimes that require an API key block create/start when the key is blank, avoiding nodes that look online but cannot execute a task.
---
@@ -131,7 +140,7 @@ flowchart LR
H -.- DB[(SQLite
~/.commhub)]
```
-Node onboarding flow (0 to online in 30 seconds):
+Node onboarding flow (elapsed time depends on first-run downloads and network speed):
```mermaid
flowchart LR
@@ -214,7 +223,7 @@ Apache-2.0, published to npm. `anet upgrade` bumps all four to `latest`.
| [`@sleep2agi/commhub-server`](https://www.npmjs.com/package/@sleep2agi/commhub-server) | MCP + REST + SSE hub (SQLite-backed) |
| [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | Web Dashboard — Next.js 16, 7 panels |
-The CLI auto-fetches the hub and node packages on first use via `bunx` / `npx`; you only ever globally install one. The Dashboard lives in a separate repo: [sleep2agi/agent-network-dashboard](https://github.com/sleep2agi/agent-network-dashboard).
+The CLI fetches the hub and node packages on first use via `bunx` / `npx`. You only globally install `agent-network`, but Bun itself must already be installed because it is the Hub runtime and is not installed automatically. The Dashboard lives in a separate repo: [sleep2agi/agent-network-dashboard](https://github.com/sleep2agi/agent-network-dashboard).
---
@@ -278,7 +287,7 @@ All four packages Apache 2.0, **Stable on npm `latest`**. Release cadence and fu
PRs welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, branch naming, and the test matrix layout. By contributing you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md).
-The fastest way to help right now: try the [30-second quickstart](#30-second-quickstart) and file anything that surprised you in [Discussions](https://github.com/sleep2agi/agent-network/discussions) or [Issues](https://github.com/sleep2agi/agent-network/issues).
+The fastest way to help right now: try the [Quick Start](#quick-start) and file anything that surprised you in [Discussions](https://github.com/sleep2agi/agent-network/discussions) or [Issues](https://github.com/sleep2agi/agent-network/issues).
---
diff --git a/README.md b/README.md
index e4bc4d77d..dba3a47ac 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,16 @@
---
-## 30 秒上手
+## 快速上手
+
+前置:Node.js >= 22.13.0、npm >= 10、Bun >= 1.2.0。Hub 使用
+`Bun.serve` / `bun:sqlite`,所以 Bun 不是可选项:
+
+```bash
+curl -fsSL https://bun.sh/install | bash
+# 重开终端后确认
+bun --version && bunx --version
+```
```bash
# 装一个全局包
@@ -70,7 +79,7 @@ anet project restart # 重启 cwd 节点接新版
完整跨版本迁移参考 [升级指南](https://anet.sh/guide/upgrade)。
-前置:Node.js ≥ 22.13.0(`@inquirer/prompts` 等依赖要求;老版本会触发 `EBADENGINE` warnings,不影响安装)。
+首次 `anet node start` 会在没有全局 `agent-node` 时通过 `npx` 拉取它;不需要手动安装第二个全局包。需要 API key 的 SDK runtime 会在 key 为空时阻止创建/启动,避免出现“在线但不能执行任务”的节点。
---
@@ -131,7 +140,7 @@ flowchart LR
H -.- DB[(SQLite
~/.commhub)]
```
-节点接入流程(从 0 到上线 30 秒):
+节点接入流程(实际耗时取决于首次下载和本机网络):
```mermaid
flowchart LR
@@ -214,7 +223,7 @@ Apache-2.0,已发 npm。`anet upgrade` 一键全升 `latest`。
| [`@sleep2agi/commhub-server`](https://www.npmjs.com/package/@sleep2agi/commhub-server) | MCP + REST + SSE 通信中枢(SQLite 持久化) |
| [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | Web Dashboard —— Next.js 16,7 大面板 |
-CLI 第一次用到 hub 和 node 时会自动用 `bunx` / `npx` 拉对应包,全局只需装一个 `agent-network`。Dashboard 是独立 repo:[sleep2agi/agent-network-dashboard](https://github.com/sleep2agi/agent-network-dashboard)。
+CLI 第一次用到 hub 和 node 时会用 `bunx` / `npx` 拉对应包;全局只需安装 `agent-network`,但本机必须预装 Bun(Hub 的运行时,不会自动安装)。Dashboard 是独立 repo:[sleep2agi/agent-network-dashboard](https://github.com/sleep2agi/agent-network-dashboard)。
---
@@ -278,7 +287,7 @@ tests/ Docker 测试矩阵
欢迎 PR。环境搭建、分支命名、测试矩阵详见 [CONTRIBUTING.md](./CONTRIBUTING.md)。提交即代表同意 [Code of Conduct](./CODE_OF_CONDUCT.md)。
-最快帮上忙的方式:跑一遍上面的 [30 秒上手](#30-秒上手),把任何"咦?"的地方发到 [Discussions](https://github.com/sleep2agi/agent-network/discussions) 或 [Issues](https://github.com/sleep2agi/agent-network/issues)。
+最快帮上忙的方式:跑一遍上面的 [快速上手](#快速上手),把任何"咦?"的地方发到 [Discussions](https://github.com/sleep2agi/agent-network/discussions) 或 [Issues](https://github.com/sleep2agi/agent-network/issues)。
---
diff --git a/agent-network/README.md b/agent-network/README.md
index 8f14766bc..111cef00b 100644
--- a/agent-network/README.md
+++ b/agent-network/README.md
@@ -33,6 +33,16 @@ Install Bun if needed:
curl -fsSL https://bun.sh/install | bash
```
+Open a new shell and verify both executables before starting the Hub:
+
+```bash
+bun --version
+bunx --version
+```
+
+`anet hub start` checks this prerequisite before spawning anything and prints
+the install command when it is missing. It never installs Bun automatically.
+
## Install
Stable:
@@ -102,6 +112,10 @@ anet node start my-bot
2. Provider preset: Anthropic, MiniMax, InternLM, Xiaomi MiMo, or `custom` (any Anthropic-compatible endpoint — used for DeepSeek / GLM / Kimi / OpenRouter etc.; codex-sdk for OpenAI Codex; grok-build-acp for xAI Grok).
3. API key and model settings.
+For `claude-agent-sdk`, an empty provider key blocks node creation/start instead
+of creating a node that can register but cannot answer. CLI-auth runtimes and
+separately configured keyless runtimes keep their own authentication flows.
+
When the node starts successfully, look for:
```text
diff --git a/agent-network/bin/cli.ts b/agent-network/bin/cli.ts
index 33b845e6d..aab663775 100644
--- a/agent-network/bin/cli.ts
+++ b/agent-network/bin/cli.ts
@@ -321,10 +321,55 @@ interface Profile {
// Re-export from the pure helper module (src/normalize-runtime.ts) so
// unit tests can import without dragging in CLI side-effects.
-import { normalizeRuntime, type RuntimeName } from "../src/normalize-runtime";
+import {
+ normalizeRuntime,
+ parseExplicitRuntime,
+ parseStoredRuntime,
+ runtimeSkipsCreateVendorPicker,
+ runtimeUsesAgentNode,
+ type RuntimeName,
+} from "../src/normalize-runtime";
+import {
+ bunHubPrerequisiteIssue,
+ providerCredentialIssue,
+ resolveAgentNodeLaunch,
+} from "../src/onboarding-guards";
import { findEnvironAliasMatches } from "../src/environ-alias";
export { normalizeRuntime, type RuntimeName };
+function explicitRuntimeOrExit(raw: string, source: string): RuntimeName {
+ const runtime = parseExplicitRuntime(raw);
+ if (runtime) return runtime;
+ console.error(`[anet] ❌ Unsupported runtime "${raw}" from ${source}.`);
+ console.error(`[anet] Supported: claude-agent-sdk, claude-code-cli, codex-sdk, codex-app-server, grok-build-acp, opencode-cli`);
+ process.exit(2);
+}
+
+function assertProviderCredential(
+ runtime: RuntimeName,
+ env: Record,
+ phase: "create" | "start",
+ profileRole?: string,
+): void {
+ const issue = providerCredentialIssue(runtime, env, {
+ launchIntent: phase === "create" ? "node-create" : "node-start",
+ profileRole,
+ });
+ if (!issue) return;
+ console.error(`[anet] ❌ ${issue}.`);
+ console.error(`[anet] ${phase === "create" ? "Provide a non-empty key in the wizard or pass --env ANTHROPIC_AUTH_TOKEN=." : "Set it in the node config/.env or export it before start."}`);
+ console.error(`[anet] CLI-auth and separately configured keyless runtimes are not affected by this check.`);
+ process.exit(1);
+}
+
+function storedRuntimeOrExit(profile: Profile, source: string): RuntimeName {
+ const runtime = parseStoredRuntime(profile);
+ if (runtime) return runtime;
+ console.error(`[anet] ❌ Unsupported runtime "${profile.runtime}" from ${source}.`);
+ console.error(`[anet] Refusing to launch a different executable; fix the stored config explicitly.`);
+ process.exit(2);
+}
+
function nodeDisplayName(id: string, profile?: Profile | null): string {
return profile?.node_name || profile?.name || profile?.alias || id;
}
@@ -780,6 +825,13 @@ function printVersionReport() {
const versions = detectInstalledPackages();
console.log(`anet v${versions.anet.version}\n`);
+ if (commandExists("bunx")) {
+ console.log("Hub runtime: bunx available ✓");
+ } else {
+ console.log("Hub runtime: Bun/bunx missing — required before `anet hub start`");
+ console.log(" Install: curl -fsSL https://bun.sh/install | bash\n");
+ }
+
console.log("Components (auto-fetched on first use, you don't need to install them manually):");
console.log(` ${formatLazyComponent(versions.agentNode)}`);
if (versions.agentNode.state === "ok") {
@@ -815,6 +867,7 @@ function installGlobalPackage(pkgName: string) {
function printDetectedPackagesForSetup() {
const versions = detectInstalledPackages();
console.log(`检测已安装的包...`);
+ console.log(` ${commandExists("bunx") ? "✅ Bun/bunx(Hub runtime)" : "❌ Bun/bunx 未安装(anet hub start 必需;安装: curl -fsSL https://bun.sh/install | bash)"}`);
console.log(` ✅ anet v${versions.anet.version}`);
console.log(` ${isInstalled(versions.agentNode) ? "✅" : "❌"} ${formatDetectedVersion(versions.agentNode)}`);
console.log(` ${isInstalled(versions.claude) ? "✅" : "❌"} ${formatDetectedVersion(versions.claude)}`);
@@ -1099,6 +1152,7 @@ anet — AI Agent Network CLI (V2)
Node Management:
anet node create Create a new agent node
+ --runtime claude-agent-sdk | claude-code-cli | codex-sdk | codex-app-server | grok-build-acp | opencode-cli
anet node start Start a node
anet node start --all Start every node in cwd (= anet project up)
anet node stop Stop a running node
@@ -1380,7 +1434,9 @@ function createProfileFromOpts(id: string, opts: ReturnType):
// Default to claude-agent-sdk — works with any Anthropic-compatible API
// (MiniMax/DeepSeek/GLM/Kimi/Anthropic). claude-code-cli only works for Max/Pro
// subscribers and was a poor default that left non-subscribers with broken nodes.
- const runtime = normalizeRuntime(opts.runtime || "claude-agent-sdk");
+ const runtime = opts.runtime
+ ? explicitRuntimeOrExit(opts.runtime, "--runtime")
+ : normalizeRuntime("claude-agent-sdk");
const defaultModel =
runtime === "codex-sdk" || runtime === "codex-app-server" ? "gpt-5.5" : undefined;
@@ -1426,6 +1482,7 @@ function createProfileFromOpts(id: string, opts: ReturnType):
},
...(opts.session || runtime === "claude-code-cli" ? { session: opts.session || randomUUID() } : {}),
};
+ assertProviderCredential(runtime, { ...process.env, ...envMap }, "create");
return profile;
}
@@ -2045,7 +2102,7 @@ async function createInteractiveCommand() {
This wizard creates one agent node for this project:
- node config: .anet/nodes//config.json
- - runtime: claude-code-cli / codex-sdk / claude-agent-sdk / grok-build-acp
+ - runtime: claude-code-cli / codex-sdk / codex-app-server / claude-agent-sdk / grok-build-acp / opencode-cli
- optional Telegram channel: text + images from an allowlist user
`);
@@ -2077,7 +2134,7 @@ This wizard creates one agent node for this project:
{ value: "claude-agent-sdk", name: "claude-agent-sdk — 任意 OpenAI/Anthropic-compat vendor (intern / MiniMax / Claude / GLM / ...)" },
{ value: "claude-code-cli", name: "claude-code-cli — Anthropic Claude (Max/Pro plan), 复用 `claude` CLI 登录态" },
{ value: "codex-sdk", name: "codex-sdk — OpenAI Codex, 复用 `codex auth login` 登录态" },
- { value: "codex-app-server", name: "codex-app-server — OpenAI Codex TUI 桥 (RFC-030), 独立 `codex app-server`, 可接管已有 codex 会话" },
+ { value: "codex-app-server", name: "codex-app-server — OpenAI Codex TUI runtime (RFC-030 preview; production locked)" },
{ value: "grok-build-acp", name: "grok-build-acp — Grok Build ACP, 复用 `grok` CLI 登录态" },
// RFC-029 — public sst/opencode CLI (multi-vendor front-end
// with unified session + auth abstraction). Runtime not yet
@@ -2099,8 +2156,7 @@ This wizard creates one agent node for this project:
console.log(`[anet] 请确保已执行: codex auth login`);
} else if (pickedRuntime === "codex-app-server") {
opts.runtime = "codex-app-server";
- console.log(`[anet] 请确保已执行: codex auth login (codex-app-server 需要 codex CLI)`);
- console.log(`[anet] 接管已有 codex 会话:在 config.json 里设 codexAppServerUrl + codexThreadId`);
+ console.log(`[anet] 请确保已执行: codex auth login (RFC-030 preview;production 仍锁定)`);
} else if (pickedRuntime === "grok-build-acp") {
opts.runtime = "grok-build-acp";
console.log(`[anet] 请确保已安装并登录 Grok Build CLI: grok auth login`);
@@ -2246,7 +2302,7 @@ async function createCommand(idOverride?: string) {
const id = idOverride || args[1];
if (!id) return createInteractiveCommand();
if (id.startsWith("--")) {
- console.error("Usage: anet node create [--runtime claude-code-cli|codex-sdk|claude-agent-sdk|grok-build-acp] [--model ...] [--tools ...]");
+ console.error("Usage: anet node create [--runtime claude-code-cli|codex-sdk|codex-app-server|claude-agent-sdk|grok-build-acp|opencode-cli] [--model ...] [--tools ...]");
console.error("Or run fully interactive: anet node create");
process.exit(1);
}
@@ -2258,6 +2314,9 @@ async function createCommand(idOverride?: string) {
}
const opts = parseOpts();
+ // Validate an explicit flag before touching Hub state or opening a picker.
+ // A typo must never be normalized into a different executable.
+ if (opts.runtime) explicitRuntimeOrExit(opts.runtime, "--runtime");
const gc = loadGlobal();
// ── Check hub connection BEFORE asking for model/key ──
@@ -2290,8 +2349,8 @@ async function createCommand(idOverride?: string) {
);
const credAlreadyProvided = !!process.env.ANTHROPIC_AUTH_TOKEN
|| !!process.env.ANTHROPIC_API_KEY || envFlagHasAuth;
- const explicitRuntime = opts.runtime ? normalizeRuntime(opts.runtime) : undefined;
- const runtimeAlreadyExplicit = explicitRuntime === "codex-sdk" || explicitRuntime === "claude-code-cli" || explicitRuntime === "grok-build-acp";
+ const explicitRuntime = opts.runtime ? explicitRuntimeOrExit(opts.runtime, "--runtime") : undefined;
+ const runtimeAlreadyExplicit = explicitRuntime ? runtimeSkipsCreateVendorPicker(explicitRuntime) : false;
const skipInteractive = credAlreadyProvided || runtimeAlreadyExplicit;
// #133 selectRuntime — runtime-first, exported as a helper so create paths
@@ -2305,7 +2364,9 @@ async function createCommand(idOverride?: string) {
{ value: "claude-agent-sdk", name: "claude-agent-sdk — 任意 OpenAI/Anthropic-compat vendor (intern / MiniMax / Claude / GLM / ...)" },
{ value: "claude-code-cli", name: "claude-code-cli — Anthropic Claude (Max/Pro plan), 复用 `claude` CLI 登录态" },
{ value: "codex-sdk", name: "codex-sdk — OpenAI Codex, 复用 `codex auth login` 登录态" },
+ { value: "codex-app-server", name: "codex-app-server — OpenAI Codex TUI runtime (RFC-030 preview; production locked)" },
{ value: "grok-build-acp", name: "grok-build-acp — Grok Build ACP, 复用 `grok` CLI 登录态" },
+ { value: "opencode-cli", name: "opencode-cli — public opencode ACP runtime" },
],
});
return picked as any;
@@ -2327,8 +2388,12 @@ async function createCommand(idOverride?: string) {
console.log("[anet] 请确保已安装 Claude Code CLI 并登录: claude auth login");
} else if (opts.runtime === "codex-sdk") {
console.log("[anet] 请确保已执行: codex auth login");
+ } else if (opts.runtime === "codex-app-server") {
+ console.log("[anet] 请确保已执行: codex auth login(RFC-030 preview;production 仍锁定)");
} else if (opts.runtime === "grok-build-acp") {
console.log("[anet] 请确保已安装并登录 Grok Build CLI: grok auth login");
+ } else if (opts.runtime === "opencode-cli") {
+ console.log("[anet] opencode-cli explicit runtime: SDK vendor picker skipped; existing opencode setup requirements are unchanged.");
} else {
// Either claude-agent-sdk (explicit / picker-default) or undefined runtime
// — fall through to vendor selection. credAlreadyProvided also skips since
@@ -2804,7 +2869,10 @@ async function launchAgent(id: string, forceNewSession = false) {
}
const { id: nodeId, profile } = resolved;
- const runtime = normalizeRuntime(profile);
+ const runtime = storedRuntimeOrExit(
+ profile,
+ `node config for "${nodeDisplayName(nodeId, profile)}"`,
+ );
const displayName = nodeDisplayName(nodeId, profile);
const session = profileSession(profile);
const willResume = !!session && !forceNewSession;
@@ -2854,12 +2922,7 @@ async function launchAgent(id: string, forceNewSession = false) {
}
} catch {}
- if (
- runtime === "codex-sdk" ||
- runtime === "claude-agent-sdk" ||
- runtime === "grok-build-acp" ||
- runtime === "opencode-cli"
- ) {
+ if (runtimeUsesAgentNode(runtime)) {
// spawn agent-node
const agentArgs = [
"--config", join(nodesDir(), nodeId, "config.json"),
@@ -2936,14 +2999,12 @@ async function launchAgent(id: string, forceNewSession = false) {
console.log(`[anet] loaded ${Object.keys(_dotenvSDK).length} key(s) from .anet/nodes/${nodeId}/.env`);
}
Object.assign(env, resolveProfileEnv(profile.env as any, home, _dotenvSDK));
+ assertProviderCredential(runtime, env, "start", profile.role);
// Try agent-node from PATH, fallback to npx
- let cmd = "agent-node";
- let commandArgs = agentArgs;
- try { execSync("which agent-node", { stdio: "pipe" }); } catch {
- cmd = "npx";
- commandArgs = ["-y", "@sleep2agi/agent-node@preview", ...agentArgs];
- }
+ const launch = resolveAgentNodeLaunch(commandExists("agent-node"), agentArgs);
+ const cmd = launch.command;
+ const commandArgs = launch.args;
// W1 supervisor wrap (RFC-024, #284 superviseChild) — handle the
// sentinel exit code 75 (BSD EX_TEMPFAIL, agent-node's "config-apply
// says please respawn me with the new config" signal) by re-spawning
@@ -3607,8 +3668,9 @@ async function serverCommand() {
// The post-spawn 15s /health poll then a Bun-missing check (see
// ~30 lines down) cannot rescue this — spawn ENOENT throws before
// the poll loop ever runs.
- if (!commandExists("bunx") && !commandExists("bun")) {
- console.error(`\n ❌ anet hub start requires the Bun runtime (commhub-server is bun-only — uses Bun.serve + bun:sqlite, no Node fallback).`);
+ const bunIssue = bunHubPrerequisiteIssue(commandExists("bunx"));
+ if (bunIssue) {
+ console.error(`\n ❌ ${bunIssue} (commhub-server is bun-only — uses Bun.serve + bun:sqlite, no Node fallback).`);
console.error(`\n Install Bun first:`);
console.error(` curl -fsSL https://bun.sh/install | bash`);
console.error(` # restart your shell so PATH picks up ~/.bun/bin`);
@@ -9485,6 +9547,12 @@ async function createBatchWizardCommand() {
return;
}
+ // A scripted custom batch may provide --runtime. Validate it before Hub
+ // access or filesystem writes; typos must never become the default SDK.
+ const explicitCustomRuntime = opts.preset === "__custom__" && opts.runtime
+ ? explicitRuntimeOrExit(opts.runtime, "--preset __custom__ --runtime")
+ : undefined;
+
const gc = loadGlobal();
if (!gc.hub) {
console.error("[anet] 未找到 CommHub Server。先运行 'anet hub start' 或 'anet init --hub '");
@@ -9507,8 +9575,12 @@ async function createBatchWizardCommand() {
// derive requiresAuth from the runtime choice.
let requiresAuth: "claude" | "codex" | undefined;
if (opts.preset === "__custom__") {
- const customRuntime = await ask("Runtime (claude-agent-sdk / codex-sdk / claude-code-cli)", "claude-agent-sdk");
- runtime = normalizeRuntime(customRuntime);
+ const customRuntime = explicitCustomRuntime
+ || explicitRuntimeOrExit(
+ await ask("Runtime (claude-agent-sdk / codex-sdk / claude-code-cli)", "claude-agent-sdk"),
+ "--preset __custom__ prompt",
+ );
+ runtime = customRuntime;
baseUrl = (await ask("ANTHROPIC_BASE_URL (空白=Anthropic default)", "")) || undefined;
model = (await ask("Model id", "")) || undefined;
presetLabel = `custom (${runtime}${model ? " + " + model : ""})`;
diff --git a/agent-network/src/normalize-runtime.test.ts b/agent-network/src/normalize-runtime.test.ts
index d070f74c1..2c5e51b5d 100644
--- a/agent-network/src/normalize-runtime.test.ts
+++ b/agent-network/src/normalize-runtime.test.ts
@@ -8,7 +8,27 @@
// Explicit `claude-code-cli` choice still works.
import { describe, expect, test } from "bun:test";
-import { normalizeRuntime } from "./normalize-runtime";
+import {
+ normalizeRuntime,
+ parseExplicitRuntime,
+ parseStoredRuntime,
+ runtimeSkipsCreateVendorPicker,
+ runtimeUsesAgentNode,
+} from "./normalize-runtime";
+
+describe("parseExplicitRuntime — explicit CLI/config input fails closed", () => {
+ test("unknown and blank values are rejected instead of defaulted", () => {
+ expect(parseExplicitRuntime("totally-unknown-runtime")).toBeNull();
+ expect(parseExplicitRuntime("")).toBeNull();
+ });
+
+ test("canonical values and documented aliases are accepted", () => {
+ expect(parseExplicitRuntime("claude-agent-sdk")).toBe("claude-agent-sdk");
+ expect(parseExplicitRuntime("agent-sdk")).toBe("claude-agent-sdk");
+ expect(parseExplicitRuntime("codex-app-server")).toBe("codex-app-server");
+ expect(parseExplicitRuntime("opencode")).toBe("opencode-cli");
+ });
+});
describe("normalizeRuntime — fallback default is claude-agent-sdk (Vincent no-Max)", () => {
test("unknown string → claude-agent-sdk (was claude-code-cli pre-2026-06-28)", () => {
@@ -137,3 +157,38 @@ describe("normalizeRuntime — profile object paths", () => {
expect(normalizeRuntime({ runtime: "bogus-runtime-name" } as any)).toBe("claude-agent-sdk");
});
});
+
+describe("parseStoredRuntime — strict launch boundary", () => {
+ test("legacy agent-sdk + codex marker keeps the codex-sdk launcher", () => {
+ expect(parseStoredRuntime({ runtime: "agent-sdk", codexRuntime: "codex" })).toBe("codex-sdk");
+ });
+
+ test("unknown non-empty stored runtime is rejected", () => {
+ expect(parseStoredRuntime({ runtime: "bogus-runtime-name" })).toBeNull();
+ });
+
+ test("missing and empty legacy slots retain the historical default", () => {
+ expect(parseStoredRuntime({})).toBe("claude-agent-sdk");
+ expect(parseStoredRuntime({ runtime: "" })).toBe("claude-agent-sdk");
+ });
+});
+
+describe("runtime launcher policy", () => {
+ test("all runtimes except claude-code-cli use agent-node", () => {
+ for (const runtime of [
+ "claude-agent-sdk",
+ "codex-sdk",
+ "codex-app-server",
+ "grok-build-acp",
+ "opencode-cli",
+ ] as const) {
+ expect(runtimeUsesAgentNode(runtime)).toBeTrue();
+ }
+ expect(runtimeUsesAgentNode("claude-code-cli")).toBeFalse();
+ });
+
+ test("codex-app-server explicit create skips the SDK vendor picker", () => {
+ expect(runtimeSkipsCreateVendorPicker("codex-app-server")).toBeTrue();
+ expect(runtimeSkipsCreateVendorPicker("claude-agent-sdk")).toBeFalse();
+ });
+});
diff --git a/agent-network/src/normalize-runtime.ts b/agent-network/src/normalize-runtime.ts
index 59280c5e5..0daa01f6a 100644
--- a/agent-network/src/normalize-runtime.ts
+++ b/agent-network/src/normalize-runtime.ts
@@ -22,50 +22,106 @@ export type RuntimeName =
/** Operator-facing default for any runtime slot that comes in empty / missing / unrecognized. */
export const DEFAULT_RUNTIME: RuntimeName = "claude-agent-sdk";
+/**
+ * Runtimes launched through agent-node rather than the dedicated Claude Code
+ * CLI branch. Keep this switch exhaustive: a newly-added runtime must make an
+ * explicit launcher choice at compile time instead of falling through.
+ */
+export function runtimeUsesAgentNode(runtime: RuntimeName): boolean {
+ switch (runtime) {
+ case "claude-agent-sdk":
+ case "codex-sdk":
+ case "codex-app-server":
+ case "grok-build-acp":
+ case "opencode-cli":
+ return true;
+ case "claude-code-cli":
+ return false;
+ default: {
+ const exhaustive: never = runtime;
+ return exhaustive;
+ }
+ }
+}
+
+/** Explicit runtime choices that do not belong in the SDK vendor picker. */
+export function runtimeSkipsCreateVendorPicker(runtime: RuntimeName): boolean {
+ switch (runtime) {
+ case "claude-agent-sdk":
+ return false;
+ case "claude-code-cli":
+ case "codex-sdk":
+ case "codex-app-server":
+ case "grok-build-acp":
+ case "opencode-cli":
+ return true;
+ default: {
+ const exhaustive: never = runtime;
+ return exhaustive;
+ }
+ }
+}
+
+/**
+ * Parse an operator-supplied runtime without applying a default.
+ *
+ * `normalizeRuntime` intentionally remains tolerant for old profiles with a
+ * missing runtime. CLI flags and persisted non-empty runtime fields are a
+ * different trust boundary: silently mapping a typo to another runtime can
+ * launch the wrong executable, so callers must reject `null`.
+ */
+export function parseExplicitRuntime(runtime: string): RuntimeName | null {
+ if (
+ runtime === "codex-app-server" ||
+ runtime === "codex-appserver" ||
+ runtime === "codex-tui"
+ ) return "codex-app-server";
+ if (runtime === "codex" || runtime === "codex-sdk") return "codex-sdk";
+ if (runtime === "grok" || runtime === "grok-build" || runtime === "grok-build-acp") {
+ return "grok-build-acp";
+ }
+ if (
+ runtime === "claude" ||
+ runtime === "claude-sdk" ||
+ runtime === "claude-agent-sdk" ||
+ runtime === "agent-sdk"
+ ) return "claude-agent-sdk";
+ if (runtime === "claude-code-cli") return "claude-code-cli";
+ if (runtime === "opencode" || runtime === "opencode-cli") return "opencode-cli";
+ return null;
+}
+
// Subset of Profile fields this helper inspects. Keeping it narrow so
// the test fixture doesn't need the full Profile shape and so callers
// (bin/cli.ts uses the full Profile) can pass anything structurally
// compatible.
-type ProfileLike = {
+export type ProfileLike = {
runtime?: string;
codexRuntime?: string;
};
+/**
+ * Strict parser for a profile loaded from disk.
+ *
+ * Missing/empty runtime remains the historical default. A non-empty unknown
+ * value is rejected, while the pre-canonical legacy hybrid keeps its original
+ * meaning (`agent-sdk` + `codexRuntime=codex` was the Codex SDK runtime).
+ */
+export function parseStoredRuntime(profile?: ProfileLike): RuntimeName | null {
+ if (!profile || profile.runtime === undefined || profile.runtime === "") {
+ return DEFAULT_RUNTIME;
+ }
+ if (profile.runtime === "agent-sdk" && profile.codexRuntime === "codex") {
+ return "codex-sdk";
+ }
+ return parseExplicitRuntime(profile.runtime);
+}
+
export function normalizeRuntime(profileOrRuntime?: ProfileLike | string): RuntimeName {
if (typeof profileOrRuntime === "string") {
- // RFC-030 — codex TUI bridge (standalone `codex app-server`). Aliases:
- // `codex-tui` / `codex-app-server` / `codex-appserver`. Checked BEFORE
- // the `codex`/`codex-sdk` branch so the more specific names win.
- if (
- profileOrRuntime === "codex-app-server" ||
- profileOrRuntime === "codex-appserver" ||
- profileOrRuntime === "codex-tui"
- ) return "codex-app-server";
- if (profileOrRuntime === "codex" || profileOrRuntime === "codex-sdk") return "codex-sdk";
- if (
- profileOrRuntime === "grok" ||
- profileOrRuntime === "grok-build" ||
- profileOrRuntime === "grok-build-acp"
- ) return "grok-build-acp";
- if (
- profileOrRuntime === "claude" ||
- profileOrRuntime === "claude-sdk" ||
- profileOrRuntime === "claude-agent-sdk"
- ) return "claude-agent-sdk";
- if (profileOrRuntime === "agent-sdk") return "claude-agent-sdk";
- // Preserve EXPLICIT `claude-code-cli` choice — operators who
- // actually want CC-CLI still get it.
- if (profileOrRuntime === "claude-code-cli") return "claude-code-cli";
- // RFC-029 — opencode CLI runtime (public sst/opencode). Aliases:
- // `opencode` (short), `opencode-cli` (canonical, matches
- // claude-code-cli precedent).
- if (profileOrRuntime === "opencode" || profileOrRuntime === "opencode-cli") return "opencode-cli";
- return DEFAULT_RUNTIME;
+ return parseExplicitRuntime(profileOrRuntime) ?? DEFAULT_RUNTIME;
}
const p = profileOrRuntime;
if (!p) return DEFAULT_RUNTIME;
- if (p.runtime === "agent-sdk") {
- return p.codexRuntime === "codex" ? "codex-sdk" : "claude-agent-sdk";
- }
- return normalizeRuntime(p.runtime || DEFAULT_RUNTIME);
+ return parseStoredRuntime(p) ?? DEFAULT_RUNTIME;
}
diff --git a/agent-network/src/onboarding-cli-smoke.test.ts b/agent-network/src/onboarding-cli-smoke.test.ts
new file mode 100644
index 000000000..fa7e113aa
--- /dev/null
+++ b/agent-network/src/onboarding-cli-smoke.test.ts
@@ -0,0 +1,248 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const CLI = join(import.meta.dir, "..", "bin", "cli.ts");
+const roots: string[] = [];
+
+function workspace(): { root: string; home: string; bin: string; cwd: string } {
+ const root = mkdtempSync(join(tmpdir(), "anet-onboarding-"));
+ roots.push(root);
+ const home = join(root, "home");
+ const bin = join(root, "bin");
+ const cwd = join(root, "project");
+ mkdirSync(home, { recursive: true });
+ mkdirSync(bin, { recursive: true });
+ mkdirSync(cwd, { recursive: true });
+ return { root, home, bin, cwd };
+}
+
+function runCli(
+ ws: ReturnType,
+ argv: string[],
+ extraEnv: Record = {},
+ stdin?: string,
+) {
+ return Bun.spawnSync({
+ cmd: [process.execPath, CLI, ...argv],
+ cwd: ws.cwd,
+ env: { HOME: ws.home, PATH: ws.bin, ...extraEnv },
+ ...(stdin === undefined ? {} : { stdin: Buffer.from(stdin) }),
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+}
+
+function writeNodeConfig(
+ ws: ReturnType,
+ runtime: string,
+ env: Record = {},
+ extra: Record = {},
+) {
+ const dir = join(ws.cwd, ".anet", "nodes", "test-node");
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "config.json"), JSON.stringify({
+ node_id: "n_testnode",
+ node_name: "test-node",
+ alias: "test-node",
+ runtime,
+ token: "test-only-node-token",
+ channels: ["server:commhub"],
+ env,
+ flags: {},
+ ...extra,
+ }));
+}
+
+function writeArgvRecorder(ws: ReturnType, name: "npx" | "claude" | "codex"): string {
+ const log = join(ws.root, `${name}-argv.txt`);
+ const executable = join(ws.bin, name);
+ writeFileSync(
+ executable,
+ `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(log)}\nexit 0\n`,
+ );
+ chmodSync(executable, 0o755);
+ return log;
+}
+
+function writeGlobalConfig(ws: ReturnType, config: Record): void {
+ const globalDir = join(ws.home, ".anet");
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(join(globalDir, "config.json"), JSON.stringify(config));
+}
+
+afterEach(() => {
+ while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true });
+});
+
+describe("onboarding CLI subprocess gates", () => {
+ test("version/setup-facing report declares the Bun prerequisite", () => {
+ const ws = workspace();
+ const result = runCli(ws, ["--version"]);
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout.toString()).toContain("Bun/bunx missing");
+ expect(result.stdout.toString()).toContain("https://bun.sh/install");
+ });
+
+ test("unknown --runtime exits non-zero before Hub access and never falls back", () => {
+ const ws = workspace();
+ const result = runCli(ws, ["node", "create", "test-node", "--runtime", "not-a-runtime"]);
+ const stderr = result.stderr.toString();
+ expect(result.exitCode).toBe(2);
+ expect(stderr).toContain("Unsupported runtime");
+ expect(stderr).toContain("not-a-runtime");
+ expect(result.stdout.toString()).not.toContain("Created node");
+ expect(existsSync(join(ws.cwd, ".anet", "nodes", "test-node", "config.json"))).toBeFalse();
+ });
+
+ test("hub start without bunx exits with actionable Bun guidance", () => {
+ const ws = workspace();
+ const result = runCli(ws, ["hub", "start", "--port", "65431"]);
+ const stderr = result.stderr.toString();
+ expect(result.exitCode).toBe(1);
+ expect(stderr).toContain("requires Bun >= 1.2.0");
+ expect(stderr).toContain("https://bun.sh/install");
+ });
+
+ test("blank claude-agent-sdk credential blocks start before npx", () => {
+ const ws = workspace();
+ writeNodeConfig(ws, "claude-agent-sdk");
+ const result = runCli(ws, ["node", "start", "test-node"]);
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr.toString()).toContain("needs a non-empty provider credential");
+ });
+
+ test("missing global agent-node takes the real npx fallback", () => {
+ const ws = workspace();
+ const argvLog = writeArgvRecorder(ws, "npx");
+ writeNodeConfig(ws, "claude-agent-sdk", { ANTHROPIC_API_KEY: "test-only-placeholder" });
+
+ const result = runCli(ws, ["node", "start", "test-node"]);
+ expect(result.exitCode).toBe(0);
+ const argv = readFileSync(argvLog, "utf8");
+ expect(argv).toContain("@sleep2agi/agent-node@preview");
+ expect(argv).toContain("--runtime");
+ expect(argv).toContain("claude-agent-sdk");
+ });
+
+ test("host_supervisor daemon start is keyless and reaches the real npx entry", () => {
+ const ws = workspace();
+ const argvLog = writeArgvRecorder(ws, "npx");
+ writeNodeConfig(ws, "claude-agent-sdk", {}, { role: "host_supervisor" });
+
+ const result = runCli(ws, ["daemon", "start", "test-node"]);
+ expect(result.exitCode).toBe(0);
+ expect(result.stderr.toString()).not.toContain("provider credential");
+ const argv = readFileSync(argvLog, "utf8");
+ expect(argv).toContain("@sleep2agi/agent-node@preview");
+ expect(argv).toContain("claude-agent-sdk");
+ });
+
+ test("host_supervisor daemon up reuses an existing profile and reaches npx without a key", () => {
+ const ws = workspace();
+ const argvLog = writeArgvRecorder(ws, "npx");
+ writeNodeConfig(ws, "claude-agent-sdk", {}, { role: "host_supervisor" });
+
+ const result = runCli(ws, ["daemon", "up", "test-node"]);
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout.toString()).toContain("already a host_supervisor daemon");
+ expect(result.stderr.toString()).not.toContain("provider credential");
+ const argv = readFileSync(argvLog, "utf8");
+ expect(argv).toContain("@sleep2agi/agent-node@preview");
+ expect(argv).toContain("claude-agent-sdk");
+ });
+
+ test("legacy agent-sdk/codex profile launches codex-sdk through npx without rewriting config", () => {
+ const ws = workspace();
+ const argvLog = writeArgvRecorder(ws, "npx");
+ writeNodeConfig(ws, "agent-sdk", {}, { codexRuntime: "codex" });
+ const configPath = join(ws.cwd, ".anet", "nodes", "test-node", "config.json");
+ const before = readFileSync(configPath, "utf8");
+
+ const result = runCli(ws, ["node", "start", "test-node"]);
+ expect(result.exitCode).toBe(0);
+ const argv = readFileSync(argvLog, "utf8");
+ expect(argv).toContain("--runtime");
+ expect(argv).toContain("codex-sdk");
+ expect(readFileSync(configPath, "utf8")).toBe(before);
+ });
+
+ test("unknown stored runtime exits 2 with zero spawn and unchanged config", () => {
+ const ws = workspace();
+ const npxLog = writeArgvRecorder(ws, "npx");
+ const claudeLog = writeArgvRecorder(ws, "claude");
+ writeNodeConfig(ws, "private-runtime");
+ const configPath = join(ws.cwd, ".anet", "nodes", "test-node", "config.json");
+ const before = readFileSync(configPath, "utf8");
+
+ const result = runCli(ws, ["node", "start", "test-node"]);
+ expect(result.exitCode).toBe(2);
+ expect(result.stderr.toString()).toContain("Unsupported runtime");
+ expect(existsSync(npxLog)).toBeFalse();
+ expect(existsSync(claudeLog)).toBeFalse();
+ expect(readFileSync(configPath, "utf8")).toBe(before);
+ });
+
+ test("codex-app-server uses npx agent-node and never the Claude launcher", () => {
+ const ws = workspace();
+ const npxLog = writeArgvRecorder(ws, "npx");
+ const claudeLog = writeArgvRecorder(ws, "claude");
+ writeArgvRecorder(ws, "codex");
+ writeNodeConfig(ws, "codex-app-server");
+
+ const result = runCli(ws, ["node", "start", "test-node"]);
+ expect(result.exitCode).toBe(0);
+ const argv = readFileSync(npxLog, "utf8");
+ expect(argv).toContain("@sleep2agi/agent-node@preview");
+ expect(argv).toContain("codex-app-server");
+ expect(existsSync(claudeLog)).toBeFalse();
+ });
+
+ test("codex-app-server is accepted by create without entering an SDK credential path", () => {
+ const ws = workspace();
+ writeGlobalConfig(ws, { hub: "http://127.0.0.1:1" });
+ const result = runCli(ws, ["node", "create", "cas-node", "--runtime", "codex-app-server"]);
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr.toString()).toContain("Not logged in");
+ expect(result.stderr.toString()).not.toContain("Unsupported runtime");
+ expect(result.stderr.toString()).not.toContain("provider credential");
+ expect(existsSync(join(ws.cwd, ".anet", "nodes", "cas-node", "config.json"))).toBeFalse();
+ });
+
+ test("batch custom unknown --runtime exits 2 before Hub/config/spawn", () => {
+ const ws = workspace();
+ const npxLog = writeArgvRecorder(ws, "npx");
+ const claudeLog = writeArgvRecorder(ws, "claude");
+
+ const result = runCli(ws, [
+ "create", "--batch", "--preset", "__custom__", "--runtime", "private-runtime",
+ ]);
+ expect(result.exitCode).toBe(2);
+ expect(result.stderr.toString()).toContain("Unsupported runtime");
+ expect(result.stderr.toString()).toContain("--preset __custom__");
+ expect(existsSync(join(ws.cwd, ".anet"))).toBeFalse();
+ expect(existsSync(npxLog)).toBeFalse();
+ expect(existsSync(claudeLog)).toBeFalse();
+ });
+
+ test("batch custom prompt rejects unknown runtime instead of defaulting", () => {
+ const ws = workspace();
+ const npxLog = writeArgvRecorder(ws, "npx");
+ const claudeLog = writeArgvRecorder(ws, "claude");
+ writeGlobalConfig(ws, { hub: "http://127.0.0.1:1" });
+
+ const result = runCli(
+ ws,
+ ["create", "--batch", "--preset", "__custom__"],
+ {},
+ "private-runtime\n",
+ );
+ expect(result.exitCode).toBe(2);
+ expect(result.stderr.toString()).toContain("Unsupported runtime");
+ expect(result.stderr.toString()).toContain("--preset __custom__ prompt");
+ expect(existsSync(join(ws.cwd, ".anet"))).toBeFalse();
+ expect(existsSync(npxLog)).toBeFalse();
+ expect(existsSync(claudeLog)).toBeFalse();
+ });
+});
diff --git a/agent-network/src/onboarding-guards.test.ts b/agent-network/src/onboarding-guards.test.ts
new file mode 100644
index 000000000..7bb82830e
--- /dev/null
+++ b/agent-network/src/onboarding-guards.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, test } from "bun:test";
+import {
+ bunHubPrerequisiteIssue,
+ providerCredentialIssue,
+ resolveAgentNodeLaunch,
+} from "./onboarding-guards";
+
+describe("onboarding guards", () => {
+ test("hub startup fails closed when bunx is unavailable", () => {
+ expect(bunHubPrerequisiteIssue(false)).toContain("requires Bun");
+ expect(bunHubPrerequisiteIssue(true)).toBeNull();
+ });
+
+ test("agent-node falls back to the preview-channel npx package when no global binary exists", () => {
+ expect(resolveAgentNodeLaunch(false, ["--config", "config.json"])).toEqual({
+ command: "npx",
+ args: ["-y", "@sleep2agi/agent-node@preview", "--config", "config.json"],
+ });
+ });
+
+ test("a global agent-node remains the preferred launch path", () => {
+ expect(resolveAgentNodeLaunch(true, ["--config", "config.json"])).toEqual({
+ command: "agent-node",
+ args: ["--config", "config.json"],
+ });
+ });
+
+ test("claude-agent-sdk rejects blank provider credentials", () => {
+ const context = { launchIntent: "node-start" as const, profileRole: "member" };
+ expect(providerCredentialIssue("claude-agent-sdk", {}, context)).toContain("provider credential");
+ expect(providerCredentialIssue("claude-agent-sdk", { ANTHROPIC_API_KEY: " " }, context)).toContain("provider credential");
+ });
+
+ test("claude-agent-sdk accepts either supported provider credential", () => {
+ const context = { launchIntent: "node-start" as const };
+ expect(providerCredentialIssue("claude-agent-sdk", { ANTHROPIC_API_KEY: "present" }, context)).toBeNull();
+ expect(providerCredentialIssue("claude-agent-sdk", { ANTHROPIC_AUTH_TOKEN: "present" }, context)).toBeNull();
+ });
+
+ test("host_supervisor doorbell start is keyless but ordinary SDK nodes are not", () => {
+ expect(providerCredentialIssue("claude-agent-sdk", {}, {
+ launchIntent: "node-start",
+ profileRole: "host_supervisor",
+ })).toBeNull();
+ expect(providerCredentialIssue("claude-agent-sdk", {}, {
+ launchIntent: "node-start",
+ profileRole: "member",
+ })).toContain("provider credential");
+ expect(providerCredentialIssue("claude-agent-sdk", {}, {
+ launchIntent: "node-create",
+ profileRole: "host_supervisor",
+ })).toContain("provider credential");
+ });
+
+ test("CLI-auth and keyless-capable runtimes are not blocked by the provider-key guard", () => {
+ for (const runtime of [
+ "claude-code-cli",
+ "codex-sdk",
+ "codex-app-server",
+ "grok-build-acp",
+ "opencode-cli",
+ ] as const) {
+ expect(providerCredentialIssue(runtime, {}, { launchIntent: "node-start" })).toBeNull();
+ }
+ });
+});
diff --git a/agent-network/src/onboarding-guards.ts b/agent-network/src/onboarding-guards.ts
new file mode 100644
index 000000000..cd12dd215
--- /dev/null
+++ b/agent-network/src/onboarding-guards.ts
@@ -0,0 +1,54 @@
+import type { RuntimeName } from "./normalize-runtime";
+
+export interface LaunchCommand {
+ command: "agent-node" | "npx";
+ args: string[];
+}
+
+export interface ProviderCredentialContext {
+ launchIntent: "node-create" | "node-start";
+ profileRole?: string;
+}
+
+export function bunHubPrerequisiteIssue(hasBunx: boolean): string | null {
+ return hasBunx
+ ? null
+ : "anet hub start requires Bun >= 1.2.0 and its bunx executable";
+}
+
+/** Resolve the real command used by `anet node start`. */
+export function resolveAgentNodeLaunch(hasGlobalAgentNode: boolean, agentArgs: string[]): LaunchCommand {
+ if (hasGlobalAgentNode) return { command: "agent-node", args: [...agentArgs] };
+ // `preview` is intentionally a moving npm dist-tag, not a version pin.
+ // Evidence/release notes must record the exact version resolved at the time
+ // of a smoke run rather than describing this channel selector as pinned.
+ return {
+ command: "npx",
+ args: ["-y", "@sleep2agi/agent-node@preview", ...agentArgs],
+ };
+}
+
+/**
+ * Only the SDK vendor path requires an Anthropic-compatible provider key.
+ * CLI-auth runtimes and opencode's separately configured free-model path are
+ * deliberately outside this guard.
+ */
+export function providerCredentialIssue(
+ runtime: RuntimeName,
+ env: Record,
+ context: ProviderCredentialContext,
+): string | null {
+ if (runtime !== "claude-agent-sdk") return null;
+ // A host supervisor uses agent-node only as the authenticated SSE doorbell
+ // daemon; it does not run an inference turn. Keep this exemption narrow:
+ // ordinary SDK nodes (including role-less/member profiles) still require a
+ // provider credential, and create-time profiles cannot claim this bypass.
+ if (context.launchIntent === "node-start" && context.profileRole === "host_supervisor") {
+ return null;
+ }
+ const hasCredential = [env.ANTHROPIC_AUTH_TOKEN, env.ANTHROPIC_API_KEY]
+ .some((value) => typeof value === "string" && value.trim().length > 0);
+ return hasCredential
+ ? null
+ : "claude-agent-sdk needs a non-empty provider credential (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)";
+}
diff --git a/docs/tests/report-onboarding-fixes.txt b/docs/tests/report-onboarding-fixes.txt
new file mode 100644
index 000000000..4d1a3803f
--- /dev/null
+++ b/docs/tests/report-onboarding-fixes.txt
@@ -0,0 +1,141 @@
+Agent Network onboarding corrective smoke — isolated candidate evidence
+Date: 2026-07-13 (Asia/Shanghai)
+Candidate: agent/onboarding-fixes, based on origin/main d4188621
+Round 2 implementation commit: 6808046
+Verdict: AUTHOR EVIDENCE GREEN; independent review pending. This is not a
+release verdict.
+
+Review history
+--------------
+- Round 1 commit 98a33b04: REQUEST CHANGES. The first candidate incorrectly
+ blocked a keyless host_supervisor daemon, did not preserve the legacy
+ agent-sdk/codex stored profile at the strict launch boundary, still routed
+ codex-app-server into the Claude launcher branch, and left the batch
+ __custom__ prompt on tolerant runtime normalization.
+- Round 2 commit 6808046: implements those four corrections. The evidence
+ below is author-generated and must not substitute for independent review.
+
+Isolation
+---------
+- Fresh node:22.13.1-bookworm-slim container, no host config mounts, no
+ published ports, and no production Hub URL or credential.
+- Candidate installed from a locally built npm tarball. Public npm stable was
+ not modified and this test is not evidence about a released version.
+- Network access was used only for Debian packages, Bun, npm runtime packages,
+ and the loopback-only throwaway Hub inside the container.
+- The container had no global agent-node binary at the time the node came
+ online. No real provider credential or task inference was attempted.
+
+Successful path and timing
+--------------------------
+The clean Node-only image needed seven load-bearing shell invocations:
+
+1. Install curl/unzip OS prerequisites: 10 s.
+2. Install Bun using the public command: 3 s.
+3. Install the local candidate tarball: 10 s.
+4. Start the loopback Hub: 4 s.
+5. Register a throwaway local account/network: 1 s.
+6. Create a claude-agent-sdk node with a non-empty test-only placeholder: <1 s.
+7. Start the node: strict online criterion reached in 20 s.
+
+Measured command-runtime sum: about 48 s. Strict online meant all of:
+- agent-node emitted "SSE connected";
+- anet status showed one idle node and one SSE connection;
+- Hub health reported ok=true, sessions_count=1, sse_connections=1.
+
+This improves on the earlier stable-package capture (11 invocations, 2m49s),
+but the runs used different package candidates and network/cache conditions;
+the comparison is directional, not a performance benchmark. It is also not a
+"zero-step" result: Bun remains an explicit prerequisite.
+
+Scoped corrective evidence
+--------------------------
+1. Bun prerequisite
+ - With bunx absent, `anet hub start` exited 1 before spawn.
+ - stderr named Bun >=1.2.0 and printed the public install command.
+ - README (Chinese/English) and package README now declare Bun before the
+ quick-start commands. `anet --version` / setup-facing output also reports
+ the missing prerequisite. Bun is never auto-installed.
+
+2. agent-node first-start path
+ - The candidate reached strict online with no global agent-node command.
+ - The real launch selected `npx -y @sleep2agi/agent-node@preview`. `preview`
+ is a moving npm dist-tag, not a version pin. Round 1 did not record the
+ exact resolved version; that evidence gap is corrected in Round 2 below.
+ - A subprocess regression test uses a controlled npx executable and asserts
+ the actual argv, so the prior "lazy" label cannot pass without a launch.
+
+3. Blank provider key
+ - claude-agent-sdk create with no key exited 1, printed an actionable
+ provider-credential error, and wrote no node config.
+ - Existing config with a blank key also exited before agent-node/npx spawn.
+ - CLI-auth runtimes and separately configured keyless runtimes are excluded
+ from this provider-key guard; this change does not impose a global key.
+
+4. Unknown runtime
+ - `--runtime not-a-runtime` exited 2 before Hub access, printed the accepted
+ values, and wrote no node config.
+ - A persisted unknown non-empty runtime is rejected on start rather than
+ normalized to claude-agent-sdk/claude-code-cli.
+
+Round 2 corrective evidence
+---------------------------
+Clean-container evidence was generated from a git archive of commit 6808046
+inside a fresh node:22.13.1-bookworm-slim container. No host configuration,
+credential, Hub state, or production endpoint was mounted into the container.
+
+- Toolchain: Node v22.13.1; Bun 1.3.14.
+- `bun test src`: 146 pass / 0 fail / 283 expect calls (9 files).
+- `npm run typecheck`: exit 0.
+- `npm run build`: exit 0 (CLI/client/node-server/worker bundles built).
+- At this smoke timestamp, the registry's moving
+ `@sleep2agi/agent-node@preview` tag resolved to exactly
+ `2.5.0-preview.20`; a separate fresh container ran
+ `npx -y @sleep2agi/agent-node@preview --version` and printed
+ `agent-node v2.5.0-preview.20`. This is time-of-test evidence only and does
+ not make the preview tag immutable.
+
+True CLI-entry regressions (controlled executables, no external Hub):
+
+1. Keyless daemon intent
+ - Both `anet daemon start test-node` and `anet daemon up test-node` on an
+ existing role=host_supervisor profile reached the actual launcher seam
+ and invoked the controlled npx executable without a provider key.
+ - An ordinary/member claude-agent-sdk start still fails early without a
+ non-empty provider credential. The exemption is limited to node-start +
+ profileRole=host_supervisor; create-time/ordinary profiles are not exempt.
+
+2. Stored runtime identity
+ - Legacy `{runtime:"agent-sdk", codexRuntime:"codex"}` launched npx with
+ `--runtime codex-sdk` and left config.json byte-for-byte unchanged.
+ - An unknown non-empty stored runtime exited 2, invoked neither npx nor
+ Claude, and left config.json byte-for-byte unchanged.
+
+3. codex-app-server preview routing
+ - Explicit create recognizes codex-app-server without entering the SDK
+ credential path.
+ - Start invoked npx/agent-node with `--runtime codex-app-server`; the
+ controlled Claude executable was never invoked.
+ - The CLI labels this path RFC-030 preview / production locked. This is
+ launcher-routing evidence only, not Policy Gateway, production, or
+ release evidence.
+
+4. Batch custom strictness
+ - Both `--preset __custom__ --runtime private-runtime` and the interactive
+ __custom__ prompt value `private-runtime` exited 2.
+ - Both paths wrote zero project config and invoked zero runtime process.
+
+Residual limits / merge ordering
+--------------------------------
+- This smoke proves registration/SSE presence, not a usable model response;
+ the placeholder key was never sent to a provider.
+- The current candidate Hub bootstrap did not accept the historical default
+ admin password in this fresh container, so the successful path used
+ `anet register`. README bootstrap wording needs a separate product/security
+ alignment; this tranche does not restore a fixed default password.
+- codex-app-server routing here reuses the reviewed #441 launcher policy but
+ does not merge #441 wholesale. This branch must not be used to claim that
+ RFC-030 is production-ready; the Policy Gateway, safety review, merge,
+ deployment, production, and latest-tag gates remain locked.
+- No merge, deploy, npm publication, latest-tag change, or production state
+ change was performed.