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.