merge: hotfix/fresh-box-experience → main (v0.3.5 fresh-box experience)

Fixes the v0.3.4 fresh-box failures: silent CLI under bare-word PATH
invocation (SEA argv detection), bootstrap dying on venv creation without an
actionable hint (poisoned-partial-venv recovery + apt hint + doctor
venv-capability probe + preflight), installer falsely 'verifying' a silent
binary, and localhost-only server binding (network mode: 0.0.0.0 + wildcard
CORS + hostname-derived API URL — remote browsing zero-config).

38 CLI tests, 3 web tests, 409 ai-service tests green; build/typecheck/lint
clean.

---ci---
phase: hotfix
milestone: v0.4
status: complete
type: hotfix
requirements:
  covered: [REQ-4-001, REQ-4-002, REQ-4-003, REQ-4-004, REQ-4-005]
  partial: []
---/ci---
This commit is contained in:
CIAgent
2026-09-13 00:05:37 +00:00
parent 1648467828
commit 135ea21a61
25 changed files with 441 additions and 70 deletions
+2 -1
View File
@@ -56,6 +56,7 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
20. **D-035 Install path = repo raw `install.sh` + Gitea latest-release API** — the one-liner `curl -fsSL <forge>/coreci/nextcraft/raw/main/scripts/install.sh | bash` resolves `GET /api/v1/repos/coreci/nextcraft/releases/latest`, downloads the `nextcraft-linux-x64` + `nextcraft-linux-x64.sha256` assets, verifies sha256 (`shasum -a 256`), installs to `~/.local/bin` (PATH hint), and degrades to printed source-bootstrap instructions when no binary asset exists or the platform mismatches (A-203/A-204/A-206).
21. **D-036 Ongoing binaries = ship-workflow asset step** — the release pipeline (v0.3's `ShipWorkflow.createRelease` equivalent, executed as the ship step's asset stage) builds the binary + checksum and attaches both to every Gitea release from v0.4 onward (A-205). Token resolution stays `.env*`-only (D-006/D-014); binaries are linux x64 only for v0.4 (macOS arm64 deferred — unverifiable on this box).
22. **D-037 CLI package layout**`apps/cli` is a pnpm workspace package (`@nextcraft/cli`): `src/` (entry, commands/, checks/, lib/), `scripts/build-binary.mjs` (esbuild bundle → SEA inject), unit tests runnable via `pnpm --filter @nextcraft/cli test` (node:test, no new test framework). Root `package.json` gains `cli:*` passthrough scripts mirroring the `ai:*` pattern (D-022).
23. **D-038 Network mode (v0.3.5)** — dev binds 0.0.0.0 (`AI_HOST`, default 0.0.0.0, revert via 127.0.0.1); CORS + WS-origin gates read `AI_CORS_ORIGINS` (default `*` — any origin, safe only because credentials are never enabled; explicit comma list restricts); the web client derives the API base URL from the browser hostname at runtime (`engine-base-url.ts`: `NEXT_PUBLIC_AI_SERVICE_URL` override → `http://${window.location.hostname}:8420``localhost` server-side). Hotfix also fixes: SEA direct-run detection (`require("node:sea").isSea()` — argv shape differs by invocation style), installer honesty gate (silent `--version` = hard fail), bootstrap venv recovery (poisoned partial `.venv` removal + distro-specific `apt install python3.XX-venv` hint), and doctor venv-capability probe with bootstrap preflight.
### v0.3 Architecture Decisions (from Research — Credential Engines)
@@ -129,7 +130,7 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
| `app/layout.tsx` | Root layout: theme provider, navigation shell, responsive container | All routes | packages/ui |
| `components/` | Surface-specific components (learner/, marketplace/, employer/, admin/) plus shared chrome (navigation-shell, header/footer, role-switcher, theme-provider, breadcrumbs, dark-mode-toggle); v0.3 learner: build-surface, sandbox-terminal (read-only exec output), defense-session | App-level components | packages/ui |
| `hooks/` | use-chat-stream.ts — SSE client hook: fetch + ReadableStream, byte buffering + frame reassembly, idempotent AbortController cleanup; use-sandbox-session.ts (v0.3) — sandbox lifecycle for the build session: create on task open, destroy on unmount, mid-start failure cleanup, 503/403/429 honest surfaces | Client components only | ai-service SSE / engine API |
| `lib/` | sse.ts (shared SSE frame parser — CRLF normalization + `: ping` immunity, v0.2 G-1), breadcrumbs.ts, format.ts, engine-client.ts (v0.3: typed fetch client for /v1/sandboxes, files/exec, variants, grade, defense, traces) | Pure utilities | None |
| `lib/` | sse.ts (shared SSE frame parser — CRLF normalization + `: ping` immunity, v0.2 G-1), breadcrumbs.ts, format.ts, engine-base-url.ts (v0.3.5: runtime API base — env override → browser hostname → localhost), engine-client.ts (v0.3: typed fetch client for /v1/sandboxes, files/exec, variants, grade, defense, traces) | Pure utilities | None |
### packages/ui — Shared Component Library
+9
View File
@@ -48,6 +48,15 @@ pnpm ai:dev
Exit-code contract: `0` success, `1` check/step failure (hint printed), `2` usage error.
### Remote server
`nextcraft dev` binds the API on **0.0.0.0:8420** (and `pnpm dev` serves the web app on all interfaces), so the stack works from other machines out of the box:
- Browse `http://<your-host>:3000` — the web app targets `http://<your-host>:8420` automatically (derived from the browser's hostname).
- CORS admits any origin (`AI_CORS_ORIGINS=*` in `apps/ai-service/.env`). This is safe **only** because credentials are never enabled; to restrict, set an explicit list: `AI_CORS_ORIGINS=http://<your-host>:3000`.
- To revert to loopback-only: `AI_HOST=127.0.0.1` in `apps/ai-service/.env`.
- Security note: this is an unauthenticated dev API reachable from any network the box exposes. Mitigations that still apply: per-learner sandbox caps + global rate caps + learner allowlist (G-5), telemetry flood control (traces marked `INCOMPLETE_FLOODED` are refused by the grader). Expose only on trusted networks until identity/KYC lands (v0.5).
## Docs
- [apps/cli/README.md](apps/cli/README.md) — CLI internals: build, binary pipeline, troubleshooting
+7
View File
@@ -2,6 +2,13 @@
# Real keys live in .ciagent/.env.secrets (gitignored) and are exported by scripts/dev.sh
AI_PORT=8420
# Network mode (v0.3.5, D-038): dev server binds 0.0.0.0 so remote machines can
# reach the stack. Set to 127.0.0.1 to revert to loopback-only.
AI_HOST=0.0.0.0
# CORS + WS-origin policy: '*' (default) admits any origin — safe because
# credentials are never enabled. Restrict with a comma list, e.g.:
# AI_CORS_ORIGINS=http://nextcraft-1:3000
AI_CORS_ORIGINS=*
AI_PROVIDER=ollama-cloud
AI_MODEL=gemma4:31b
AI_OLLAMA_CLOUD_BASE_URL=https://ollama.com/v1
+18 -4
View File
@@ -39,16 +39,28 @@ from .deps import get_trace_integrity, get_trace_store
router = APIRouter(prefix="/v1/telemetry", tags=["telemetry"])
#: Browser Origins allowed to open the ingest socket (A-008 mirror). The
#: stdlib capture agent sends NO Origin header (it is not a browser) and
#: Browser Origins allowed to open the ingest socket (A-008 mirror, D-038).
#: The stdlib capture agent sends NO Origin header (it is not a browser) and
#: stays allowed; a malicious page loaded in the learner's browser would
#: carry an Origin and must not be able to poison/flood the trace. CORS
#: middleware does NOT cover WebSocket upgrades, so this gate is explicit.
_ALLOWED_WS_ORIGINS = frozenset(
#: In network mode the configured CORS list governs (default '*' — any
#: origin, since credentials are never used); an explicit list still rejects
#: unlisted origins with 1008.
_LOCAL_WS_ORIGINS = frozenset(
{"http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8420"}
)
def _allowed_ws_origins(settings: object) -> frozenset[str]:
configured = getattr(settings, "cors_origin_list", None)
if configured is None:
return _LOCAL_WS_ORIGINS
if configured == ["*"]:
return frozenset() # empty = wildcard = every Origin passes
return frozenset(configured) | _LOCAL_WS_ORIGINS
# --- WS ingest (D-026) ---------------------------------------------------------
@@ -60,10 +72,12 @@ async def telemetry_ingest_ws(websocket: WebSocket) -> None:
FastAPI; this shim is the only place the two layers meet.
"""
origin = (websocket.headers.get("origin") or "").strip()
if origin and origin not in _ALLOWED_WS_ORIGINS:
allowed = _allowed_ws_origins(getattr(websocket.app.state, "settings", None))
if origin and allowed and origin not in allowed:
# Same-origin dev pages (Next.js on :3000, the service itself on
# :8420) pass; anything else is refused pre-accept. Non-browser
# producers (the capture agent, tests) send no Origin and pass.
# Wildcard (empty frozenset) passes every Origin in network mode.
await websocket.close(
code=1008, reason=f"origin {origin!r} not allowed for telemetry ingest"
)
+13
View File
@@ -78,6 +78,19 @@ class Settings(BaseSettings):
# `port` (A-004); only the host is configurable — never a second port.
telemetry_ingest_host: str = "127.0.0.1"
# v0.3.5 network mode (D-038): dev.sh binds 0.0.0.0 so remote browsers can
# reach the stack; '*' (default) lets any origin call the API (safe ONLY
# because credentials are never enabled — A-008). Set a comma-separated
# origin list (e.g. 'http://nextcraft-1:3000') to restrict instead.
cors_origins: str = "*"
@property
def cors_origin_list(self) -> list[str]:
value = self.cors_origins.strip()
if value == "*":
return ["*"]
return [o.strip() for o in value.split(",") if o.strip()]
# Voice provider selection (REQ-3-006, D-030): 'mock' (default — the
# no-key path is first-class; tests never call a real voice API) or
# 'browser' (browser-native SpeechRecognition/speechSynthesis fallback;
+8 -6
View File
@@ -171,14 +171,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app = FastAPI(title="Nextcraft AI Service", version="0.3.0", lifespan=lifespan)
# A-008: localhost-only CORS, no credentials. PUT is CONTRACT, not
# trivia: the learner build surface writes workspace files with PUT
# (engine-client writeFile) — v0.3 initially shipped without it and
# every cross-origin Save failed preflight (caught in P7 review;
# tests/api/test_cors.py pins the policy now).
# A-008 + D-038: no-credentials CORS. Default '*' admits remote-browser
# origins in network mode (safe only because allow_credentials stays
# False — never enable credentials with a wildcard). AI_CORS_ORIGINS
# restricts to an explicit list. PUT is CONTRACT, not trivia: the learner
# build surface writes workspace files with PUT (engine-client writeFile)
# — v0.3 initially shipped without it and every cross-origin Save failed
# preflight (caught in P7 review; tests/api/test_cors.py pins the policy).
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_origins=settings.cors_origin_list,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
allow_credentials=False,
+52 -7
View File
@@ -1,28 +1,73 @@
#!/usr/bin/env bash
# Idempotent bootstrap: create venv + install deps.
# Handles Debian systems without python3-venv/ensurepip via --without-pip + get-pip.
# Handles Debian/Ubuntu systems without python3-venv/ensurepip via --without-pip + get-pip.
# v2 (v0.3.5): recovers from a poisoned partial .venv left by a failed earlier
# attempt, cleans before each retry, and dies with a distro-specific fix hint
# when venv creation is impossible (e.g. missing python3.XX-venv package).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV="$APP_DIR/.venv"
venv_usable() {
[ -x "$VENV/bin/python3" ]
}
rm_broken_venv() {
echo "bootstrap: removing broken partial .venv from a failed earlier attempt" >&2
rm -rf "$VENV"
}
mkdir -p "$HOME/.cache/ciagent"
if [ ! -x "$VENV/bin/python3" ]; then
if python3 -m venv "$VENV" 2>/dev/null; then
if venv_usable && [ ! -x "$VENV/bin/pip" ]; then
# A usable python3 without pip means the --without-pip fallback half-ran and
# the get-pip step never completed: start over cleanly.
rm_broken_venv
fi
if ! venv_usable; then
if [ -d "$VENV" ]; then
# Directory exists but no working python3: remains of a crashed venv create.
rm_broken_venv
fi
if python3 -m venv "$VENV" 2>/tmp/venv-create.err; then
:
else
# No ensurepip available — create bare venv and bootstrap pip separately.
python3 -m venv --without-pip "$VENV"
rm -rf "$VENV"
if python3 -m venv --without-pip "$VENV" 2>>/tmp/venv-create.err; then
:
else
rm -rf "$VENV"
PYVER="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)"
PKG="python3-venv"
[ -n "$PYVER" ] && PKG="python${PYVER}-venv"
echo "bootstrap: could not create a virtual environment." >&2
echo " python3 reported:" >&2
sed 's/^/ /' /tmp/venv-create.err >&2 || true
echo " fix (Debian/Ubuntu): install the venv support package, then re-run nextcraft bootstrap:" >&2
echo " apt install $PKG" >&2
exit 1
fi
fi
fi
if [ ! -x "$VENV/bin/pip" ]; then
GET_PIP="$HOME/.cache/ciagent/get-pip.py"
if [ ! -f "$GET_PIP" ]; then
curl -sSf --max-time 60 https://bootstrap.pypa.io/get-pip.py -o "$GET_PIP"
if ! curl -sSf --max-time 60 https://bootstrap.pypa.io/get-pip.py -o "$GET_PIP"; then
rm -rf "$VENV"
echo "bootstrap: get-pip.py download failed (no network?)." >&2
echo " fix: restore network access and re-run nextcraft bootstrap" >&2
exit 1
fi
fi
if ! "$VENV/bin/python3" "$GET_PIP" --quiet; then
rm -rf "$VENV"
echo "bootstrap: pip installation into the venv failed." >&2
echo " fix: re-run nextcraft bootstrap (the venv was cleaned; this retry is safe)" >&2
exit 1
fi
"$VENV/bin/python3" "$GET_PIP" --quiet
fi
"$VENV/bin/pip" install --quiet --upgrade pip
+17 -3
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
# Dev server: export secrets (if present) then run uvicorn on :8420.
# Dev server: export secrets (if present) then run uvicorn.
# Binds 0.0.0.0 by default so the stack is reachable from other machines
# (v0.3.5 network mode) — set AI_HOST=127.0.0.1 in .env to revert to loopback.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -7,7 +9,7 @@ REPO_ROOT="$(cd "$APP_DIR/../.." && pwd)"
VENV="$APP_DIR/.venv"
if [ ! -x "$VENV/bin/uvicorn" ]; then
echo "venv missing — run scripts/bootstrap.sh first" >&2
echo "venv missing — run nextcraft bootstrap first (or: bash scripts/bootstrap.sh)" >&2
exit 1
fi
@@ -22,5 +24,17 @@ if [ -f "$SECRETS" ]; then
done < "$SECRETS"
fi
ENV_FILE="$APP_DIR/.env"
if [ -f "$ENV_FILE" ]; then
while IFS='=' read -r key value; do
case "$key" in
AI_HOST|AI_PORT|AI_CORS_ORIGINS) export "$key=$value" ;;
esac
done < "$ENV_FILE"
fi
HOST="${AI_HOST:-0.0.0.0}"
PORT="${AI_PORT:-8420}"
cd "$APP_DIR"
exec "$VENV/bin/uvicorn" ai_service.main:app --reload --port 8420
exec "$VENV/bin/uvicorn" ai_service.main:app --reload --host "$HOST" --port "$PORT"
+32 -10
View File
@@ -1,4 +1,4 @@
"""CORS policy tests (A-008, P7 review regression).
"""CORS policy tests (A-008, D-038 network mode).
v0.3 initially shipped `allow_methods` WITHOUT "PUT" while the learner
build surface writes workspace files with PUT (engine-client writeFile) —
@@ -6,11 +6,11 @@ every cross-origin Save failed preflight. These tests pin the policy so a
future method-list edit fails loudly instead of silently breaking the
headline flow.
Two-layer check:
- preflight (OPTIONS + Access-Control-Request-Method) for every method the
web client actually uses: GET/POST/PUT/DELETE;
- actual cross-origin request echoes the localhost dev origin.
Disallowed origins must NOT be granted (localhost-only, no credentials).
v0.3.5 network mode (D-038): the default AI_CORS_ORIGINS='*' admits any
origin (safe ONLY because credentials are never enabled); an explicit list
restricts. Both modes are pinned here:
- wildcard: remote origin gets the grant; credentials still never sent;
- explicit: unlisted origins get no grant.
"""
from __future__ import annotations
@@ -18,9 +18,14 @@ from __future__ import annotations
from fastapi.testclient import TestClient
ALLOWED_ORIGIN = "http://localhost:3000"
REMOTE_ORIGIN = "http://nextcraft-1:3000"
ALL_CLIENT_METHODS = ("GET", "POST", "PUT", "DELETE")
def _allow_origin(resp) -> str | None:
return resp.headers.get("access-control-allow-origin")
def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) -> None:
for method in ALL_CLIENT_METHODS:
resp = client.options(
@@ -31,7 +36,7 @@ def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) -
},
)
assert resp.status_code == 200, f"preflight {method} failed: {resp.status_code}"
assert resp.headers["access-control-allow-origin"] == ALLOWED_ORIGIN
assert _allow_origin(resp) in ("*", ALLOWED_ORIGIN)
allowed = resp.headers["access-control-allow-methods"].split(", ")
assert method in allowed, f"{method} missing from CORS methods: {allowed}"
@@ -39,11 +44,28 @@ def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) -
def test_cross_origin_get_echoes_allow_origin(client: TestClient) -> None:
resp = client.get("/v1/sandboxes", headers={"Origin": ALLOWED_ORIGIN})
assert resp.status_code == 200
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
assert _allow_origin(resp) in ("*", ALLOWED_ORIGIN)
def test_unknown_origin_gets_no_cors_grant(client: TestClient) -> None:
resp = client.get("/v1/sandboxes", headers={"Origin": "https://evil.example"})
def test_wildcard_mode_grants_remote_origins(client: TestClient) -> None:
"""D-038 default: '*' grants any origin — remote browsers work zero-config."""
resp = client.get("/v1/sandboxes", headers={"Origin": REMOTE_ORIGIN})
assert resp.status_code == 200
assert _allow_origin(resp) in ("*", REMOTE_ORIGIN)
def test_explicit_list_mode_denies_unlisted_origins(
settings, monkeypatch, tmp_path
) -> None:
"""Explicit AI_CORS_ORIGINS restricts to the listed origins only."""
from fastapi.testclient import TestClient as TC
from ai_service.main import create_app
restricted = settings.model_copy(update={"cors_origins": "http://localhost:3000"})
app = create_app(restricted)
with TC(app) as c:
resp = c.get("/v1/sandboxes", headers={"Origin": "https://evil.example"})
assert resp.status_code == 200 # non-CORS requests still serve
assert resp.headers.get("access-control-allow-origin") is None
@@ -394,19 +394,35 @@ def test_missing_identity_query_params_rejected_at_handshake(
assert excinfo.value.code == 1008
def test_browser_origin_not_allowed_for_ingest(client: TestClient) -> None:
"""CORS middleware does not cover WS upgrades (P7): a page loaded in the
learner's browser (any non-localhost Origin) must not be able to open
the ingest socket and poison/flood the trace. The stdlib capture agent
sends no Origin and is unaffected (see the no-origin test below)."""
def test_browser_origin_rejected_in_explicit_list_mode(
settings, monkeypatch: pytest.MonkeyPatch
) -> None:
"""D-038: with an explicit AI_CORS_ORIGINS list, a page loaded in the
learner's browser (unlisted Origin) must not be able to open the ingest
socket and poison/flood the trace. The stdlib capture agent sends no
Origin and is unaffected (see the no-origin test below)."""
from fastapi.testclient import TestClient as TC
restricted = settings.model_copy(update={"cors_origins": "http://localhost:3000"})
app = create_app(restricted)
with TC(app) as c:
with pytest.raises(WebSocketDisconnect) as excinfo:
with client.websocket_connect(
with c.websocket_connect(
_ingest_url(), headers={"Origin": "https://evil.example"}
):
pass
assert excinfo.value.code == 1008
def test_wildcard_mode_admits_any_browser_origin(client: TestClient) -> None:
"""D-038 default ('*'): remote-browser origins open the ingest socket —
the remote build surface streams telemetry from the learner's browser."""
with client.websocket_connect(
_ingest_url(), headers={"Origin": "http://nextcraft-1:3000"}
) as ws:
ws.send_text(_frame(0))
def test_dev_origin_and_no_origin_both_allowed(client: TestClient) -> None:
"""The same-origin dev page (Next.js :3000) opens fine, and so does the
capture-agent path (no Origin header at all)."""
+8
View File
@@ -3,6 +3,7 @@ import type { Ctx } from "../ctx.js";
import { findRepoRoot } from "../lib/repo.js";
import { diffEnvTemplate } from "../checks/check-env.js";
import { hr, info, warn } from "../lib/log.js";
import { doctor } from "./doctor.js";
const INSTALL_TIMEOUT_MS = 600_000;
@@ -18,6 +19,13 @@ export async function bootstrap(_args: string[], ctx: Ctx): Promise<number> {
hr("nextcraft bootstrap — monorepo setup", ctx);
info("preflight: checking prerequisites (doctor)...", ctx);
const preflight = await doctor([], ctx);
if (preflight !== 0) {
ctx.stderr.write("\n\u2717 preflight failed — fix the failed checks above, then re-run nextcraft bootstrap\n");
return 1;
}
info("installing workspace dependencies (pnpm install)...", ctx);
const install = await ctx.spawn("pnpm", ["install"], { cwd: root, timeoutMs: INSTALL_TIMEOUT_MS });
if (install.code !== 0) {
+40
View File
@@ -1,6 +1,9 @@
import type { Ctx } from "../ctx.js";
import { compareVersions } from "../checks/check-command.js";
import { ok, fail, hr, summary } from "../lib/log.js";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
interface CheckOutcome {
name: string;
@@ -16,6 +19,7 @@ export async function doctor(_args: string[], ctx: Ctx): Promise<number> {
results.push(await checkProgram(ctx, "python3", "3.11", "install python3 >= 3.11 (e.g. apt install python3 python3-venv)"));
results.push(await checkProgram(ctx, "git", undefined, "install git: https://git-scm.com/download/linux"));
results.push(await checkUnshare(ctx));
results.push(await checkVenvCapability(ctx));
const passed = results.filter((r) => r.passed).length;
const failed = results.length - passed;
@@ -23,6 +27,42 @@ export async function doctor(_args: string[], ctx: Ctx): Promise<number> {
return failed === 0 ? 0 : 1;
}
async function checkVenvCapability(ctx: Ctx): Promise<CheckOutcome> {
const probeDir = mkdtempSync(join(tmpdir(), "nc-venv-probe-"));
try {
const full = await ctx.spawn("python3", ["-m", "venv", join(probeDir, "v")], {
capture: true,
timeoutMs: 60_000,
});
if (full.code === 0) return venvOk(ctx, "full venv");
const pipless = await ctx.spawn("python3", ["-m", "venv", "--without-pip", join(probeDir, "v2")], {
capture: true,
timeoutMs: 60_000,
});
if (pipless.code === 0) {
ok("python3 venv capability (fallback path: pip bootstrapped via get-pip)", ctx);
return { name: "venv", passed: true };
}
return venvFail(ctx);
} finally {
await ctx.spawn("rm", ["-rf", probeDir], { capture: true, timeoutMs: 30_000 });
}
}
function venvOk(ctx: Ctx, mode: string): CheckOutcome {
ok(`python3 venv capability (${mode})`, ctx);
return { name: "venv", passed: true };
}
function venvFail(ctx: Ctx): CheckOutcome {
fail(
"python3 cannot create virtual environments (python3 -m venv fails)",
"Debian/Ubuntu: apt install python<version>-venv (e.g. python3.12-venv for python 3.12) — nextcraft bootstrap needs it; then re-run nextcraft doctor",
ctx,
);
return { name: "venv", passed: false };
}
async function checkNode(ctx: Ctx): Promise<CheckOutcome> {
const version = process.version;
if (compareVersions(version, "18") >= 0) {
+13 -4
View File
@@ -7,6 +7,10 @@ import { dev } from "./commands/dev.js";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import net from "node:net";
interface NodeRequire {
(id: string): unknown;
}
const commands: Record<string, Command> = { doctor, bootstrap, verify, dev };
export async function run(argv: string[], ctx: Ctx): Promise<number> {
@@ -36,14 +40,19 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
return await command(positional.slice(1), ctx);
}
declare const require: NodeRequire;
function isDirectRun(): boolean {
if (process.env.NODE_TEST_CONTEXT) return false;
const [argv0, argv1] = process.argv;
if (argv0 && argv1 && argv0 === argv1) return true;
if (argv1?.endsWith("dist/index.js")) return true;
if (argv1?.endsWith("src/index.ts")) return true;
const argv1 = process.argv[1];
if (argv1?.endsWith("dist/index.js") || argv1?.endsWith("src/index.ts")) return true;
try {
const sea = require("node:sea") as { isSea?: () => boolean };
return typeof sea.isSea === "function" && sea.isSea();
} catch {
return false;
}
}
if (isDirectRun()) {
void (async () => {
const realCtx: Ctx = {
+64 -9
View File
@@ -11,6 +11,7 @@ import type { SpawnResult } from "../src/lib/spawn.ts";
const r0 = (stdout = ""): SpawnResult => ({ code: 0, signal: null, timedOut: false, stdout, stderr: "" });
const r1 = (): SpawnResult => ({ code: 1, signal: null, timedOut: false, stdout: "", stderr: "" });
const r = (stdout: string): SpawnResult => r0(stdout);
test("doctor: all prerequisites present exits 0", async () => {
const ctx = testCtx({
@@ -66,7 +67,15 @@ test("bootstrap: outside a repo fails with clone hint", async () => {
assert.ok(ctx.err().includes("git clone"));
});
test("bootstrap: step order — pnpm install before venv bootstrap before env copy", async () => {
const doctorPassSpawn = async (cmd: string, args: string[]): Promise<SpawnResult> => {
if (cmd === "pnpm" && args[0] === "--version") return r("10.0.0\n");
if (cmd === "python3" && args[0] === "--version") return r("Python 3.11.2\n");
if (cmd === "git") return r("git version 2.39.2\n");
if (cmd === "python3" && args[1] === "venv") return r0();
return r0();
};
test("bootstrap: step order — preflight, pnpm install, venv bootstrap, env copy", async () => {
const dir = mkdtempSync(join(tmpdir(), "nc-repo-"));
writeFileSync(join(dir, "pnpm-workspace.yaml"), "packages:\n - apps/*\n");
mkdirSync(join(dir, "apps", "ai-service"), { recursive: true });
@@ -77,13 +86,15 @@ test("bootstrap: step order — pnpm install before venv bootstrap before env co
cwd: dir,
spawn: async (cmd, args) => {
calls.push(`${cmd} ${args.join(" ")}`);
return r0();
return doctorPassSpawn(cmd, args);
},
});
const code = await bootstrap([], ctx);
assert.equal(code, 0);
assert.ok(calls[0].startsWith("pnpm install"));
assert.ok(calls[1].startsWith("bash scripts/bootstrap.sh"));
const installIdx = calls.findIndex((c) => c.startsWith("pnpm install"));
const venvIdx = calls.findIndex((c) => c.startsWith("bash scripts/bootstrap.sh"));
assert.ok(installIdx >= 0, "pnpm install runs");
assert.ok(venvIdx > installIdx, "venv bootstrap runs after pnpm install");
assert.ok(ctx.exists(join(dir, "apps", "ai-service", ".env")));
assert.ok(ctx.out().includes("bootstrap complete"));
});
@@ -96,7 +107,7 @@ test("bootstrap: existing .env kept, not overwritten", async () => {
writeFileSync(join(aiDir, ".env.example"), "AI_PORT=8420\n");
writeFileSync(join(aiDir, ".env"), "AI_PORT=9999\n");
const ctx = testCtx({ cwd: dir, spawn: async () => r0() });
const ctx = testCtx({ cwd: dir, spawn: doctorPassSpawn });
const code = await bootstrap([], ctx);
assert.equal(code, 0);
assert.ok(ctx.out().includes("kept as-is"));
@@ -110,14 +121,15 @@ test("bootstrap: failing pnpm install aborts before venv step", async () => {
const ctx = testCtx({
cwd: dir,
spawn: async (cmd, args) => {
calls.push(cmd);
return r1();
calls.push(`${cmd} ${args.join(" ")}`);
if (cmd === "pnpm" && args[0] === "install") return r1();
return doctorPassSpawn(cmd, args);
},
});
const code = await bootstrap([], ctx);
assert.equal(code, 1);
assert.equal(calls.length, 1);
assert.ok(ctx.err().includes("pnpm install failed"));
assert.ok(ctx.err().includes("pnpm install failed"), ctx.err());
assert.equal(calls.filter((c) => c.startsWith("bash scripts/bootstrap.sh")).length, 0, "venv step must not run");
});
test("verify: busy port fails with stop-the-process hint", async () => {
const dir = mkdtempSync(join(tmpdir(), "nc-ver-"));
@@ -161,3 +173,46 @@ test("verify: free port passes and reports AI_PORT from .env", async () => {
assert.equal(code, 0);
assert.ok(ctx.out().includes("port 8421 free"));
});
test("bootstrap: preflight failure aborts before pnpm install", async () => {
const dir = mkdtempSync(join(tmpdir(), "nc-pre-"));
writeFileSync(join(dir, "pnpm-workspace.yaml"), "packages:\n - apps/*\n");
const calls: string[] = [];
const ctx = testCtx({
cwd: dir,
spawn: async (cmd, args) => {
calls.push(`${cmd} ${args.join(" ")}`);
if (cmd === "which") return r0();
if (cmd === "pnpm" && args[0] === "--version") return r("10.0.0\n");
if (cmd === "python3" && args[0] === "--version") return r("Python 3.11.2\n");
if (cmd === "git") return r("git version 2.39.2\n");
if (cmd === "python3" && args[1] === "venv") return r1();
if (cmd === "rm") return r0();
return r0();
},
});
const code = await bootstrap([], ctx);
assert.equal(code, 1);
assert.ok(ctx.err().includes("preflight failed"));
assert.equal(calls.filter((c) => c.startsWith("pnpm install")).length, 0, "pnpm install must not run when preflight fails");
});
test("doctor: venv-capability probe failure surfaces apt hint", async () => {
const ctx = testCtx({
spawn: async (cmd, args) => {
if (cmd === "which") return r0();
if (cmd === "pnpm") return r("10.0.0\n");
if (cmd === "python3" && args[0] === "--version") return r("Python 3.12.3\n");
if (cmd === "git") return r("git version 2.43.0\n");
if (cmd === "node") return r("v24.0.0\n");
if (cmd === "python3" && args[1] === "venv") return r1();
if (cmd === "rm") return r0();
return r0();
},
});
const code = await doctor([], ctx);
assert.equal(code, 1);
assert.ok(ctx.err().includes("cannot create virtual environments"));
assert.ok(ctx.err().includes("apt install python"));
assert.ok(ctx.err().includes("-venv"));
});
+24
View File
@@ -122,6 +122,30 @@ test("install.sh: version-mismatching binary is rejected (G-102 install-time int
}
});
test("install.sh: silent --version binary is rejected (honesty gate)", async () => {
const home = mkdtempSync(join(tmpdir(), "nc-home-silent-"));
const srv = await serve((srvDir, base) => {
const bin = join(srvDir, "nextcraft-linux-x64");
writeFileSync(bin, "#!/bin/sh\nexit 0\n");
writeFileSync(join(srvDir, "nextcraft-linux-x64.sha256"), `${sha256(bin)} nextcraft-linux-x64\n`);
apiManifestDir(srvDir, {
tag_name: "v9.9.9",
assets: [
{ name: "nextcraft-linux-x64", browser_download_url: `${base()}/nextcraft-linux-x64` },
{ name: "nextcraft-linux-x64.sha256", browser_download_url: `${base()}/nextcraft-linux-x64.sha256` },
],
});
});
try {
const res = await runInstall(home, srv.url);
assert.notEqual(res.status, 0, "silent binary must be rejected");
assert.ok(res.stderr.includes("produced no output"), `stderr: ${res.stderr}`);
assert.ok(!existsSync(join(home, "bin", "nextcraft")) || res.stderr.includes("Do not use"), "must not leave a trusted silent binary");
} finally {
srv.stop();
}
});
test("install.sh: release without binary assets degrades to source instructions, exit 0", async () => {
const home = mkdtempSync(join(tmpdir(), "nc-home-nb-"));
const srv = await serve((srvDir) => {
+38
View File
@@ -0,0 +1,38 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const cliDir = join(dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = join(cliDir, "..", "..");
const binary = join(repoRoot, "apps", "cli", "dist", "nextcraft-linux-x64");
test("PATH bare-word invocation: installed binary speaks (SEA regression, v0.3.4 bug)", () => {
if (!existsSync(binary)) return; // built by cli:build:binary; covered in ship flow
const binDir = mkdtempSync(join(tmpdir(), "nc-pathword-"));
cpSync(binary, join(binDir, "nextcraft"));
const env = {
PATH: [binDir, process.env.PATH].filter(Boolean).join(":"),
HOME: process.env.HOME,
};
const { NODE_TEST_CONTEXT, ...rest } = process.env as Record<string, string | undefined>;
const envClean = { ...rest, ...env } as Record<string, string | undefined>;
const ver = spawnSync("sh", ["-c", "nextcraft --version"], { encoding: "utf8", timeout: 30000, env: envClean });
assert.equal(ver.status, 0, `--version rc: ${ver.status} stderr: ${ver.stderr}`);
assert.ok(ver.stdout.trim().length > 0, "--version must print the version (v0.3.4 was silent)");
const doc = spawnSync("sh", ["-c", "nextcraft doctor"], { encoding: "utf8", timeout: 120000, env: envClean });
assert.equal(doc.status, 0, `doctor rc: ${doc.status} stderr: ${doc.stderr}`);
assert.ok(doc.stdout.includes("environment prerequisites"), "doctor must print its report");
const noArgs = spawnSync("sh", ["-c", "nextcraft"], { encoding: "utf8", timeout: 30000, env: envClean });
assert.equal(noArgs.status, 2, "no args must exit 2");
assert.ok((noArgs.stderr + noArgs.stdout).includes("Usage:"), "no args must print usage");
const bad = spawnSync("sh", ["-c", "nextcraft nosuchcmd"], { encoding: "utf8", timeout: 30000, env: envClean });
assert.equal(bad.status, 2, "unknown command must exit 2");
});
+5 -1
View File
@@ -1 +1,5 @@
NEXT_PUBLIC_AI_SERVICE_URL=http://localhost:8420
# Browser-side engine API base URL. UNSET = auto (recommended): the browser
# derives http://<current-hostname>:8420 at runtime, so remote browsing works
# zero-config. Set explicitly only for unusual topologies, e.g.:
# NEXT_PUBLIC_AI_SERVICE_URL=http://ai.internal:8420
# NEXT_PUBLIC_AI_SERVICE_URL=
@@ -4,8 +4,9 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { parseSseEvents } from '../../lib/sse';
import { Bot, RefreshCw, AlertTriangle, Loader2 } from 'lucide-react';
const AI_SERVICE_URL =
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
import { engineBaseUrl } from '../../lib/engine-base-url';
const AI_SERVICE_URL = engineBaseUrl();
interface StreamPanelProps {
title: string;
+3 -2
View File
@@ -3,8 +3,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { parseSseEvents } from '../lib/sse';
const AI_SERVICE_URL =
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
import { engineBaseUrl } from '../lib/engine-base-url';
const AI_SERVICE_URL = engineBaseUrl();
export type AgentName = 'coach' | 'tutor' | 'lab' | 'assessor' | 'proctor' | 'mentor';
+8
View File
@@ -0,0 +1,8 @@
export function engineBaseUrl(): string {
const override = process.env.NEXT_PUBLIC_AI_SERVICE_URL;
if (override) return override;
if (typeof window !== "undefined") {
return `http://${window.location.hostname}:8420`;
}
return "http://localhost:8420";
}
+3 -2
View File
@@ -22,8 +22,9 @@ import type {
TelemetryEvent,
} from '@nextcraft/types';
export const AI_SERVICE_URL =
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
import { engineBaseUrl } from './engine-base-url';
export const AI_SERVICE_URL = engineBaseUrl();
/** v0.3 mock session constant (G-5: allowlisted server-side as pilot-learner). */
export const MOCK_LEARNER_ID = 'pilot-learner';
+4 -2
View File
@@ -9,7 +9,8 @@
"start": "next start",
"typecheck": "tsc --noEmit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"test": "tsx --test tests/*.test.ts"
},
"dependencies": {
"@nextcraft/mock-data": "workspace:*",
@@ -33,6 +34,7 @@
"@types/react-dom": "^19.0.0",
"storybook": "^10.6.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"tsx": "^4.23.0"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { test } from "node:test";
import assert from "node:assert/strict";
async function freshModule() {
const qs = `?t=${Date.now()}-${Math.random()}`;
return await import(`../lib/engine-base-url.ts${qs}`);
}
test("engine-base-url: NEXT_PUBLIC_AI_SERVICE_URL override wins", async () => {
process.env.NEXT_PUBLIC_AI_SERVICE_URL = "http://explicit.example:9000";
const mod = await freshModule();
assert.equal(mod.engineBaseUrl(), "http://explicit.example:9000");
delete process.env.NEXT_PUBLIC_AI_SERVICE_URL;
});
test("engine-base-url: browser context derives API host from window.location.hostname", async () => {
delete process.env.NEXT_PUBLIC_AI_SERVICE_URL;
const g = globalThis as Record<string, unknown>;
g.window = { location: { hostname: "nextcraft-1" } };
const mod = await freshModule();
assert.equal(mod.engineBaseUrl(), "http://nextcraft-1:8420");
delete g.window;
});
test("engine-base-url: server context falls back to localhost", async () => {
delete process.env.NEXT_PUBLIC_AI_SERVICE_URL;
const g = globalThis as Record<string, unknown>;
delete g.window;
const mod = await freshModule();
assert.equal(mod.engineBaseUrl(), "http://localhost:8420");
});
+3
View File
@@ -193,6 +193,9 @@ importers:
tailwindcss:
specifier: ^4.0.0
version: 4.3.3
tsx:
specifier: ^4.23.0
version: 4.23.13
typescript:
specifier: ^5.7.2
version: 5.9.3
+5 -2
View File
@@ -77,10 +77,13 @@ chmod +x "$DEST/nextcraft"
say "nextcraft: installed $DEST/nextcraft ($TAG)"
INSTALLED_VERSION="$("$DEST/nextcraft" --version 2>/dev/null || true)"
if [ -n "$INSTALLED_VERSION" ] && [ "$INSTALLED_VERSION" != "$TAG" ]; then
if [ -z "$INSTALLED_VERSION" ]; then
die "nextcraft: installed binary produced no output for --version — it is broken. Do not use it; remove $DEST/nextcraft, report the issue, and fall back to source bootstrap (git clone $FORGE_BASE/$OWNER/$REPO.git)."
fi
if [ "$INSTALLED_VERSION" != "$TAG" ]; then
die "nextcraft: binary reports version $INSTALLED_VERSION but release is $TAG — integrity mismatch. Remove $DEST/nextcraft and re-run the installer."
fi
say "nextcraft: verified binary version: ${INSTALLED_VERSION:-$TAG}"
say "nextcraft: verified binary version: $INSTALLED_VERSION"
case ":$PATH:" in
*":$DEST:"*) ;;
*)