diff --git a/README.md b/README.md index bac6fb5..1fdae99 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,22 @@ npm run dev # open http://localhost:3000 After the UI opens: **Settings** → configure a model provider → open a workspace → click **Sync** → start chatting. +### Optional CLI + +```bash +npm link +innoclaw +innoclaw run --prompt "Summarize the current workspace" +``` + +The CLI uses the same local app runtime as the web UI. When auth is enabled, it auto-starts the app, opens the browser login page on `localhost`, and stores a dedicated CLI session after sign-in. For trusted headless automation, start the app with authentication disabled. + +Headless local run mode: + +```bash +npm run dev:no-auth +``` + > **Security**: InnoClaw includes shell execution and remote job submission capabilities. See [SECURITY.md](SECURITY.md) for deployment hardening and trust boundary documentation.
@@ -141,6 +157,7 @@ Go from code inspection to job submission and result analysis. Review repositori #### 2026-05-12 - **Local User Authentication**: Added built-in account registration, sign-in, sign-out, and persistent session support. - **Admin User Management**: Added an admin users page to create accounts and manage roles, access, passwords, and ownership. +- **CLI Login Handoff**: `innoclaw` now supports interactive terminal mode, one-shot `run`, JSON `batch`, and browser-to-CLI session handoff. @@ -306,4 +323,3 @@ Go from code inspection to job submission and result analysis. Review repositori - **License** — Apache-2.0, see `LICENSE` - **Repository** — https://github.com/SpectrAI-Initiative/InnoClaw - **Docs** — https://SpectrAI-Initiative.github.io/InnoClaw/ - diff --git a/dev-start.sh b/dev-start.sh index eb1ee5e..9157dad 100755 --- a/dev-start.sh +++ b/dev-start.sh @@ -5,6 +5,86 @@ cd "$(dirname "$0")" PORT=3000 +if [ -n "${INNOCLAW_DEV_START_TEST_PATH:-}" ]; then + PATH="${INNOCLAW_DEV_START_TEST_PATH}:$PATH" + hash -r +fi + +server_responding() { + command -v curl >/dev/null 2>&1 || return 1 + command -v node >/dev/null 2>&1 || return 1 + + local body_file meta status content_type node_status + body_file=$(mktemp) || return 1 + meta=$(curl --noproxy "*" -sS -o "$body_file" -w "%{http_code}\n%{content_type}" "http://127.0.0.1:$PORT/api/auth/me" 2>/dev/null) || { + rm -f "$body_file" + return 1 + } + status=$(printf '%s\n' "$meta" | sed -n '1p') + content_type=$(printf '%s\n' "$meta" | sed -n '2p') + + case "${content_type,,}" in + *application/json*) ;; + *) + rm -f "$body_file" + return 1 + ;; + esac + + node - "$status" "$body_file" <<'NODE' +const [status, bodyPath] = process.argv.slice(2); +const { readFileSync } = require("node:fs"); + +let body; +try { + body = JSON.parse(readFileSync(bodyPath, "utf8")); +} catch { + process.exit(1); +} + +if (status === "200" && body && typeof body.authMode === "string" && body.user) { + process.exit(0); +} + +if (status === "401" && body?.error === "Unauthorized") { + process.exit(0); +} + +process.exit(1); +NODE + node_status=$? + rm -f "$body_file" + return "$node_status" +} + +pid_elapsed_seconds() { + ps -p "$1" -o etimes= 2>/dev/null | tr -d ' ' +} + +pid_workdir() { + readlink "/proc/$1/cwd" 2>/dev/null +} + +is_repo_dev_process() { + local pid=$1 + local cwd=$(pid_workdir "$pid") + local cmdline=$(ps -p "$pid" -o args= 2>/dev/null) + [ "$cwd" = "$PWD" ] && echo "$cmdline" | grep -qE "(npm run dev|next dev|node.*next)" +} + +if [ "${INNOCLAW_DEV_START_TEST_HOOK:-}" = "1" ]; then + case "${1:-}" in + server_responding) + server_responding + exit $? + ;; + *) + echo "Unknown dev-start test hook: ${1:-}" >&2 + exit 64 + ;; + esac +fi + # Check if already running if [ -f .dev.pid ]; then PID=$(cat .dev.pid) @@ -12,8 +92,30 @@ if [ -f .dev.pid ]; then echo "Invalid PID in .dev.pid, removing file" rm -f .dev.pid elif ps -p "$PID" > /dev/null 2>&1; then - echo "Dev server is already running (PID: $PID)" - exit 1 + if ! is_repo_dev_process "$PID"; then + PID_CWD=$(pid_workdir "$PID") + echo ".dev.pid points to a live non-server process (PID: $PID${PID_CWD:+, cwd: $PID_CWD}). Removing stale file." + rm -f .dev.pid + elif server_responding; then + echo "Dev server is already running (PID: $PID)" + exit 0 + else + PID_AGE=$(pid_elapsed_seconds "$PID") + if [ -n "$PID_AGE" ] && [ "$PID_AGE" -le 30 ]; then + echo "Dev server is still starting (PID: $PID, age: ${PID_AGE}s)" + exit 0 + fi + + echo "Dev server PID $PID is not healthy. Restarting it..." + kill "$PID" 2>/dev/null + sleep 2 + if ps -p "$PID" > /dev/null 2>&1; then + echo "Force killing stale dev server..." + kill -9 "$PID" 2>/dev/null + sleep 1 + fi + rm -f .dev.pid + fi else rm -f .dev.pid fi @@ -22,63 +124,45 @@ fi # Check if port is occupied check_port() { local port=$1 - local pid=$(lsof -t -i:$port 2>/dev/null) - if [ -n "$pid" ]; then - echo "Port $port is occupied by process: $pid" - local process_name=$(ps -p "$pid" -o comm= 2>/dev/null) - echo "Process name: $process_name" + local pids=$(lsof -t -i:$port 2>/dev/null) + if [ -n "$pids" ]; then + echo "Port $port is occupied by process: $pids" + for pid in $pids; do + local process_name=$(ps -p "$pid" -o comm= 2>/dev/null) + local cmdline=$(ps -p "$pid" -o args= 2>/dev/null) + echo "Process $pid: ${process_name:-unknown} ${cmdline:+($cmdline)}" + done return 0 fi return 1 } -kill_port_process() { +port_owned_by_pid_file() { local port=$1 - # Only kill node/next processes, not VSCode port forwarding + [ -f .dev.pid ] || return 1 + local expected_pid=$(cat .dev.pid) + echo "$expected_pid" | grep -qE '^[0-9]+$' || return 1 + is_repo_dev_process "$expected_pid" || return 1 + local pids=$(lsof -t -i:$port 2>/dev/null) - if [ -n "$pids" ]; then - for pid in $pids; do - local cmdline=$(ps -p "$pid" -o args= 2>/dev/null) - # Skip VSCode related processes - if echo "$cmdline" | grep -qE "(vscode|code-server|sshd)"; then - echo "Skipping VSCode/SSH process: $pid ($cmdline)" - continue - fi - # Only kill node/next related processes - if echo "$cmdline" | grep -qE "(node|next|npm)"; then - echo "Killing process $pid on port $port ($cmdline)..." - kill "$pid" 2>/dev/null - sleep 2 - if ps -p "$pid" > /dev/null 2>&1; then - echo "Force killing..." - kill -9 "$pid" 2>/dev/null - sleep 1 - fi - fi - done - if lsof -t -i:$port > /dev/null 2>&1; then - # Check if remaining process is VSCode - local remaining=$(lsof -t -i:$port 2>/dev/null | head -1) - local remaining_cmd=$(ps -p "$remaining" -o args= 2>/dev/null) - if echo "$remaining_cmd" | grep -qE "(vscode|code-server|sshd)"; then - echo "Port $port still used by VSCode (safe to ignore)" - return 0 - fi - echo "Failed to free port $port" - return 1 + [ -n "$pids" ] || return 1 + for pid in $pids; do + if [ "$pid" = "$expected_pid" ]; then + return 0 fi - echo "Port $port is now free" - fi - return 0 + done + return 1 } # Check and resolve port conflict if check_port $PORT; then - echo "Attempting to free port $PORT..." - if ! kill_port_process $PORT; then - echo "Error: Could not free port $PORT. Please manually resolve." - exit 1 + if port_owned_by_pid_file $PORT && server_responding; then + echo "Dev server is already running (PID: $(cat .dev.pid))" + exit 0 fi + echo "Error: Port $PORT is already in use by a process not managed by this repo's .dev.pid." + echo "Stop that process or remove the conflict before running dev-start.sh." + exit 1 fi # Install dependencies if needed diff --git a/docs/usage/api-reference.md b/docs/usage/api-reference.md index 88a98c8..2a7c785 100644 --- a/docs/usage/api-reference.md +++ b/docs/usage/api-reference.md @@ -2,6 +2,72 @@ InnoClaw provides a set of REST API endpoints served by Next.js API routes. All endpoints are under the `/api/` path. +## Auth + +### Login + +``` +POST /api/auth/login +``` + +Creates a browser session for a local user account. + +### Register + +``` +POST /api/auth/register +``` + +Creates a local user account and signs the user in. + +### Current Session + +``` +GET /api/auth/me +``` + +Returns the signed-in user and session expiry, or `401` if the request is unauthenticated. + +### CLI Session Handoff + +``` +POST /api/auth/cli-session +``` + +Requires an authenticated browser session. Mints a fresh CLI session for the same user and returns the cookie triple needed by the terminal client. + +**Request Body:** + +```json +{ + "nonce": "cli-login-nonce" +} +``` + +**Response:** + +```json +{ + "nonce": "cli-login-nonce", + "expiresAt": "2026-06-20T00:00:00.000Z", + "user": { + "id": "user-123", + "email": "user@example.com", + "name": "User", + "role": "user", + "isActive": true, + "lastLoginAt": null, + "createdAt": "2026-05-20T00:00:00.000Z", + "updatedAt": "2026-05-20T00:00:00.000Z" + }, + "cookies": { + "innoclaw_session": "token", + "innoclaw_session_expires": "2026-06-20T00:00:00.000Z", + "innoclaw_session_sig": "signature" + } +} +``` + ## Workspaces ### List Workspaces @@ -12,6 +78,8 @@ GET /api/workspaces Returns all workspaces. +When auth is enabled, this endpoint requires a valid browser or CLI session cookie set. With `AUTH_MODE=disabled`, the trusted no-auth path returns the anonymous admin context. + **Response:** ```json @@ -19,7 +87,7 @@ Returns all workspaces. { "id": "workspace-uuid", "name": "my-project", - "rootPath": "/data/research/my-project", + "folderPath": "/data/research/my-project", "createdAt": "2025-01-01T00:00:00.000Z" } ] @@ -35,7 +103,8 @@ POST /api/workspaces ```json { - "path": "/data/research/my-project" + "name": "my-project", + "folderPath": "/data/research/my-project" } ``` @@ -694,8 +763,8 @@ POST /api/git/clone ```json { - "url": "https://github.com/user/repo.git", - "rootPath": "/data/research" + "repoUrl": "https://github.com/user/repo.git", + "targetFolderName": "repo" } ``` diff --git a/plugins/innoclaw-cli/README.md b/plugins/innoclaw-cli/README.md index 145e52b..4208597 100644 --- a/plugins/innoclaw-cli/README.md +++ b/plugins/innoclaw-cli/README.md @@ -4,12 +4,16 @@ ## What it provides +- `innoclaw` interactive TUI, using the current shell directory as the workspace +- `innoclaw run --prompt ...` for one-shot non-interactive agent runs +- `innoclaw batch --input ...` for JSON-driven batch runs +- `innoclaw auth status|login|logout` - `innoclaw app dev|build|lint|test|start` - `innoclaw doctor` - `innoclaw workspace list|add` - `innoclaw research list|create|show|run|export` -The Deep Research commands call the existing HTTP API exposed by the local Next.js app. By default the CLI targets `http://localhost:3000`, or `INNOCLAW_BASE_URL` if set. +The CLI keeps the local Next.js app as the runtime. By default it targets `http://localhost:3000`, auto-starts the local app when needed, opens the browser login page, and stores a dedicated CLI session for later reuse. For trusted headless runs, start the app with `npm run dev:no-auth` or set `AUTH_MODE=disabled`. ## Local usage @@ -29,6 +33,11 @@ innoclaw --help ## Examples ```bash +innoclaw +innoclaw run --prompt "Summarize the current workspace" +printf 'Generate a plan for this repository' | innoclaw run +innoclaw batch --input jobs.json --workers 4 +innoclaw auth login innoclaw doctor innoclaw app dev innoclaw workspace list @@ -37,3 +46,22 @@ innoclaw research create --workspace-id --title "Survey of time-s innoclaw research run --session-id innoclaw research export --session-id ``` + +## Interactive login flow + +When auth is enabled, the first interactive CLI command: + +1. ensures the local app is running, +2. opens `http://localhost:3000/login` in your browser, +3. waits for browser sign-in or registration, +4. receives a dedicated CLI cookie triple through a localhost callback, +5. persists that CLI session in `~/.innoclaw/cli-sessions.json`. + +The browser and CLI share the same user identity, but they do not reuse the same session token set. + +## Headless run mode + +```bash +npm run dev:no-auth +innoclaw run --prompt "Summarize the current workspace" +``` diff --git a/plugins/innoclaw-cli/scripts/innoclaw-cli.mjs b/plugins/innoclaw-cli/scripts/innoclaw-cli.mjs index f565efb..7717ca4 100755 --- a/plugins/innoclaw-cli/scripts/innoclaw-cli.mjs +++ b/plugins/innoclaw-cli/scripts/innoclaw-cli.mjs @@ -4,14 +4,28 @@ import { spawn } from "node:child_process"; import { existsSync, statSync } from "node:fs"; import { resolve } from "node:path"; import process from "node:process"; - -const DEFAULT_BASE_URL = process.env.INNOCLAW_BASE_URL || "http://localhost:3000"; -const REPO_ROOT = process.cwd(); +import { + APP_ROOT, + DEFAULT_BASE_URL, + DEFAULT_WORKSPACE_CWD, + normalizeBaseUrl, +} from "../src/runtime.mjs"; +import { createApiClient } from "../src/http.mjs"; +import { runAgentStream, makeUserMessage, getMessageText } from "../src/agent-client.mjs"; +import { runBatch } from "../src/batch-client.mjs"; +import { createRenderer, startRepl } from "../src/repl.mjs"; +import { ensureServerReady } from "../src/server-client.mjs"; +import { createSessionManager } from "../src/session-client.mjs"; +import { ensureWorkspace } from "../src/workspace-client.mjs"; function printHelp() { console.log(`InnoClaw CLI Usage: + innoclaw + innoclaw run --prompt + innoclaw batch --input + innoclaw auth innoclaw doctor innoclaw app [-- ] innoclaw workspace list [--base-url ] @@ -21,6 +35,23 @@ Usage: innoclaw research show --session-id [--base-url ] innoclaw research run --session-id [--base-url ] innoclaw research export --session-id [--filename ] [--base-url ] + +Shared flags: + --base-url Override the local app URL (default: ${DEFAULT_BASE_URL}) + --cwd Workspace directory for interactive/run/batch (default: current shell directory) + --workspace-name Explicit workspace name when auto-registering --cwd + --skill Run a specific skill instead of the default agent + --provider Override model provider for this run + --model Override model name for this run + --mode + Agent run mode for interactive/run/batch defaults + +Examples: + innoclaw + innoclaw run --prompt "Summarize this repository" + printf 'Plan this workspace' | innoclaw run + innoclaw batch --input jobs.json --workers 4 + innoclaw auth login `); } @@ -67,32 +98,43 @@ function requireFlag(flags, name) { return value.trim(); } +function getOptionalFlag(flags, name) { + const value = flags[name]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseIntegerFlag(flags, name) { + const raw = getOptionalFlag(flags, name); + if (raw === null) { + return null; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid --${name}: ${raw}`); + } + return parsed; +} + function getBaseUrl(flags) { - return typeof flags["base-url"] === "string" && flags["base-url"].trim().length > 0 - ? flags["base-url"].trim().replace(/\/$/, "") - : DEFAULT_BASE_URL.replace(/\/$/, ""); + return normalizeBaseUrl(getOptionalFlag(flags, "base-url") || DEFAULT_BASE_URL); } -async function requestJson(path, { method = "GET", body, baseUrl }) { - const response = await fetch(`${baseUrl}${path}`, { - method, - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); +function getWorkspaceCwd(flags) { + return resolve(getOptionalFlag(flags, "cwd") || DEFAULT_WORKSPACE_CWD); +} - const payload = await response.json().catch(() => null); - if (!response.ok) { - const message = payload && typeof payload.error === "string" ? payload.error : `${response.status} ${response.statusText}`; - throw new Error(message); +function parseListFlag(flags, name) { + const raw = getOptionalFlag(flags, name); + if (!raw) { + return []; } - - return payload; + return raw.split(",").map((value) => value.trim()).filter(Boolean); } function runNpmScript(script, extraArgs) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn("npm", ["run", script, ...(extraArgs.length > 0 ? ["--", ...extraArgs] : [])], { - cwd: REPO_ROOT, + cwd: APP_ROOT, stdio: "inherit", shell: false, }); @@ -111,22 +153,60 @@ function formatJson(value) { console.log(JSON.stringify(value, null, 2)); } -async function handleDoctor() { +function createManagedSession(baseUrl) { + return createSessionManager(baseUrl, ({ getCookieHeader, onResponse }) => + createApiClient({ baseUrl, getCookieHeader, onResponse })); +} + +async function createApiContext(baseUrl, { authenticate = true, interactiveAuth = true } = {}) { + await ensureServerReady({ appRoot: APP_ROOT, baseUrl }); + const sessionManager = createManagedSession(baseUrl); + await sessionManager.load(); + if (authenticate) { + await sessionManager.ensureAuthenticated({ interactive: interactiveAuth }); + } + return sessionManager; +} + +async function readPromptFromStdin() { + if (process.stdin.isTTY) { + return ""; + } + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf-8")); + } + return chunks.join("").trim(); +} + +function getRunMode(flags) { + const mode = getOptionalFlag(flags, "mode") || "agent"; + if (!["agent", "ask", "plan", "long-agent"].includes(mode)) { + throw new Error(`Unsupported --mode: ${mode}`); + } + return mode; +} + +async function handleDoctor(flags) { const checks = { node: process.version, - repoRoot: REPO_ROOT, - hasEnvLocal: existsSync(resolve(REPO_ROOT, ".env.local")), - hasDataDir: existsSync(resolve(REPO_ROOT, "data")), - hasNodeModules: existsSync(resolve(REPO_ROOT, "node_modules")), + appRoot: APP_ROOT, + baseUrl: getBaseUrl(flags), + workspaceCwd: getWorkspaceCwd(flags), + hasEnvLocal: existsSync(resolve(APP_ROOT, ".env.local")), + hasDataDir: existsSync(resolve(APP_ROOT, "data")), + hasNodeModules: existsSync(resolve(APP_ROOT, "node_modules")), }; formatJson(checks); } async function handleWorkspace(command, flags) { const baseUrl = getBaseUrl(flags); + const sessionManager = await createApiContext(baseUrl); + const apiClient = sessionManager.apiClient; if (command === "list") { - const data = await requestJson("/api/workspaces", { baseUrl }); - formatJson(data); + const { payload } = await apiClient.requestJson("/api/workspaces"); + formatJson(payload); return; } @@ -141,12 +221,11 @@ async function handleWorkspace(command, flags) { isGitRepo: flags.git === true, gitRemoteUrl: typeof flags["git-remote-url"] === "string" ? flags["git-remote-url"] : undefined, }; - const data = await requestJson("/api/workspaces", { + const { payload: created } = await apiClient.requestJson("/api/workspaces", { method: "POST", body: payload, - baseUrl, }); - formatJson(data); + formatJson(created); return; } @@ -155,13 +234,13 @@ async function handleWorkspace(command, flags) { async function handleResearch(command, flags) { const baseUrl = getBaseUrl(flags); + const sessionManager = await createApiContext(baseUrl); + const apiClient = sessionManager.apiClient; if (command === "list") { const workspaceId = requireFlag(flags, "workspace-id"); - const data = await requestJson(`/api/deep-research/sessions?workspaceId=${encodeURIComponent(workspaceId)}`, { - baseUrl, - }); - formatJson(data); + const { payload } = await apiClient.requestJson(`/api/deep-research/sessions?workspaceId=${encodeURIComponent(workspaceId)}`); + formatJson(payload); return; } @@ -172,61 +251,248 @@ async function handleResearch(command, flags) { content: typeof flags.content === "string" ? flags.content : undefined, config: flags["interface-only"] === true ? { interfaceOnly: true } : undefined, }; - const data = await requestJson("/api/deep-research/sessions", { + const { payload: created } = await apiClient.requestJson("/api/deep-research/sessions", { method: "POST", body: payload, - baseUrl, }); - formatJson(data); + formatJson(created); return; } if (command === "show") { const sessionId = requireFlag(flags, "session-id"); - const data = await requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}`, { - baseUrl, - }); - formatJson(data); + const { payload } = await apiClient.requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}`); + formatJson(payload); return; } if (command === "run") { const sessionId = requireFlag(flags, "session-id"); - const data = await requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}/run`, { + const { payload } = await apiClient.requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}/run`, { method: "POST", body: {}, - baseUrl, }); - formatJson(data); + formatJson(payload); return; } if (command === "export") { const sessionId = requireFlag(flags, "session-id"); const payload = typeof flags.filename === "string" ? { filename: flags.filename } : {}; - const data = await requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}/export`, { + const { payload: exported } = await apiClient.requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}/export`, { method: "POST", body: payload, - baseUrl, }); - formatJson(data); + formatJson(exported); return; } throw new Error(`Unsupported research command: ${command}`); } +async function handleAuth(command, flags) { + const action = command || "status"; + const baseUrl = getBaseUrl(flags); + const sessionManager = createManagedSession(baseUrl); + await sessionManager.load(); + + if (action === "logout") { + try { + await ensureServerReady({ appRoot: APP_ROOT, baseUrl }); + } catch { + // Clear cached CLI state even if the app is offline. + } + await sessionManager.revoke(); + console.log("[innoclaw] Logged out."); + return; + } + + await ensureServerReady({ appRoot: APP_ROOT, baseUrl }); + + if (action === "login") { + await sessionManager.ensureAuthenticated({ interactive: true }); + } else if (action !== "status") { + throw new Error("Usage: innoclaw auth "); + } + + const status = await sessionManager.getAuthStatus(); + formatJson({ + baseUrl, + authenticated: status.authenticated, + user: status.user, + session: status.session, + cachedSession: sessionManager.getSession(), + }); +} + +async function resolveWorkspaceContext(flags, { interactiveAuth = true } = {}) { + const baseUrl = getBaseUrl(flags); + const sessionManager = await createApiContext(baseUrl, { authenticate: true, interactiveAuth }); + const cwd = getWorkspaceCwd(flags); + const workspace = await ensureWorkspace( + sessionManager.apiClient, + cwd, + getOptionalFlag(flags, "workspace-name") || undefined, + ); + const authStatus = await sessionManager.getAuthStatus(); + + return { + baseUrl, + cwd, + workspace, + sessionManager, + apiClient: sessionManager.apiClient, + authStatus, + }; +} + +async function handleInteractive(flags) { + const context = await resolveWorkspaceContext(flags, { interactiveAuth: true }); + await startRepl(context.apiClient, { + baseUrl: context.baseUrl, + workspace: context.workspace, + cwd: context.cwd, + session: context.authStatus.authenticated + ? { user: context.authStatus.user, expiresAt: context.authStatus.session?.expiresAt } + : context.sessionManager.getSession(), + skill: getOptionalFlag(flags, "skill"), + provider: getOptionalFlag(flags, "provider"), + model: getOptionalFlag(flags, "model"), + mode: getRunMode(flags), + onLogout: async () => { + await context.sessionManager.revoke(); + }, + }); +} + +async function handleRun(flags, promptArgs) { + const prompt = getOptionalFlag(flags, "prompt") + || promptArgs.join(" ").trim() + || await readPromptFromStdin(); + + if (!prompt) { + throw new Error("Usage: innoclaw run --prompt "); + } + + const context = await resolveWorkspaceContext(flags, { interactiveAuth: true }); + const skill = getOptionalFlag(flags, "skill"); + const provider = getOptionalFlag(flags, "provider"); + const model = getOptionalFlag(flags, "model"); + const mode = getRunMode(flags); + const jsonOutput = flags.json === true; + const renderer = jsonOutput ? null : createRenderer(); + + if (!jsonOutput) { + console.log(`[innoclaw] workspace=${context.workspace.name} cwd=${context.cwd}`); + } + + const result = await runAgentStream(context.apiClient, { + messages: [makeUserMessage(prompt)], + workspaceId: context.workspace.id, + cwd: context.cwd, + mode, + skillId: skill || undefined, + paramValues: skill ? { user_input: prompt } : undefined, + llmProvider: provider || undefined, + llmModel: model || undefined, + sessionCreatedAt: new Date().toISOString(), + onSnapshot(message) { + renderer?.onSnapshot(message); + }, + }); + + renderer?.finish(); + + const assistantMessage = [...result.messages] + .reverse() + .find((message) => message.role === "assistant"); + const assistantText = assistantMessage ? getMessageText(assistantMessage) : ""; + + if (jsonOutput) { + formatJson({ + workspace: context.workspace, + cwd: context.cwd, + provider: result.provider, + model: result.model, + assistantText, + }); + return; + } + + if (!assistantText.trim()) { + console.log("[innoclaw] Agent completed without assistant text."); + } + console.log(""); +} + +async function handleBatch(flags, positional) { + const inputPath = resolve(getOptionalFlag(flags, "input") || positional[0] || ""); + if (!inputPath || inputPath === resolve("")) { + throw new Error("Usage: innoclaw batch --input "); + } + + const context = await createApiContext(getBaseUrl(flags), { authenticate: true, interactiveAuth: true }); + const workers = parseIntegerFlag(flags, "workers") || 2; + const limit = parseIntegerFlag(flags, "limit"); + const batchResult = await runBatch(context.apiClient, { + inputPath, + defaultCwd: getWorkspaceCwd(flags), + defaultSkill: getOptionalFlag(flags, "skill") || undefined, + defaultProvider: getOptionalFlag(flags, "provider") || undefined, + defaultModel: getOptionalFlag(flags, "model") || undefined, + defaultMode: getRunMode(flags), + workers, + startId: getOptionalFlag(flags, "start-id") || undefined, + ids: parseListFlag(flags, "ids"), + limit, + outputDir: getOptionalFlag(flags, "output-dir") || undefined, + jsonl: flags.jsonl === true, + failFast: flags["fail-fast"] === true, + }); + + if (flags.json === true) { + formatJson(batchResult); + return; + } + + console.log("[innoclaw] Batch complete."); + console.log(`runDir: ${batchResult.runDir}`); + console.log(`resultsFile: ${batchResult.resultsFile}`); + console.log(`summaryFile: ${batchResult.summaryFile}`); + console.log(`done: ${batchResult.summary.done}/${batchResult.summary.total}`); +} + async function main() { const { positional, flags, passthrough } = parseArgs(process.argv.slice(2)); - if (positional.length === 0 || flags.help === true) { + if (flags.help === true) { printHelp(); return; } + if (positional.length === 0) { + await handleInteractive(flags); + return; + } + const [group, command] = positional; if (group === "doctor") { - await handleDoctor(); + await handleDoctor(flags); + return; + } + + if (group === "run") { + await handleRun(flags, positional.slice(1)); + return; + } + + if (group === "batch") { + await handleBatch(flags, positional.slice(1)); + return; + } + + if (group === "auth") { + await handleAuth(command, flags); return; } diff --git a/plugins/innoclaw-cli/skills/innoclaw-cli/SKILL.md b/plugins/innoclaw-cli/skills/innoclaw-cli/SKILL.md index 72f16d1..8223180 100644 --- a/plugins/innoclaw-cli/skills/innoclaw-cli/SKILL.md +++ b/plugins/innoclaw-cli/skills/innoclaw-cli/SKILL.md @@ -7,6 +7,29 @@ description: Use the local InnoClaw CLI to run app workflows and Deep Research s Use the `innoclaw` command from the repository root for local operation. +Bare `innoclaw` starts the interactive CLI and treats the current shell directory as the workspace. + +## Interactive and auth + +```bash +innoclaw +innoclaw auth status +innoclaw auth login +innoclaw auth logout +``` + +- The CLI auto-starts the local app on `localhost:3000` when needed. +- When auth is enabled, the CLI opens the browser login page and waits for a dedicated CLI session handoff. +- For trusted headless and CI-style runs, start the app with `npm run dev:no-auth` or `AUTH_MODE=disabled`. + +## Non-interactive agent runs + +```bash +innoclaw run --prompt "Summarize this workspace" +printf 'Create a plan for the current repo' | innoclaw run +innoclaw batch --input jobs.json --workers 4 +``` + ## Command groups ### App lifecycle @@ -44,6 +67,7 @@ innoclaw research export --session-id ## Usage notes -- `research create`, `research run`, and `research export` require the local app server to be running. +- `research create`, `research run`, and `research export` use the same local app runtime and auth flow as `innoclaw`. - `workspace add` expects a filesystem path that already exists on disk. -- The CLI is intentionally thin: it wraps existing repo commands and HTTP APIs rather than bypassing them. +- `innoclaw`, `run`, and `batch` auto-bind the current shell directory as a workspace if needed. +- The CLI stays thin: it wraps the existing local app and HTTP APIs rather than bypassing them. diff --git a/plugins/innoclaw-cli/src/agent-client.mjs b/plugins/innoclaw-cli/src/agent-client.mjs new file mode 100644 index 0000000..21d9c81 --- /dev/null +++ b/plugins/innoclaw-cli/src/agent-client.mjs @@ -0,0 +1,146 @@ +import { parseJsonEventStream } from "@ai-sdk/provider-utils"; +import { readUIMessageStream, uiMessageChunkSchema } from "ai"; + +function randomId(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function upsertMessage(messages, nextMessage) { + const index = messages.findIndex((message) => message.id === nextMessage.id); + if (index >= 0) { + messages[index] = nextMessage; + } else { + messages.push(nextMessage); + } +} + +export function makeUserMessage(text) { + return { + id: randomId("user"), + role: "user", + parts: [{ type: "text", text }], + }; +} + +export function getToolName(part) { + if (part.toolName) { + return part.toolName; + } + if (typeof part.type === "string" && part.type.startsWith("tool-")) { + return part.type.slice(5); + } + return "unknown"; +} + +export function summarizeToolInput(toolName, input) { + const args = input && typeof input === "object" ? input : {}; + switch (toolName) { + case "bash": + return String(args.command || ""); + case "readFile": + return String(args.filePath || ""); + case "writeFile": + return String(args.filePath || ""); + case "listDirectory": + return String(args.dirPath || "."); + case "grep": + return String(args.pattern || ""); + case "getSkillInstructions": + return String(args.slug || ""); + default: + try { + return JSON.stringify(args); + } catch { + return ""; + } + } +} + +export function getMessageText(message) { + return (message.parts || []) + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); +} + +export async function runAgentStream(apiClient, { + messages, + workspaceId, + cwd, + mode = "agent", + skillId, + paramValues, + llmProvider, + llmModel, + sessionCreatedAt, + timeoutMs = 2 * 60 * 60 * 1000, + onHeaders, + onSnapshot, +} = {}) { + const payload = { + messages, + workspaceId, + cwd, + mode, + sessionCreatedAt, + }; + + if (skillId) { + payload.skillId = skillId; + } + if (paramValues && Object.keys(paramValues).length > 0) { + payload.paramValues = paramValues; + } + if (llmProvider && llmModel) { + payload.llmProvider = llmProvider; + payload.llmModel = llmModel; + } + + const response = await apiClient.request("/api/agent", { + method: "POST", + body: payload, + timeoutMs, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(text || `${response.status} ${response.statusText}`); + } + + onHeaders?.({ + provider: response.headers.get("X-Agent-Provider"), + model: response.headers.get("X-Agent-Model"), + }); + + if (!response.body) { + return { + messages: [...messages], + provider: response.headers.get("X-Agent-Provider"), + model: response.headers.get("X-Agent-Model"), + }; + } + + const nextMessages = [...messages]; + const chunkStream = parseJsonEventStream({ + stream: response.body, + schema: uiMessageChunkSchema, + }).pipeThrough(new TransformStream({ + transform(chunk, controller) { + if (chunk.success) { + controller.enqueue(chunk.value); + } + }, + })); + + const messageStream = readUIMessageStream({ stream: chunkStream }); + for await (const message of messageStream) { + upsertMessage(nextMessages, message); + onSnapshot?.(message, nextMessages); + } + + return { + messages: nextMessages, + provider: response.headers.get("X-Agent-Provider"), + model: response.headers.get("X-Agent-Model"), + }; +} diff --git a/plugins/innoclaw-cli/src/batch-client.mjs b/plugins/innoclaw-cli/src/batch-client.mjs new file mode 100644 index 0000000..799e56b --- /dev/null +++ b/plugins/innoclaw-cli/src/batch-client.mjs @@ -0,0 +1,248 @@ +import path from "node:path"; +import { mkdir, readFile, writeFile, appendFile } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; +import { ensureWorkspace } from "./workspace-client.mjs"; +import { getMessageText, makeUserMessage, runAgentStream } from "./agent-client.mjs"; + +const VALID_RUN_MODES = new Set(["agent", "ask", "plan", "long-agent"]); + +function timestampForPath() { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +function normalizeBatchEntries(entries) { + if (!Array.isArray(entries)) { + throw new Error("Batch input must be a JSON array"); + } + return entries.map((entry, index) => { + const prompt = entry?.prompt ?? entry?.requirement ?? entry?.text; + if (typeof prompt !== "string" || prompt.trim().length === 0) { + throw new Error(`Batch item ${index + 1} is missing a non-empty prompt`); + } + const mode = entry?.mode; + if (mode !== undefined && (!VALID_RUN_MODES.has(mode))) { + throw new Error(`Batch item ${index + 1} has unsupported mode: ${mode}`); + } + return { + id: typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : `item-${index + 1}`, + prompt: prompt.trim(), + mode: typeof mode === "string" ? mode : null, + skill: typeof entry.skill === "string" ? entry.skill : null, + cwd: entry.cwd || entry.workspace || null, + provider: typeof entry.provider === "string" ? entry.provider : null, + model: typeof entry.model === "string" ? entry.model : null, + params: entry.params && typeof entry.params === "object" ? entry.params : null, + }; + }); +} + +async function readBatchInput(inputPath) { + const raw = await readFile(inputPath, "utf-8"); + return normalizeBatchEntries(JSON.parse(raw)); +} + +async function appendEvent(logFile, line) { + await appendFile(logFile, `${line}\n`, "utf-8"); +} + +/** + * @param {unknown} apiClient + * @param {{ + * inputPath?: string, + * defaultCwd?: string, + * defaultSkill?: string | null, + * defaultProvider?: string | null, + * defaultModel?: string | null, + * defaultMode?: string, + * workers?: number, + * startId?: string | null, + * ids?: string[], + * limit?: number | null, + * outputDir?: string | null, + * jsonl?: boolean, + * failFast?: boolean, + * }} [options] + */ +export async function runBatch(apiClient, { + inputPath, + defaultCwd, + defaultSkill = null, + defaultProvider = null, + defaultModel = null, + defaultMode = "agent", + workers = 2, + startId = null, + ids = [], + limit = null, + outputDir = null, + jsonl = false, + failFast = false, +} = {}) { + if (!VALID_RUN_MODES.has(defaultMode)) { + throw new Error(`Unsupported default mode: ${defaultMode}`); + } + + const allEntries = await readBatchInput(inputPath); + let entries = allEntries; + + if (Array.isArray(ids) && ids.length > 0) { + const idSet = new Set(ids); + entries = entries.filter((entry) => idSet.has(entry.id)); + } else if (startId) { + const startIndex = entries.findIndex((entry) => entry.id === startId); + if (startIndex === -1) { + throw new Error(`start-id not found: ${startId}`); + } + entries = entries.slice(startIndex); + } + + if (typeof limit === "number" && Number.isFinite(limit)) { + entries = entries.slice(0, limit); + } + + const runDir = outputDir + ? path.resolve(outputDir) + : path.join(path.resolve(defaultCwd), ".innoclaw", "runs", timestampForPath()); + await mkdir(runDir, { recursive: true }); + + const resultsFile = path.join(runDir, "results.json"); + const summaryFile = path.join(runDir, "summary.json"); + const eventsFile = path.join(runDir, "events.log"); + const jsonlFile = path.join(runDir, "results.jsonl"); + + const results = []; + const workspaceCache = new Map(); + let nextIndex = 0; + let stopDispatch = false; + + async function resolveWorkspace(folderPath) { + const resolved = path.resolve(folderPath); + if (workspaceCache.has(resolved)) { + return workspaceCache.get(resolved); + } + const workspace = await ensureWorkspace(apiClient, resolved); + workspaceCache.set(resolved, workspace); + return workspace; + } + + async function runEntry(entry) { + const cwd = path.resolve(entry.cwd || defaultCwd); + const skill = entry.skill || defaultSkill; + const provider = entry.provider || defaultProvider; + const model = entry.model || defaultModel; + const mode = entry.mode || defaultMode; + const workspace = await resolveWorkspace(cwd); + + const startedAt = new Date().toISOString(); + const timer = performance.now(); + await appendEvent(eventsFile, `[START] ${entry.id} ${startedAt}`); + console.log(`[START] ${entry.id}`); + + try { + const agentResult = await runAgentStream(apiClient, { + messages: [makeUserMessage(entry.prompt)], + workspaceId: workspace.id, + cwd, + mode, + skillId: skill, + paramValues: skill + ? { user_input: entry.prompt, ...(entry.params || {}) } + : entry.params || undefined, + llmProvider: provider, + llmModel: model, + }); + const assistantMessage = [...agentResult.messages] + .reverse() + .find((message) => message.role === "assistant"); + const assistantText = assistantMessage ? getMessageText(assistantMessage) : ""; + + const completedAt = new Date().toISOString(); + const result = { + id: entry.id, + status: "done", + workspaceId: workspace.id, + cwd, + mode, + skill, + provider, + model, + startedAt, + completedAt, + elapsedMs: Math.round(performance.now() - timer), + assistantText, + error: null, + }; + await appendEvent(eventsFile, `[DONE] ${entry.id} ${completedAt}`); + if (jsonl) { + await appendFile(jsonlFile, `${JSON.stringify(result)}\n`, "utf-8"); + } + console.log(`[DONE] ${entry.id} (${result.elapsedMs}ms)`); + return result; + } catch (error) { + const completedAt = new Date().toISOString(); + const result = { + id: entry.id, + status: "error", + workspaceId: workspace.id, + cwd, + mode, + skill, + provider, + model, + startedAt, + completedAt, + elapsedMs: Math.round(performance.now() - timer), + assistantText: "", + error: error instanceof Error ? error.message : String(error), + }; + await appendEvent(eventsFile, `[ERROR] ${entry.id} ${completedAt} ${result.error}`); + if (jsonl) { + await appendFile(jsonlFile, `${JSON.stringify(result)}\n`, "utf-8"); + } + console.log(`[ERROR] ${entry.id}: ${result.error}`); + return result; + } + } + + async function worker() { + while (true) { + if (stopDispatch) { + return; + } + const index = nextIndex; + nextIndex += 1; + if (index >= entries.length) { + return; + } + + const result = await runEntry(entries[index]); + results.push(result); + if (failFast && result.status !== "done") { + stopDispatch = true; + } + } + } + + const concurrency = Math.max(1, Math.min(workers, entries.length || 1)); + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + results.sort((left, right) => left.id.localeCompare(right.id)); + const summary = { + total: results.length, + done: results.filter((result) => result.status === "done").length, + failed: results.filter((result) => result.status !== "done").length, + inputPath: path.resolve(inputPath), + runDir, + }; + + await writeFile(resultsFile, JSON.stringify(results, null, 2), "utf-8"); + await writeFile(summaryFile, JSON.stringify(summary, null, 2), "utf-8"); + + return { + results, + summary, + runDir, + resultsFile, + summaryFile, + }; +} diff --git a/plugins/innoclaw-cli/src/http.mjs b/plugins/innoclaw-cli/src/http.mjs new file mode 100644 index 0000000..a330229 --- /dev/null +++ b/plugins/innoclaw-cli/src/http.mjs @@ -0,0 +1,138 @@ +import { getBaseUrlCandidates, isLocalBaseUrl } from "./runtime.mjs"; + +export class ApiError extends Error { + constructor(message, { status = 500, payload = null, response = null } = {}) { + super(message); + this.name = "ApiError"; + this.status = status; + this.payload = payload; + this.response = response; + } +} + +function createTimeoutSignal(timeoutMs) { + if (!timeoutMs || timeoutMs <= 0) { + return null; + } + return AbortSignal.timeout(timeoutMs); +} + +async function parseResponsePayload(response) { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + return response.json().catch(() => null); + } + return response.text().catch(() => ""); +} + +function buildErrorMessage(response, payload) { + if (payload && typeof payload === "object" && typeof payload.error === "string") { + return payload.error; + } + if (typeof payload === "string" && payload.trim()) { + return payload.trim(); + } + return `${response.status} ${response.statusText}`; +} + +/** + * @param {{ + * baseUrl: string, + * getCookieHeader?: () => string | null | undefined | Promise, + * onResponse?: (response: Response) => void | Promise, + * }} options + */ +export function createApiClient({ baseUrl, getCookieHeader, onResponse }) { + async function request(path, { + method = "GET", + body, + headers = {}, + timeoutMs = 30_000, + redirect = "follow", + } = {}) { + const finalHeaders = new Headers(headers); + const cookieHeader = await getCookieHeader?.(); + if (cookieHeader) { + finalHeaders.set("cookie", cookieHeader); + } + + let requestBody = body; + if ( + body !== undefined && + body !== null && + typeof body === "object" && + !(body instanceof Uint8Array) && + !(body instanceof ArrayBuffer) && + !(body instanceof FormData) && + !(body instanceof URLSearchParams) + ) { + if (!finalHeaders.has("content-type")) { + finalHeaders.set("content-type", "application/json"); + } + requestBody = JSON.stringify(body); + } + + const requestOptions = { + method, + headers: finalHeaders, + body: requestBody, + redirect, + signal: createTimeoutSignal(timeoutMs), + }; + + const candidates = isLocalBaseUrl(baseUrl) + ? getBaseUrlCandidates(baseUrl) + : [baseUrl]; + + let lastError = null; + let response = null; + + for (const candidate of candidates) { + try { + response = await fetch(`${candidate}${path}`, requestOptions); + break; + } catch (error) { + lastError = error; + } + } + + if (!response) { + throw lastError instanceof Error ? lastError : new Error(`Failed to reach ${baseUrl}${path}`); + } + + await onResponse?.(response); + return response; + } + + async function requestJson(path, options = {}) { + const response = await request(path, options); + const payload = await parseResponsePayload(response); + if (!response.ok) { + throw new ApiError(buildErrorMessage(response, payload), { + status: response.status, + payload, + response, + }); + } + return { response, payload }; + } + + async function requestText(path, options = {}) { + const response = await request(path, options); + const payload = await response.text().catch(() => ""); + if (!response.ok) { + throw new ApiError(buildErrorMessage(response, payload), { + status: response.status, + payload, + response, + }); + } + return { response, payload }; + } + + return { + request, + requestJson, + requestText, + }; +} diff --git a/plugins/innoclaw-cli/src/model-client.mjs b/plugins/innoclaw-cli/src/model-client.mjs new file mode 100644 index 0000000..79627f1 --- /dev/null +++ b/plugins/innoclaw-cli/src/model-client.mjs @@ -0,0 +1,59 @@ +export async function getModelSettings(apiClient) { + const { payload } = await apiClient.requestJson("/api/settings", { + timeoutMs: 10_000, + }); + return { + provider: payload.llmProvider, + model: payload.llmModel, + }; +} + +export function parseModelCommandArgs(args, currentProvider) { + if (!Array.isArray(args) || args.length === 0) { + return { action: "show" }; + } + + if (args[0] !== "set") { + throw new Error("Usage: /model | /model set | /model set "); + } + + if (args.length === 2) { + return { + action: "set", + provider: currentProvider, + model: args[1], + }; + } + + if (args.length >= 3) { + return { + action: "set", + provider: args[1], + model: args.slice(2).join(" "), + }; + } + + throw new Error("Usage: /model | /model set | /model set "); +} + +export async function setModelSettings(apiClient, { provider, model }) { + const trimmedProvider = typeof provider === "string" ? provider.trim() : ""; + const trimmedModel = typeof model === "string" ? model.trim() : ""; + if (!trimmedProvider || !trimmedModel) { + throw new Error("Both provider and model are required."); + } + + await apiClient.requestJson("/api/settings", { + method: "PATCH", + body: { + llm_provider: trimmedProvider, + llm_model: trimmedModel, + }, + timeoutMs: 10_000, + }); + + return { + provider: trimmedProvider, + model: trimmedModel, + }; +} diff --git a/plugins/innoclaw-cli/src/repl.mjs b/plugins/innoclaw-cli/src/repl.mjs new file mode 100644 index 0000000..843a97f --- /dev/null +++ b/plugins/innoclaw-cli/src/repl.mjs @@ -0,0 +1,233 @@ +import readline from "node:readline/promises"; +import process from "node:process"; +import { + makeUserMessage, + runAgentStream, + getMessageText, + getToolName, + summarizeToolInput, +} from "./agent-client.mjs"; +import { + getModelSettings, + parseModelCommandArgs, + setModelSettings, +} from "./model-client.mjs"; + +function printDivider() { + console.log("=".repeat(72)); +} + +function printHeader({ baseUrl, workspace, cwd, session, provider, model, skill, mode }) { + printDivider(); + console.log("InnoClaw CLI"); + console.log(`workspace: ${workspace.name} (${workspace.id})`); + console.log(`cwd: ${cwd}`); + console.log(`server: ${baseUrl}`); + console.log(`auth: ${session?.user?.email || "AUTH_MODE=disabled"}`); + console.log(`mode: ${mode}${skill ? ` | skill=${skill}` : ""}`); + if (provider || model) { + console.log(`model: ${provider || "default"} / ${model || "default"}`); + } + printDivider(); + console.log("Commands: /help /clear /workspace /model /logout /exit"); + console.log(""); +} + +function summarizeToolState(part) { + if (part.state === "output-error") { + return "error"; + } + if (part.state === "output-available") { + return "done"; + } + return "running"; +} + +export function createRenderer() { + let activeText = ""; + const toolStates = new Map(); + + function flushText() { + if (activeText.length > 0) { + process.stdout.write("\n"); + activeText = ""; + } + } + + return { + onSnapshot(message) { + if (message.role !== "assistant") { + return; + } + + const nextText = getMessageText(message); + const delta = nextText.slice(activeText.length); + if (delta) { + if (activeText.length === 0) { + process.stdout.write("assistant> "); + } + process.stdout.write(delta); + activeText = nextText; + } + + for (const part of message.parts || []) { + if (!(part.type?.startsWith("tool-") || part.type === "dynamic-tool")) { + continue; + } + const toolName = getToolName(part); + const stateKey = `${part.toolCallId}:${part.state}`; + if (toolStates.has(stateKey)) { + continue; + } + toolStates.set(stateKey, true); + flushText(); + console.log( + `[tool:${toolName}] ${summarizeToolState(part)} ${summarizeToolInput(toolName, part.input)}`.trim(), + ); + if (part.state === "output-error" && part.errorText) { + console.log(` error: ${part.errorText}`); + } + } + }, + finish() { + flushText(); + }, + }; +} + +export async function startRepl(apiClient, { + baseUrl, + workspace, + cwd, + session, + skill = null, + provider = null, + model = null, + mode = "agent", + onLogout, +} = {}) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + let messages = []; + let sessionState = session; + let activeProvider = provider; + let activeModel = model; + + try { + const current = await getModelSettings(apiClient); + activeProvider = activeProvider || current.provider; + activeModel = activeModel || current.model; + } catch { + // Keep the passed-in overrides if settings are unavailable. + } + + printHeader({ + baseUrl, + workspace, + cwd, + session: sessionState, + provider: activeProvider, + model: activeModel, + skill, + mode, + }); + + try { + while (true) { + const input = (await rl.question("> ")).trim(); + if (!input) { + continue; + } + + if (input === "/exit" || input === "/quit") { + break; + } + if (input === "/help") { + console.log("Commands:"); + console.log(" /help Show CLI commands"); + console.log(" /clear Clear local conversation history"); + console.log(" /workspace Show current workspace information"); + console.log(" /logout Clear CLI session and stop this REPL"); + console.log(" /exit Exit InnoClaw CLI"); + continue; + } + if (input === "/clear") { + messages = []; + console.log("[innoclaw] Conversation cleared."); + continue; + } + if (input === "/workspace") { + console.log(JSON.stringify(workspace, null, 2)); + continue; + } + if (input.startsWith("/model")) { + try { + const args = input + .slice("/model".length) + .trim() + .split(/\s+/) + .filter(Boolean); + const command = parseModelCommandArgs(args, activeProvider || "openai"); + + if (command.action === "show") { + const current = await getModelSettings(apiClient); + activeProvider = current.provider; + activeModel = current.model; + console.log(JSON.stringify(current, null, 2)); + continue; + } + + const next = await setModelSettings(apiClient, { + provider: command.provider, + model: command.model, + }); + activeProvider = next.provider; + activeModel = next.model; + console.log(`[innoclaw] Model set to ${next.provider} / ${next.model}`); + } catch (error) { + console.log(`[innoclaw] ${error instanceof Error ? error.message : String(error)}`); + } + continue; + } + if (input === "/logout") { + await onLogout?.(); + console.log("[innoclaw] Logged out."); + break; + } + + messages = [...messages, makeUserMessage(input)]; + const renderer = createRenderer(); + const result = await runAgentStream(apiClient, { + messages, + workspaceId: workspace.id, + cwd, + mode, + skillId: skill, + paramValues: skill ? { user_input: input } : undefined, + llmProvider: activeProvider, + llmModel: activeModel, + onHeaders(headers) { + activeProvider = headers.provider || activeProvider; + activeModel = headers.model || activeModel; + }, + onSnapshot(message) { + renderer.onSnapshot(message); + }, + }); + renderer.finish(); + messages = result.messages; + const lastAssistantMessage = [...messages] + .reverse() + .find((message) => message.role === "assistant"); + if (!lastAssistantMessage || !getMessageText(lastAssistantMessage).trim()) { + console.log("[innoclaw] No assistant output. Check the server logs or current model configuration."); + } + console.log(""); + } + } finally { + rl.close(); + } +} diff --git a/plugins/innoclaw-cli/src/runtime.mjs b/plugins/innoclaw-cli/src/runtime.mjs new file mode 100644 index 0000000..de22d80 --- /dev/null +++ b/plugins/innoclaw-cli/src/runtime.mjs @@ -0,0 +1,56 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function trimTrailingSlash(value) { + return value.replace(/\/+$/, ""); +} + +export function normalizeBaseUrl(value) { + const raw = typeof value === "string" && value.trim().length > 0 + ? value.trim() + : "http://localhost:3000"; + return trimTrailingSlash(raw); +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const APP_ROOT = path.resolve(__dirname, "../../.."); +export const DEFAULT_WORKSPACE_CWD = process.cwd(); +export const DEFAULT_BASE_URL = normalizeBaseUrl( + process.env.INNOCLAW_BASE_URL || "http://localhost:3000", +); + +export function getWorkspaceName(folderPath) { + const cleaned = path.resolve(folderPath); + const base = path.basename(cleaned); + return base && base !== path.sep ? base : cleaned; +} + +export function isLocalBaseUrl(baseUrl) { + try { + const parsed = new URL(baseUrl); + return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + } catch { + return false; + } +} + +export function getBaseUrlCandidates(baseUrl) { + try { + const parsed = new URL(baseUrl); + const candidates = [trimTrailingSlash(parsed.toString())]; + + if (parsed.hostname === "localhost") { + parsed.hostname = "127.0.0.1"; + candidates.push(trimTrailingSlash(parsed.toString())); + } else if (parsed.hostname === "127.0.0.1") { + parsed.hostname = "localhost"; + candidates.push(trimTrailingSlash(parsed.toString())); + } + + return [...new Set(candidates)]; + } catch { + return [normalizeBaseUrl(baseUrl)]; + } +} diff --git a/plugins/innoclaw-cli/src/server-client.mjs b/plugins/innoclaw-cli/src/server-client.mjs new file mode 100644 index 0000000..09d7272 --- /dev/null +++ b/plugins/innoclaw-cli/src/server-client.mjs @@ -0,0 +1,90 @@ +import { spawn } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; +import { getBaseUrlCandidates, isLocalBaseUrl } from "./runtime.mjs"; + +async function pingServer(baseUrl) { + for (const candidate of getBaseUrlCandidates(baseUrl)) { + try { + const response = await fetch(`${candidate}/api/auth/me`, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(3_000), + }); + const contentType = response.headers?.get?.("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) { + continue; + } + const body = await response.json(); + if ( + response.status === 200 + && body + && typeof body.authMode === "string" + && body.user + ) { + return true; + } + if (response.status === 401 && body?.error === "Unauthorized") { + return true; + } + } catch { + // Try the next local candidate when available. + } + } + return false; +} + +function startLocalServer(appRoot) { + return new Promise((resolve, reject) => { + const child = spawn("bash", ["dev-start.sh"], { + cwd: appRoot, + stdio: "inherit", + shell: false, + }); + child.on("exit", (code, signal) => { + resolve({ + code: code ?? 0, + signal: signal ?? null, + }); + }); + child.on("error", reject); + }); +} + +export async function ensureServerReady({ + appRoot, + baseUrl, + autoStart = true, + waitTimeoutMs = 90_000, +}) { + if (await pingServer(baseUrl)) { + return; + } + + if (!autoStart || !isLocalBaseUrl(baseUrl)) { + throw new Error(`InnoClaw is not reachable at ${baseUrl}`); + } + + const launchResult = await startLocalServer(appRoot); + + const deadline = Date.now() + waitTimeoutMs; + while (Date.now() < deadline) { + if (await pingServer(baseUrl)) { + return; + } + await delay(1_000); + } + + if (launchResult.code !== 0) { + throw new Error( + `Timed out waiting for InnoClaw to start at ${baseUrl} after dev-start.sh exited with code ${launchResult.code}`, + ); + } + + if (launchResult.signal) { + throw new Error( + `Timed out waiting for InnoClaw to start at ${baseUrl} after dev-start.sh ended with signal ${launchResult.signal}`, + ); + } + + throw new Error(`Timed out waiting for InnoClaw to start at ${baseUrl}`); +} diff --git a/plugins/innoclaw-cli/src/session-client.mjs b/plugins/innoclaw-cli/src/session-client.mjs new file mode 100644 index 0000000..28b8e1c --- /dev/null +++ b/plugins/innoclaw-cli/src/session-client.mjs @@ -0,0 +1,430 @@ +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { ApiError } from "./http.mjs"; + +const COOKIE_NAMES = [ + "innoclaw_session", + "innoclaw_session_expires", + "innoclaw_session_sig", +]; + +const SESSION_DIR = path.join(os.homedir(), ".innoclaw"); +const SESSION_FILE = path.join(SESSION_DIR, "cli-sessions.json"); + +function emptyStore() { + return { + version: 1, + sessions: {}, + }; +} + +function isKnownCookie(name) { + return COOKIE_NAMES.includes(name); +} + +function parseCookieNameValue(setCookie) { + const first = setCookie.split(";", 1)[0]; + const index = first.indexOf("="); + if (index === -1) return null; + return { + name: first.slice(0, index).trim(), + value: first.slice(index + 1).trim(), + }; +} + +async function loadStore() { + try { + const raw = await readFile(SESSION_FILE, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && parsed.sessions) { + return parsed; + } + return emptyStore(); + } catch { + return emptyStore(); + } +} + +async function saveStore(store) { + await mkdir(SESSION_DIR, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") { + await chmod(SESSION_DIR, 0o700); + } + + const tempFile = path.join(SESSION_DIR, `.cli-sessions.${process.pid}.${randomUUID()}.tmp`); + try { + await writeFile(tempFile, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 }); + if (process.platform !== "win32") { + await chmod(tempFile, 0o600); + } + await rename(tempFile, SESSION_FILE); + if (process.platform !== "win32") { + await chmod(SESSION_FILE, 0o600); + } + } catch (error) { + await unlink(tempFile).catch(() => {}); + throw error; + } +} + +function getResponseSetCookies(response) { + const getter = response.headers?.getSetCookie; + if (typeof getter === "function") { + return getter.call(response.headers); + } + const fallback = response.headers.get("set-cookie"); + return fallback ? [fallback] : []; +} + +function buildBrowserCallbackUrl(port) { + return `http://127.0.0.1:${port}/callback`; +} + +function validateCallbackPayload(payload, expectedNonce) { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid CLI login handoff payload"); + } + if (payload.nonce !== expectedNonce) { + throw new Error("CLI login handoff nonce mismatch"); + } + if (!payload.cookies || typeof payload.cookies !== "object") { + throw new Error("CLI login handoff cookies missing"); + } + + for (const name of COOKIE_NAMES) { + const value = payload.cookies[name]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`CLI login handoff missing cookie: ${name}`); + } + } + + return { + cookies: payload.cookies, + user: payload.user ?? null, + expiresAt: payload.expiresAt ?? payload.cookies.innoclaw_session_expires, + updatedAt: new Date().toISOString(), + }; +} + +export function openBrowser(url, { settleMs = 250 } = {}) { + let command; + let args; + + if (process.platform === "darwin") { + command = "open"; + args = [url]; + } else if (process.platform === "win32") { + command = "cmd"; + args = ["/c", "start", "", url]; + } else { + command = "xdg-open"; + args = [url]; + } + + return new Promise((resolve) => { + let child; + try { + child = spawn(command, args, { + detached: true, + stdio: "ignore", + shell: false, + }); + } catch { + resolve(false); + return; + } + + let settled = false; + let timer = null; + + function finish(opened) { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + child.off("exit", onExit); + if (opened) { + child.off("error", onError); + child.on("error", () => {}); + child.unref(); + } else { + child.off("error", onError); + } + resolve(opened); + } + + function onError() { + finish(false); + } + + function onExit(code) { + if (code !== 0) { + finish(false); + } + } + + child.once("error", onError); + child.once("exit", onExit); + timer = setTimeout(() => finish(true), settleMs); + }); +} + +async function waitForBrowserSession({ baseUrl, timeoutMs = 5 * 60_000 }) { + const nonce = randomUUID(); + const allowedOrigins = new Set([ + baseUrl, + baseUrl.replace("localhost", "127.0.0.1"), + baseUrl.replace("127.0.0.1", "localhost"), + ]); + + let resolved = false; + let resolvePayload; + let rejectPayload; + const payloadPromise = new Promise((resolve, reject) => { + resolvePayload = resolve; + rejectPayload = reject; + }); + + const server = createServer((req, res) => { + const origin = req.headers.origin; + if (origin && allowedOrigins.has(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + } + res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.method !== "POST" || req.url !== "/callback") { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + return; + } + + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + try { + const body = Buffer.concat(chunks).toString("utf-8"); + const payload = JSON.parse(body); + const session = validateCallbackPayload(payload, nonce); + resolved = true; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + ok: true, + message: "CLI login complete. Return to the terminal.", + })); + resolvePayload(session); + } catch (error) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + error: error instanceof Error ? error.message : "Invalid callback payload", + })); + } + }); + }); + + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => resolve()); + server.on("error", reject); + }); + + const address = server.address(); + const port = typeof address === "object" && address ? address.port : null; + if (!port) { + server.close(); + throw new Error("Failed to allocate a CLI login callback port"); + } + + const loginUrl = new URL(`${baseUrl}/login`); + loginUrl.searchParams.set("next", "/"); + loginUrl.searchParams.set("cliCallback", buildBrowserCallbackUrl(port)); + loginUrl.searchParams.set("cliNonce", nonce); + + const opened = await openBrowser(loginUrl.toString()); + if (!opened) { + console.log(`[innoclaw] Open this URL in your browser to sign in:\n${loginUrl.toString()}`); + } + + console.log("[innoclaw] Waiting for browser login..."); + + const timeout = setTimeout(() => { + if (!resolved) { + rejectPayload(new Error("Timed out waiting for browser login")); + } + }, timeoutMs); + + try { + return await payloadPromise; + } finally { + clearTimeout(timeout); + server.close(); + } +} + +export function createSessionManager(baseUrl, apiClientFactory) { + let session = null; + + async function load() { + const store = await loadStore(); + session = store.sessions[baseUrl] ?? null; + return session; + } + + async function persist(nextSession) { + const store = await loadStore(); + if (nextSession) { + store.sessions[baseUrl] = nextSession; + } else { + delete store.sessions[baseUrl]; + } + await saveStore(store); + session = nextSession; + return session; + } + + function getCookieHeader() { + if (!session?.cookies) { + return ""; + } + return COOKIE_NAMES + .map((name) => { + const value = session.cookies[name]; + if (typeof value !== "string" || value.length === 0) { + return null; + } + return `${name}=${value}`; + }) + .filter((value) => typeof value === "string") + .join("; "); + } + + async function updateFromResponse(response) { + const setCookies = getResponseSetCookies(response); + if (!setCookies.length) { + return session; + } + + const nextCookies = { ...(session?.cookies ?? {}) }; + let changed = false; + + for (const raw of setCookies) { + const parsed = parseCookieNameValue(raw); + if (!parsed || !isKnownCookie(parsed.name)) { + continue; + } + nextCookies[parsed.name] = parsed.value; + changed = true; + } + + if (!changed) { + return session; + } + + const nextSession = { + ...(session ?? {}), + cookies: nextCookies, + expiresAt: nextCookies.innoclaw_session_expires ?? session?.expiresAt ?? null, + updatedAt: new Date().toISOString(), + }; + return persist(nextSession); + } + + const apiClient = apiClientFactory({ + getCookieHeader, + onResponse: updateFromResponse, + }); + + async function ensureAuthenticated({ interactive = true } = {}) { + try { + await apiClient.requestJson("/api/workspaces", { timeoutMs: 5_000 }); + return session; + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 401) { + throw error; + } + } + + if (session) { + await persist(null); + } + + if (!interactive) { + throw new Error("Authentication required. Run `innoclaw auth login` or `innoclaw` to complete browser sign-in."); + } + + const browserSession = await waitForBrowserSession({ baseUrl }); + await persist(browserSession); + try { + await apiClient.requestJson("/api/workspaces", { timeoutMs: 5_000 }); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + await persist(null); + throw new Error("Browser sign-in completed, but the CLI session was rejected. Please try again."); + } + throw error; + } + return session; + } + + async function revoke() { + try { + if (session) { + await apiClient.requestJson("/api/auth/logout", { + method: "POST", + body: {}, + timeoutMs: 10_000, + }); + } + } catch { + // Clear local state even if the server session is already invalid. + } finally { + await persist(null); + } + } + + async function getAuthStatus() { + try { + const { payload } = await apiClient.requestJson("/api/auth/me", { timeoutMs: 5_000 }); + return { + authenticated: true, + user: payload.user, + session: payload.session, + }; + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + return { + authenticated: false, + user: session?.user ?? null, + session: session ? { expiresAt: session.expiresAt } : null, + }; + } + throw error; + } + } + + return { + apiClient, + load, + getSession: () => session, + getCookieHeader, + ensureAuthenticated, + getAuthStatus, + updateFromResponse, + revoke, + clear: () => persist(null), + save: persist, + }; +} diff --git a/plugins/innoclaw-cli/src/workspace-client.mjs b/plugins/innoclaw-cli/src/workspace-client.mjs new file mode 100644 index 0000000..bcc0382 --- /dev/null +++ b/plugins/innoclaw-cli/src/workspace-client.mjs @@ -0,0 +1,23 @@ +import path from "node:path"; +import { getWorkspaceName } from "./runtime.mjs"; + +export async function ensureWorkspace(apiClient, folderPath, explicitName) { + const resolved = path.resolve(folderPath); + const { payload: workspaces } = await apiClient.requestJson("/api/workspaces"); + const existing = Array.isArray(workspaces) + ? workspaces.find((workspace) => workspace.folderPath === resolved) + : null; + + if (existing) { + return existing; + } + + const { payload } = await apiClient.requestJson("/api/workspaces", { + method: "POST", + body: { + name: explicitName || getWorkspaceName(resolved), + folderPath: resolved, + }, + }); + return payload; +} diff --git a/src/app/api/auth/cli-session/route.ts b/src/app/api/auth/cli-session/route.ts new file mode 100644 index 0000000..38026bf --- /dev/null +++ b/src/app/api/auth/cli-session/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { jsonError } from "@/lib/api-errors"; +import { + createAuthSession, + getAuthContext, + refreshAuthSessionIfNeeded, + signSessionToken, + unauthorizedResponse, +} from "@/lib/auth/server"; +import { createCliSessionHandoffPayload } from "@/lib/auth/cli-handoff"; +import { isAuthDisabled } from "@/lib/auth/mode"; + +export async function POST(request: NextRequest) { + try { + if (isAuthDisabled()) { + return jsonError("Authentication is disabled", 403); + } + + const auth = await getAuthContext(request); + if (!auth) { + return unauthorizedResponse(); + } + + const body = await request.json().catch(() => ({})); + const nonce = typeof body.nonce === "string" ? body.nonce.trim() : ""; + if (!nonce) { + return jsonError("Missing nonce", 400); + } + + const cliSession = await createAuthSession(auth.user.id); + const payload = createCliSessionHandoffPayload({ + nonce, + user: auth.user, + token: cliSession.token, + expiresAt: cliSession.expiresAt, + signature: signSessionToken(cliSession.token), + }); + + const response = NextResponse.json(payload, { status: 201 }); + return refreshAuthSessionIfNeeded(response, auth); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to create CLI session", 500); + } +} diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx new file mode 100644 index 0000000..b22a79e --- /dev/null +++ b/src/app/login/page.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToString } from "react-dom/server"; +import LoginPage from "./page"; + +const replaceMock = vi.hoisted(() => vi.fn()); +const refreshMock = vi.hoisted(() => vi.fn()); +const completeCliBrowserHandoffMock = vi.hoisted(() => vi.fn().mockResolvedValue(true)); +const capturedButtonClicks = vi.hoisted(() => new Map unknown>()); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => effect(), + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: replaceMock, + refresh: refreshMock, + }), + useSearchParams: () => + new URLSearchParams({ + cliCallback: "http://127.0.0.1:43123/callback", + cliNonce: "nonce-123", + next: "/workspace", + }), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => {children}, +})); + +vi.mock("lucide-react", () => ({ + Bot: () => , + LogIn: () => , +})); + +vi.mock("@/lib/hooks/use-auth", () => ({ + useAuthUser: () => ({ + user: { + id: "user-1", + email: "user@example.com", + name: "User", + role: "user", + isActive: true, + }, + isLoading: false, + isAuthDisabled: false, + }), +})); + +vi.mock("@/lib/auth/cli-handoff", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + completeCliBrowserHandoff: completeCliBrowserHandoffMock, + }; +}); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + ...props + }: { + children: React.ReactNode; + onClick?: () => unknown; + type?: "button" | "submit"; + }) => { + if (props.type === "button" && onClick) { + capturedButtonClicks.set(String(children), onClick); + } + return ; + }, +})); + +vi.mock("@/components/ui/card", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + CardHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); + +vi.mock("@/components/ui/label", () => ({ + Label: ({ children, ...props }: React.LabelHTMLAttributes) => , +})); + +afterEach(() => { + replaceMock.mockClear(); + refreshMock.mockClear(); + completeCliBrowserHandoffMock.mockClear(); + capturedButtonClicks.clear(); +}); + +describe("LoginPage", () => { + it("waits for explicit action before completing an authenticated CLI handoff", async () => { + const html = renderToString(); + + expect(html).toContain("Complete CLI sign-in"); + expect(completeCliBrowserHandoffMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + + const click = [...capturedButtonClicks.values()][0]; + expect(click).toBeDefined(); + await click(); + + expect(completeCliBrowserHandoffMock).toHaveBeenCalledTimes(1); + expect(replaceMock).toHaveBeenCalledWith("/workspace"); + expect(refreshMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index f009b6e..e87e61e 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,21 +2,31 @@ import { FormEvent, useEffect, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { Bot, LogIn } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + buildAuthPageHref, + completeCliBrowserHandoff, + parseCliHandoffParams, + resolveSafeRedirectPath, +} from "@/lib/auth/cli-handoff"; import { useAuthUser } from "@/lib/hooks/use-auth"; export default function LoginPage() { const router = useRouter(); + const searchParams = useSearchParams(); const { user, isLoading, isAuthDisabled } = useAuthUser(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [cliHandoffLoading, setCliHandoffLoading] = useState(false); + const registerHref = buildAuthPageHref("/register", searchParams); + const cliHandoff = parseCliHandoffParams(searchParams); useEffect(() => { if (isAuthDisabled) { @@ -29,18 +39,18 @@ export default function LoginPage() { return; } - const next = new URLSearchParams(window.location.search).get("next"); + if (cliHandoff) { + return; + } + const fallback = user.role === "admin" ? "/admin/users" : "/"; - router.replace(next && next !== "/" ? next : fallback); + router.replace(resolveSafeRedirectPath(searchParams.get("next"), fallback)); router.refresh(); - }, [isAuthDisabled, isLoading, router, user]); + }, [cliHandoff, isAuthDisabled, isLoading, router, searchParams, user]); function resolvePostLoginPath(role: "admin" | "user"): string { - const next = new URLSearchParams(window.location.search).get("next"); - if (next && next !== "/") { - return next; - } - return role === "admin" ? "/admin/users" : "/"; + const fallback = role === "admin" ? "/admin/users" : "/"; + return resolveSafeRedirectPath(searchParams.get("next"), fallback); } async function handleSubmit(event: FormEvent) { @@ -48,22 +58,42 @@ export default function LoginPage() { setLoading(true); setError(""); - const res = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); - const data = await res.json().catch(() => ({})); - setLoading(false); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error || "Login failed"); + return; + } - if (!res.ok) { - setError(data.error || "Login failed"); - return; + await completeCliBrowserHandoff(searchParams); + router.replace(resolvePostLoginPath(data.user?.role === "admin" ? "admin" : "user")); + router.refresh(); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "Login failed"); + } finally { + setLoading(false); } + } - router.replace(resolvePostLoginPath(data.user?.role === "admin" ? "admin" : "user")); - router.refresh(); + async function handleCliHandoffClick() { + setCliHandoffLoading(true); + setError(""); + + try { + await completeCliBrowserHandoff(searchParams); + router.replace(resolvePostLoginPath(user?.role === "admin" ? "admin" : "user")); + router.refresh(); + } catch (handoffError) { + setError(handoffError instanceof Error ? handoffError.message : "CLI sign-in failed"); + } finally { + setCliHandoffLoading(false); + } } if (isAuthDisabled) { @@ -74,7 +104,7 @@ export default function LoginPage() { ); } - if (user) { + if (user && !cliHandoff) { return (

Redirecting...

@@ -82,6 +112,31 @@ export default function LoginPage() { ); } + if (user && cliHandoff) { + return ( +
+ + +
+ +
+
+ Complete CLI sign-in + Authorize the CLI to use your current browser session. +
+
+ + {error &&

{error}

} + +
+
+
+ ); + } + return (
@@ -126,7 +181,7 @@ export default function LoginPage() {

No account yet?{" "} - + Create one

diff --git a/src/app/register/page.test.tsx b/src/app/register/page.test.tsx new file mode 100644 index 0000000..1273f2b --- /dev/null +++ b/src/app/register/page.test.tsx @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToString } from "react-dom/server"; +import RegisterPage from "./page"; + +const replaceMock = vi.hoisted(() => vi.fn()); +const refreshMock = vi.hoisted(() => vi.fn()); +const completeCliBrowserHandoffMock = vi.hoisted(() => vi.fn().mockResolvedValue(true)); +const capturedButtonClicks = vi.hoisted(() => new Map unknown>()); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => effect(), + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: replaceMock, + refresh: refreshMock, + }), + useSearchParams: () => + new URLSearchParams({ + cliCallback: "http://127.0.0.1:43123/callback", + cliNonce: "nonce-123", + next: "/workspace", + }), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => {children}, +})); + +vi.mock("lucide-react", () => ({ + UserPlus: () => , +})); + +vi.mock("@/lib/hooks/use-auth", () => ({ + useAuthUser: () => ({ + user: { + id: "user-1", + email: "user@example.com", + name: "User", + role: "user", + isActive: true, + }, + isAuthDisabled: false, + }), +})); + +vi.mock("@/lib/auth/cli-handoff", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + completeCliBrowserHandoff: completeCliBrowserHandoffMock, + }; +}); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + ...props + }: { + children: React.ReactNode; + onClick?: () => unknown; + type?: "button" | "submit"; + }) => { + if (props.type === "button" && onClick) { + capturedButtonClicks.set(String(children), onClick); + } + return ; + }, +})); + +vi.mock("@/components/ui/card", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + CardHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); + +vi.mock("@/components/ui/label", () => ({ + Label: ({ children, ...props }: React.LabelHTMLAttributes) => , +})); + +afterEach(() => { + replaceMock.mockClear(); + refreshMock.mockClear(); + completeCliBrowserHandoffMock.mockClear(); + capturedButtonClicks.clear(); +}); + +describe("RegisterPage", () => { + it("waits for explicit action before completing an authenticated CLI handoff", async () => { + const html = renderToString(); + + expect(html).toContain("Complete CLI sign-in"); + expect(completeCliBrowserHandoffMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + + const click = [...capturedButtonClicks.values()][0]; + expect(click).toBeDefined(); + await click(); + + expect(completeCliBrowserHandoffMock).toHaveBeenCalledTimes(1); + expect(replaceMock).toHaveBeenCalledWith("/workspace"); + expect(refreshMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 560bc71..6633e99 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -2,22 +2,32 @@ import { FormEvent, useEffect, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { UserPlus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + buildAuthPageHref, + completeCliBrowserHandoff, + parseCliHandoffParams, + resolveSafeRedirectPath, +} from "@/lib/auth/cli-handoff"; import { useAuthUser } from "@/lib/hooks/use-auth"; export default function RegisterPage() { const router = useRouter(); - const { isAuthDisabled } = useAuthUser(); + const searchParams = useSearchParams(); + const { user, isAuthDisabled } = useAuthUser(); const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [cliHandoffLoading, setCliHandoffLoading] = useState(false); + const loginHref = buildAuthPageHref("/login", searchParams); + const cliHandoff = parseCliHandoffParams(searchParams); useEffect(() => { if (isAuthDisabled) { @@ -31,22 +41,47 @@ export default function RegisterPage() { setLoading(true); setError(""); - const res = await fetch("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, name, password }), - }); + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, name, password }), + }); - const data = await res.json().catch(() => ({})); - setLoading(false); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error || "Registration failed"); + return; + } - if (!res.ok) { - setError(data.error || "Registration failed"); - return; + await completeCliBrowserHandoff(searchParams); + + router.replace(resolvePostRegisterPath(data.requiresSetup)); + router.refresh(); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "Registration failed"); + } finally { + setLoading(false); } + } + + function resolvePostRegisterPath(requiresSetup = false): string { + return resolveSafeRedirectPath(searchParams.get("next"), requiresSetup ? "/settings" : "/"); + } - router.replace(data.requiresSetup ? "/settings" : "/"); - router.refresh(); + async function handleCliHandoffClick() { + setCliHandoffLoading(true); + setError(""); + + try { + await completeCliBrowserHandoff(searchParams); + router.replace(resolvePostRegisterPath()); + router.refresh(); + } catch (handoffError) { + setError(handoffError instanceof Error ? handoffError.message : "CLI sign-in failed"); + } finally { + setCliHandoffLoading(false); + } } if (isAuthDisabled) { @@ -57,6 +92,31 @@ export default function RegisterPage() { ); } + if (user && cliHandoff) { + return ( +
+ + +
+ +
+
+ Complete CLI sign-in + Authorize the CLI to use your current browser session. +
+
+ + {error &&

{error}

} + +
+
+
+ ); + } + return (
@@ -111,7 +171,7 @@ export default function RegisterPage() {

Already have an account?{" "} - + Sign in

diff --git a/src/lib/auth/cli-handoff.test.ts b/src/lib/auth/cli-handoff.test.ts new file mode 100644 index 0000000..355a13d --- /dev/null +++ b/src/lib/auth/cli-handoff.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { + AUTH_SESSION_COOKIE, + AUTH_SESSION_EXPIRES_COOKIE, + AUTH_SESSION_SIGNATURE_COOKIE, +} from "./constants"; +import { + buildAuthPageHref, + completeCliBrowserHandoff, + createCliSessionHandoffPayload, + isLoopbackHostname, + parseCliHandoffParams, + resolveSafeRedirectPath, +} from "./cli-handoff"; + +const originalAuthMode = process.env.AUTH_MODE; + +afterEach(() => { + if (originalAuthMode === undefined) { + delete process.env.AUTH_MODE; + } else { + process.env.AUTH_MODE = originalAuthMode; + } +}); + +describe("cli-handoff", () => { + it("recognizes supported loopback hostnames", () => { + expect(isLoopbackHostname("localhost")).toBe(true); + expect(isLoopbackHostname("127.0.0.1")).toBe(true); + expect(isLoopbackHostname("::1")).toBe(true); + expect(isLoopbackHostname("[::1]")).toBe(true); + expect(isLoopbackHostname("example.com")).toBe(false); + }); + + it("parses cli handoff params for a loopback callback", () => { + const params = new URLSearchParams({ + cliCallback: "http://127.0.0.1:43123/callback", + cliNonce: "nonce-123", + next: "/workspace", + }); + + expect(parseCliHandoffParams(params)).toEqual({ + callbackUrl: "http://127.0.0.1:43123/callback", + nonce: "nonce-123", + }); + }); + + it("rejects cli handoff params for non-loopback callbacks", () => { + const params = new URLSearchParams({ + cliCallback: "http://example.com:43123/callback", + cliNonce: "nonce-123", + }); + + expect(parseCliHandoffParams(params)).toBeNull(); + }); + + it("preserves auth handoff query params when switching auth pages", () => { + const params = new URLSearchParams({ + next: "/", + cliCallback: "http://localhost:43123/callback", + cliNonce: "nonce-456", + }); + + expect(buildAuthPageHref("/register", params)).toBe( + "/register?next=%2F&cliCallback=http%3A%2F%2Flocalhost%3A43123%2Fcallback&cliNonce=nonce-456", + ); + }); + + it.each([ + ["javascript:alert(1)"], + ["https://evil.example"], + ["//evil.example"], + ["/workspace\\evil"], + ["/workspace/%5Cevil"], + ["/workspace/\u0000evil"], + ["/workspace/%00evil"], + ])("rejects unsafe redirect path %s", (next) => { + expect(resolveSafeRedirectPath(next, "/fallback")).toBe("/fallback"); + }); + + it.each(["/settings", "/workspace/id"])("allows app-local redirect path %s", (next) => { + expect(resolveSafeRedirectPath(next, "/fallback")).toBe(next); + }); + + it("creates a CLI session payload with the auth cookie triple", () => { + const payload = createCliSessionHandoffPayload({ + nonce: "nonce-789", + user: { + id: "user-1", + email: "user@example.com", + name: "User", + role: "user", + isActive: true, + lastLoginAt: null, + createdAt: "2026-05-20T00:00:00.000Z", + updatedAt: "2026-05-20T00:00:00.000Z", + }, + token: "token-123", + expiresAt: "2026-06-20T00:00:00.000Z", + signature: "sig-123", + }); + + expect(payload).toEqual({ + nonce: "nonce-789", + user: expect.objectContaining({ id: "user-1", email: "user@example.com" }), + expiresAt: "2026-06-20T00:00:00.000Z", + cookies: { + [AUTH_SESSION_COOKIE]: "token-123", + [AUTH_SESSION_EXPIRES_COOKIE]: "2026-06-20T00:00:00.000Z", + [AUTH_SESSION_SIGNATURE_COOKIE]: "sig-123", + }, + }); + }); + + it("returns false without cli handoff params", async () => { + const fetchMock = vi.fn(); + + await expect(completeCliBrowserHandoff(new URLSearchParams(), fetchMock)).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("completes the CLI browser handoff", async () => { + const params = new URLSearchParams({ + cliCallback: "http://127.0.0.1:43123/callback", + cliNonce: "nonce-123", + }); + const sessionPayload = { + nonce: "nonce-123", + cookies: { + [AUTH_SESSION_COOKIE]: "token-123", + [AUTH_SESSION_EXPIRES_COOKIE]: "2026-06-20T00:00:00.000Z", + [AUTH_SESSION_SIGNATURE_COOKIE]: "sig-123", + }, + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(sessionPayload, { status: 201 })) + .mockResolvedValueOnce(Response.json({ ok: true })); + + await expect(completeCliBrowserHandoff(params, fetchMock)).resolves.toBe(true); + + expect(fetchMock).toHaveBeenNthCalledWith(1, "/api/auth/cli-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nonce: "nonce-123" }), + }); + expect(fetchMock).toHaveBeenNthCalledWith(2, "http://127.0.0.1:43123/callback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sessionPayload), + }); + }); + + it("throws the cli-session JSON error when session creation fails", async () => { + const params = new URLSearchParams({ + cliCallback: "http://127.0.0.1:43123/callback", + cliNonce: "nonce-123", + }); + const fetchMock = vi.fn().mockResolvedValueOnce(Response.json({ error: "Authentication is disabled" }, { status: 403 })); + + await expect(completeCliBrowserHandoff(params, fetchMock)).rejects.toThrow("Authentication is disabled"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws the callback JSON error when callback delivery fails", async () => { + const params = new URLSearchParams({ + cliCallback: "http://127.0.0.1:43123/callback", + cliNonce: "nonce-123", + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ nonce: "nonce-123", cookies: {} }, { status: 201 })) + .mockResolvedValueOnce(Response.json({ error: "nonce mismatch" }, { status: 400 })); + + await expect(completeCliBrowserHandoff(params, fetchMock)).rejects.toThrow("nonce mismatch"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("rejects CLI session creation when auth is disabled", async () => { + process.env.AUTH_MODE = "disabled"; + const { POST } = await import("../../app/api/auth/cli-session/route"); + const request = new NextRequest("http://localhost/api/auth/cli-session", { + method: "POST", + body: JSON.stringify({ nonce: "nonce-123" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.error).toBe("Authentication is disabled"); + }); +}); diff --git a/src/lib/auth/cli-handoff.ts b/src/lib/auth/cli-handoff.ts new file mode 100644 index 0000000..5bbf9f1 --- /dev/null +++ b/src/lib/auth/cli-handoff.ts @@ -0,0 +1,156 @@ +import type { PublicUser } from "@/types/auth"; +import { + AUTH_SESSION_COOKIE, + AUTH_SESSION_EXPIRES_COOKIE, + AUTH_SESSION_SIGNATURE_COOKIE, +} from "./constants"; + +type SearchParamSource = Pick; + +export interface CliHandoffParams { + callbackUrl: string; + nonce: string; +} + +export interface CliSessionCookies { + [AUTH_SESSION_COOKIE]: string; + [AUTH_SESSION_EXPIRES_COOKIE]: string; + [AUTH_SESSION_SIGNATURE_COOKIE]: string; +} + +export interface CliSessionHandoffPayload { + nonce: string; + user: PublicUser | null; + expiresAt: string; + cookies: CliSessionCookies; +} + +const CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/; + +function getResponseError(payload: unknown, fallback: string): string { + if (payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string") { + return payload.error; + } + return fallback; +} + +function normalizeHostname(hostname: string): string { + return hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase(); +} + +export function isLoopbackHostname(hostname: string): boolean { + const normalized = normalizeHostname(hostname); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +} + +export function parseCliHandoffParams(searchParams: SearchParamSource): CliHandoffParams | null { + const callbackUrl = searchParams.get("cliCallback"); + const nonce = searchParams.get("cliNonce"); + + if (!callbackUrl || !nonce?.trim()) { + return null; + } + + try { + const parsed = new URL(callbackUrl); + if (parsed.protocol !== "http:" || !parsed.port || !isLoopbackHostname(parsed.hostname)) { + return null; + } + + return { + callbackUrl: parsed.toString(), + nonce: nonce.trim(), + }; + } catch { + return null; + } +} + +export function resolveSafeRedirectPath(value: string | null | undefined, fallback = "/"): string { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return fallback; + } + if (value.includes("\\") || CONTROL_CHAR_PATTERN.test(value)) { + return fallback; + } + + try { + const decoded = decodeURIComponent(value); + if (decoded.startsWith("//") || decoded.includes("\\") || CONTROL_CHAR_PATTERN.test(decoded)) { + return fallback; + } + } catch { + return fallback; + } + + return value; +} + +export function buildAuthPageHref(basePath: string, searchParams: SearchParamSource): string { + const url = new URL(basePath, "http://innoclaw.local"); + + const next = resolveSafeRedirectPath(searchParams.get("next"), ""); + if (next) { + url.searchParams.set("next", next); + } + + for (const key of ["cliCallback", "cliNonce"]) { + const value = searchParams.get(key); + if (value?.trim()) { + url.searchParams.set(key, value); + } + } + + return `${url.pathname}${url.search}`; +} + +export function createCliSessionHandoffPayload(input: { + nonce: string; + user: PublicUser | null; + token: string; + expiresAt: string; + signature: string; +}): CliSessionHandoffPayload { + return { + nonce: input.nonce, + user: input.user, + expiresAt: input.expiresAt, + cookies: { + [AUTH_SESSION_COOKIE]: input.token, + [AUTH_SESSION_EXPIRES_COOKIE]: input.expiresAt, + [AUTH_SESSION_SIGNATURE_COOKIE]: input.signature, + }, + }; +} + +export async function completeCliBrowserHandoff( + searchParams: SearchParamSource, + fetchImpl: typeof fetch = fetch, +): Promise { + const handoff = parseCliHandoffParams(searchParams); + if (!handoff) { + return false; + } + + const sessionResponse = await fetchImpl("/api/auth/cli-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nonce: handoff.nonce }), + }); + const sessionPayload = await sessionResponse.json().catch(() => null); + if (!sessionResponse.ok) { + throw new Error(getResponseError(sessionPayload, "Failed to create CLI session")); + } + + const callbackResponse = await fetchImpl(handoff.callbackUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sessionPayload), + }); + const callbackPayload = await callbackResponse.json().catch(() => null); + if (!callbackResponse.ok) { + throw new Error(getResponseError(callbackPayload, "Failed to deliver CLI session")); + } + + return true; +} diff --git a/src/lib/files/filesystem.test.ts b/src/lib/files/filesystem.test.ts new file mode 100644 index 0000000..c503671 --- /dev/null +++ b/src/lib/files/filesystem.test.ts @@ -0,0 +1,73 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + updateEnvLocal: vi.fn(), +})); + +vi.mock("@/lib/env-file", () => ({ + updateEnvLocal: mocks.updateEnvLocal, +})); + +import { addWorkspaceRoot, getWorkspaceRoots, validatePath } from "./filesystem"; + +const originalEnv = { ...process.env }; + +function normalizedResolved(targetPath: string): string { + return path.resolve(targetPath).replace(/\\/g, "/"); +} + +function normalizedWorkspaceRoots(): string[] { + return getWorkspaceRoots().map((root) => root.replace(/\\/g, "/")); +} + +beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.WORKSPACE_ROOTS; + delete process.env.WORKSPACE_ROOTS_MAX_ENTRIES; + mocks.updateEnvLocal.mockReset(); +}); + +describe("workspace root allowlist", () => { + it("keeps earlier registered workspace roots available across more than three candidates", () => { + const roots = [ + "/tmp/skillopt_aircraft_candidate_workspaces/formal_train/candidate_01", + "/tmp/skillopt_aircraft_candidate_workspaces/formal_train/candidate_02", + "/tmp/skillopt_aircraft_candidate_workspaces/formal_train/candidate_03", + "/tmp/skillopt_aircraft_candidate_workspaces/formal_train/candidate_04", + ]; + + for (const root of roots) { + addWorkspaceRoot(root); + } + + expect(getWorkspaceRoots()).toEqual(roots.map((root) => path.resolve(root))); + expect(() => validatePath(path.join(roots[0], "scripts", "run_headless.py"))).not.toThrow(); + }); + + it("keeps more than three workspace roots when under the configured cap", () => { + process.env.WORKSPACE_ROOTS = ["/tmp/a", "/tmp/b", "/tmp/c"].join(","); + process.env.WORKSPACE_ROOTS_MAX_ENTRIES = "5"; + + addWorkspaceRoot("/tmp/d"); + + expect(normalizedWorkspaceRoots()).toContain(normalizedResolved("/tmp/a")); + expect(normalizedWorkspaceRoots()).toContain(normalizedResolved("/tmp/d")); + }); + + it("throws instead of silently dropping workspace roots when the cap is exceeded", () => { + process.env.WORKSPACE_ROOTS = ["/tmp/a", "/tmp/b"].join(","); + process.env.WORKSPACE_ROOTS_MAX_ENTRIES = "2"; + + expect(() => addWorkspaceRoot("/tmp/c")).toThrow("WORKSPACE_ROOTS limit exceeded"); + }); + + it("compacts child roots when a parent root is added", () => { + process.env.WORKSPACE_ROOTS = ["/tmp/project/a", "/tmp/project/b"].join(","); + process.env.WORKSPACE_ROOTS_MAX_ENTRIES = "5"; + + addWorkspaceRoot("/tmp/project"); + + expect(normalizedWorkspaceRoots()).toEqual([normalizedResolved("/tmp/project")]); + }); +}); diff --git a/src/lib/files/filesystem.ts b/src/lib/files/filesystem.ts index 6f2211e..4cee282 100644 --- a/src/lib/files/filesystem.ts +++ b/src/lib/files/filesystem.ts @@ -13,6 +13,11 @@ function isPathUnderRoot(resolved: string, root: string): boolean { return n === r || n.startsWith(r + "/"); } +function getWorkspaceRootsMaxEntries(): number { + const raw = Number.parseInt(process.env.WORKSPACE_ROOTS_MAX_ENTRIES || "64", 10); + return Number.isFinite(raw) && raw > 0 ? raw : 64; +} + /** * Parse workspace roots from environment variable */ @@ -68,8 +73,20 @@ export function addWorkspaceRoot(targetPath: string): void { if (alreadyCovered) return; - // Keep at most 3 roots (FIFO — drop the oldest when over limit) - const newRoots = [...roots, resolved].slice(-3).join(","); + // Keep all explicitly registered roots unless a newly added parent root + // covers them. Dropping older roots makes a DB + // workspace record diverge from the in-memory path sandbox and can break + // long-running headless workflows that revisit earlier workspaces. + const compactedRoots = roots.filter((root) => !isPathUnderRoot(root, resolved)); + const nextRoots = [...compactedRoots, resolved]; + const maxEntries = getWorkspaceRootsMaxEntries(); + if (nextRoots.length > maxEntries) { + throw new Error( + `WORKSPACE_ROOTS limit exceeded (${nextRoots.length}/${maxEntries}). Configure a broader root or increase WORKSPACE_ROOTS_MAX_ENTRIES.` + ); + } + + const newRoots = nextRoots.join(","); updateEnvLocal({ WORKSPACE_ROOTS: newRoots }); process.env.WORKSPACE_ROOTS = newRoots; } diff --git a/src/lib/innoclaw-cli/batch-client.test.ts b/src/lib/innoclaw-cli/batch-client.test.ts new file mode 100644 index 0000000..870fd8c --- /dev/null +++ b/src/lib/innoclaw-cli/batch-client.test.ts @@ -0,0 +1,128 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const runAgentStreamMock = vi.fn(); +const ensureWorkspaceMock = vi.fn(); + +vi.mock("../../../plugins/innoclaw-cli/src/agent-client.mjs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runAgentStream: runAgentStreamMock, + }; +}); + +vi.mock("../../../plugins/innoclaw-cli/src/workspace-client.mjs", () => ({ + ensureWorkspace: ensureWorkspaceMock, +})); + +describe("innoclaw-cli batch client", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "innoclaw-batch-test-")); + runAgentStreamMock.mockReset(); + ensureWorkspaceMock.mockReset(); + ensureWorkspaceMock.mockResolvedValue({ id: "workspace-1", name: "Workspace" }); + runAgentStreamMock.mockImplementation(async (_apiClient, { messages }) => ({ + messages: [ + ...messages, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "done" }], + }, + ], + })); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function writeBatch(entries: unknown[]) { + const inputPath = path.join(tempDir, "batch.json"); + await writeFile(inputPath, JSON.stringify(entries), "utf-8"); + return inputPath; + } + + it("uses the batch default mode unless an entry provides its own valid mode", async () => { + const inputPath = await writeBatch([ + { id: "default-mode", prompt: "Use the default" }, + { id: "entry-mode", prompt: "Use the entry", mode: "ask" }, + ]); + + const { runBatch } = await import("../../../plugins/innoclaw-cli/src/batch-client.mjs"); + await runBatch({}, { + inputPath, + defaultCwd: tempDir, + defaultMode: "plan", + workers: 1, + outputDir: path.join(tempDir, "runs"), + }); + + expect(runAgentStreamMock).toHaveBeenNthCalledWith(1, expect.anything(), expect.objectContaining({ + mode: "plan", + })); + expect(runAgentStreamMock).toHaveBeenNthCalledWith(2, expect.anything(), expect.objectContaining({ + mode: "ask", + })); + }); + + it("rejects batch entries with an unsupported mode", async () => { + const inputPath = await writeBatch([ + { id: "bad-mode", prompt: "Nope", mode: "review" }, + ]); + + const { runBatch } = await import("../../../plugins/innoclaw-cli/src/batch-client.mjs"); + + await expect(runBatch({}, { + inputPath, + defaultCwd: tempDir, + outputDir: path.join(tempDir, "runs"), + })).rejects.toThrow("Batch item 1 has unsupported mode: review"); + }); + + it("rejects an unsupported default mode before any API call", async () => { + const inputPath = await writeBatch([ + { id: "default-mode", prompt: "Use the default" }, + ]); + + const { runBatch } = await import("../../../plugins/innoclaw-cli/src/batch-client.mjs"); + + await expect(runBatch({}, { + inputPath, + defaultCwd: tempDir, + defaultMode: "review", + outputDir: path.join(tempDir, "runs"), + })).rejects.toThrow("Unsupported default mode: review"); + + expect(ensureWorkspaceMock).not.toHaveBeenCalled(); + expect(runAgentStreamMock).not.toHaveBeenCalled(); + }); + + it("writes the selected mode to batch results", async () => { + const inputPath = await writeBatch([ + { id: "long", prompt: "Run long", mode: "long-agent" }, + ]); + + const { runBatch } = await import("../../../plugins/innoclaw-cli/src/batch-client.mjs"); + const result = await runBatch({}, { + inputPath, + defaultCwd: tempDir, + workers: 1, + outputDir: path.join(tempDir, "runs"), + }); + + expect(result.results[0]).toEqual(expect.objectContaining({ + id: "long", + mode: "long-agent", + })); + const persisted = JSON.parse(await readFile(result.resultsFile, "utf-8")); + expect(persisted[0]).toEqual(expect.objectContaining({ + mode: "long-agent", + })); + }); +}); diff --git a/src/lib/innoclaw-cli/http.test.ts b/src/lib/innoclaw-cli/http.test.ts new file mode 100644 index 0000000..c4e975b --- /dev/null +++ b/src/lib/innoclaw-cli/http.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("innoclaw-cli http client", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("retries local requests on 127.0.0.1 when localhost fetch fails", async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error("localhost unavailable")) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchMock); + + const { createApiClient } = await import("../../../plugins/innoclaw-cli/src/http.mjs"); + const client = createApiClient({ baseUrl: "http://localhost:3000" }); + const { payload } = await client.requestJson("/api/workspaces"); + + expect(payload).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenNthCalledWith(1, "http://localhost:3000/api/workspaces", expect.any(Object)); + expect(fetchMock).toHaveBeenNthCalledWith(2, "http://127.0.0.1:3000/api/workspaces", expect.any(Object)); + }); +}); diff --git a/src/lib/innoclaw-cli/model-client.test.ts b/src/lib/innoclaw-cli/model-client.test.ts new file mode 100644 index 0000000..6d6e7e5 --- /dev/null +++ b/src/lib/innoclaw-cli/model-client.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { + getModelSettings, + parseModelCommandArgs, + setModelSettings, +} from "../../../plugins/innoclaw-cli/src/model-client.mjs"; + +describe("innoclaw-cli model client", () => { + it("parses /model with no args as a show command", () => { + expect(parseModelCommandArgs([], "openai")).toEqual({ action: "show" }); + }); + + it("parses /model set using the current provider", () => { + expect(parseModelCommandArgs(["set", "gpt-5.4"], "openai")).toEqual({ + action: "set", + provider: "openai", + model: "gpt-5.4", + }); + }); + + it("parses /model set ", () => { + expect(parseModelCommandArgs(["set", "anthropic", "claude-sonnet-4-20250514"], "openai")).toEqual({ + action: "set", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + }); + }); + + it("loads current model settings from /api/settings", async () => { + const apiClient = { + requestJson: vi.fn(async () => ({ + payload: { + llmProvider: "openai", + llmModel: "gpt-5.4", + }, + })), + }; + + await expect(getModelSettings(apiClient)).resolves.toEqual({ + provider: "openai", + model: "gpt-5.4", + }); + }); + + it("persists model settings through /api/settings", async () => { + const apiClient = { + requestJson: vi.fn(async () => ({ payload: { success: true } })), + }; + + await expect(setModelSettings(apiClient, { + provider: "openai", + model: "gpt-5.4", + })).resolves.toEqual({ + provider: "openai", + model: "gpt-5.4", + }); + + expect(apiClient.requestJson).toHaveBeenCalledWith("/api/settings", expect.objectContaining({ + method: "PATCH", + body: { + llm_provider: "openai", + llm_model: "gpt-5.4", + }, + })); + }); +}); diff --git a/src/lib/innoclaw-cli/server-client.test.ts b/src/lib/innoclaw-cli/server-client.test.ts new file mode 100644 index 0000000..9eec51b --- /dev/null +++ b/src/lib/innoclaw-cli/server-client.test.ts @@ -0,0 +1,342 @@ +import { EventEmitter } from "node:events"; +import { chmod, copyFile, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.fn(); +const delayMock = vi.fn(() => Promise.resolve()); + +function jsonResponse(status: number, body: unknown) { + return { + status, + headers: new Headers({ "content-type": "application/json" }), + json: async () => body, + }; +} + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +vi.mock("node:timers/promises", () => ({ + setTimeout: delayMock, +})); + +describe("innoclaw-cli ensureServerReady", () => { + beforeEach(() => { + spawnMock.mockReset(); + delayMock.mockClear(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("keeps polling when dev-start exits non-zero but the server becomes reachable", async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error("offline")) + .mockRejectedValueOnce(new Error("booting")) + .mockResolvedValueOnce(jsonResponse( + 200, + { + user: { id: "user-1" }, + authMode: "disabled", + }, + )); + vi.stubGlobal("fetch", fetchMock); + + spawnMock.mockImplementation(() => { + const child = new EventEmitter(); + queueMicrotask(() => { + child.emit("exit", 1); + }); + return child; + }); + + const { ensureServerReady } = await import("../../../plugins/innoclaw-cli/src/server-client.mjs"); + + await expect(ensureServerReady({ + appRoot: "/tmp/innoclaw-app", + baseUrl: "http://localhost:3000", + waitTimeoutMs: 5_000, + })).resolves.toBeUndefined(); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("does not treat an arbitrary login page as a reachable InnoClaw server", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + status: 200, + headers: new Headers({ "content-type": "text/html" }), + json: async () => { + throw new SyntaxError("Unexpected token '<'"); + }, + }); + vi.stubGlobal("fetch", fetchMock); + + const { ensureServerReady } = await import("../../../plugins/innoclaw-cli/src/server-client.mjs"); + + await expect(ensureServerReady({ + appRoot: "/tmp/innoclaw-app", + baseUrl: "https://example.com", + autoStart: false, + waitTimeoutMs: 1_000, + })).rejects.toThrow("InnoClaw is not reachable at https://example.com"); + + expect(spawnMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith("https://example.com/api/auth/me", expect.any(Object)); + }); + + it("rejects text responses even when the body looks like a valid auth probe", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + status: 200, + headers: new Headers({ "content-type": "text/plain" }), + json: async () => ({ + user: { id: "user-1" }, + authMode: "disabled", + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { ensureServerReady } = await import("../../../plugins/innoclaw-cli/src/server-client.mjs"); + + await expect(ensureServerReady({ + appRoot: "/tmp/innoclaw-app", + baseUrl: "https://example.com", + autoStart: false, + waitTimeoutMs: 1_000, + })).rejects.toThrow("InnoClaw is not reachable at https://example.com"); + + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("accepts an authenticated InnoClaw auth probe", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse( + 200, + { + user: { id: "user-1", email: "dev@example.com" }, + authMode: "oauth", + }, + )); + vi.stubGlobal("fetch", fetchMock); + + const { ensureServerReady } = await import("../../../plugins/innoclaw-cli/src/server-client.mjs"); + + await expect(ensureServerReady({ + appRoot: "/tmp/innoclaw-app", + baseUrl: "http://localhost:3000", + waitTimeoutMs: 1_000, + })).resolves.toBeUndefined(); + + expect(spawnMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith("http://localhost:3000/api/auth/me", expect.any(Object)); + }); + + it("accepts an unauthenticated InnoClaw auth probe", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(401, { error: "Unauthorized" })); + vi.stubGlobal("fetch", fetchMock); + + const { ensureServerReady } = await import("../../../plugins/innoclaw-cli/src/server-client.mjs"); + + await expect(ensureServerReady({ + appRoot: "/tmp/innoclaw-app", + baseUrl: "http://localhost:3000", + waitTimeoutMs: 1_000, + })).resolves.toBeUndefined(); + + expect(spawnMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith("http://localhost:3000/api/auth/me", expect.any(Object)); + }); + + it("accepts a local server that is reachable on 127.0.0.1 when localhost fetch fails", async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error("localhost unavailable")) + .mockResolvedValueOnce(jsonResponse( + 200, + { + user: { id: "user-1" }, + authMode: "disabled", + }, + )); + vi.stubGlobal("fetch", fetchMock); + + const { ensureServerReady } = await import("../../../plugins/innoclaw-cli/src/server-client.mjs"); + + await expect(ensureServerReady({ + appRoot: "/tmp/innoclaw-app", + baseUrl: "http://localhost:3000", + waitTimeoutMs: 1_000, + })).resolves.toBeUndefined(); + + expect(spawnMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenNthCalledWith(1, "http://localhost:3000/api/auth/me", expect.any(Object)); + expect(fetchMock).toHaveBeenNthCalledWith(2, "http://127.0.0.1:3000/api/auth/me", expect.any(Object)); + }); +}); + +describe("dev-start.sh", () => { + function toBashPath(windowsPath: string) { + const normalized = windowsPath.replace(/\\/g, "/"); + return normalized.replace(/^([A-Za-z]):/, (_, drive: string) => `/${drive.toLowerCase()}`); + } + + async function runDevStart( + args: string[], + options: { cwd?: string; env?: Partial } = {}, + ) { + const { execFile } = await vi.importActual("node:child_process"); + const bashPath = process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "bash"; + + return new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + execFile( + bashPath, + ["dev-start.sh", ...args], + { + cwd: options.cwd ?? process.cwd(), + env: { + ...process.env, + ...options.env, + }, + }, + (error, stdout, stderr) => { + resolve({ + stdout, + stderr, + code: typeof error?.code === "number" ? error.code : error ? 1 : 0, + }); + }, + ); + }); + } + + async function withStubPath( + stubs: Record, + run: (stubDir: string) => Promise, + ) { + const stubDir = await mkdtemp(path.join(tmpdir(), "innoclaw-dev-start-")); + try { + await Promise.all(Object.entries(stubs).map(async ([name, contents]) => { + const stubPath = path.join(stubDir, name); + await writeFile(stubPath, contents); + await chmod(stubPath, 0o755); + })); + await run(stubDir); + } finally { + await rm(stubDir, { recursive: true, force: true }); + } + } + + async function withDevStartCopy(run: (scriptDir: string) => Promise) { + const scriptDir = await mkdtemp(path.join(tmpdir(), "innoclaw-dev-start-script-")); + try { + const scriptPath = path.join(scriptDir, "dev-start.sh"); + await copyFile(path.join(process.cwd(), "dev-start.sh"), scriptPath); + await chmod(scriptPath, 0o755); + await run(scriptDir); + } finally { + await rm(scriptDir, { recursive: true, force: true }); + } + } + + it("accepts a 401 JSON Unauthorized auth probe", async () => { + await withStubPath({ + "curl": `#!/bin/sh +body_file="" +fail_on_http_error=0 +while [ "$#" -gt 0 ]; do + case "$1" in + -*f*) fail_on_http_error=1 ;; + esac + if [ "$1" = "-o" ]; then + body_file="$2" + shift 2 + continue + fi + shift +done +printf '{"error":"Unauthorized"}' > "$body_file" +printf '401\\napplication/json' +if [ "$fail_on_http_error" -eq 1 ]; then + exit 22 +fi +`, + }, async (stubDir) => { + await withDevStartCopy(async (scriptDir) => { + const result = await runDevStart(["server_responding"], { + cwd: scriptDir, + env: { + INNOCLAW_DEV_START_TEST_HOOK: "1", + INNOCLAW_DEV_START_TEST_PATH: toBashPath(stubDir), + }, + }); + + expect(result).toMatchObject({ code: 0 }); + }); + }); + }); + + it("rejects text/plain even when the body looks like a valid auth probe", async () => { + await withStubPath({ + "curl": `#!/bin/sh +body_file="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + body_file="$2" + shift 2 + continue + fi + shift +done +printf '{"user":{"id":"user-1"},"authMode":"disabled"}' > "$body_file" +printf '200\\ntext/plain' +`, + }, async (stubDir) => { + await withDevStartCopy(async (scriptDir) => { + const result = await runDevStart(["server_responding"], { + cwd: scriptDir, + env: { + INNOCLAW_DEV_START_TEST_HOOK: "1", + INNOCLAW_DEV_START_TEST_PATH: toBashPath(stubDir), + }, + }); + + expect(result).toMatchObject({ code: 1 }); + }); + }); + }); + + it("exits on an unrelated occupied port without invoking kill", async () => { + await withStubPath({ + "lsof": `#!/bin/sh +printf '4242\\n' +`, + "ps": `#!/bin/sh +case "$*" in + *"-o comm="*) printf 'node\\n' ;; + *"-o args="*) printf 'node unrelated-server.js\\n' ;; + *) exit 0 ;; +esac +`, + "kill": `#!/bin/sh +printf 'kill should not be called\\n' > "$KILL_MARKER" +exit 1 +`, + }, async (stubDir) => { + const killMarker = path.join(stubDir, "kill-called"); + await withDevStartCopy(async (scriptDir) => { + const result = await runDevStart([], { + cwd: scriptDir, + env: { + INNOCLAW_DEV_START_TEST_PATH: toBashPath(stubDir), + KILL_MARKER: killMarker, + }, + }); + + await expect(rm(killMarker, { force: false })).rejects.toThrow(); + expect(result.code).toBe(1); + expect(result.stdout).toContain("Port 3000 is occupied"); + expect(result.stdout).toContain("not managed by this repo's .dev.pid"); + }); + }); + }); +}); diff --git a/src/lib/innoclaw-cli/session-client.test.ts b/src/lib/innoclaw-cli/session-client.test.ts new file mode 100644 index 0000000..d6fbd48 --- /dev/null +++ b/src/lib/innoclaw-cli/session-client.test.ts @@ -0,0 +1,149 @@ +import { EventEmitter } from "node:events"; +import { chmod, mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.fn(); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +describe("innoclaw-cli session client", () => { + beforeEach(() => { + spawnMock.mockReset(); + vi.restoreAllMocks(); + vi.resetModules(); + vi.doUnmock("node:fs/promises"); + }); + + it("reports browser launch failure when the launcher emits an async error", async () => { + const child = new EventEmitter() as EventEmitter & { unref: ReturnType }; + child.unref = vi.fn(); + spawnMock.mockImplementation(() => { + queueMicrotask(() => { + child.emit("error", Object.assign(new Error("not found"), { code: "ENOENT" })); + }); + return child; + }); + + const { openBrowser } = await import("../../../plugins/innoclaw-cli/src/session-client.mjs"); + + await expect(openBrowser("http://localhost:3000/login", { settleMs: 1 })).resolves.toBe(false); + expect(child.unref).not.toHaveBeenCalled(); + }); + + it("persists sessions with private POSIX permissions", async () => { + if (process.platform === "win32") { + return; + } + + const homeDir = await mkdtemp(path.join(os.tmpdir(), "innoclaw-cli-session-")); + vi.spyOn(os, "homedir").mockReturnValue(homeDir); + const { createSessionManager } = await import("../../../plugins/innoclaw-cli/src/session-client.mjs"); + const manager = createSessionManager("http://localhost:3000", () => ({ + requestJson: vi.fn(), + })); + + await manager.save({ + cookies: { + innoclaw_session: "token-123", + innoclaw_session_expires: "2026-06-20T00:00:00.000Z", + innoclaw_session_sig: "sig-123", + }, + expiresAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-18T00:00:00.000Z", + }); + + const sessionDirMode = (await stat(path.join(homeDir, ".innoclaw"))).mode & 0o777; + const sessionFileMode = (await stat(path.join(homeDir, ".innoclaw", "cli-sessions.json"))).mode & 0o777; + expect(sessionDirMode).toBe(0o700); + expect(sessionFileMode).toBe(0o600); + }); + + it("tightens an existing permissive POSIX session file before final save", async () => { + if (process.platform === "win32") { + return; + } + + const homeDir = await mkdtemp(path.join(os.tmpdir(), "innoclaw-cli-session-")); + const sessionDir = path.join(homeDir, ".innoclaw"); + const sessionFile = path.join(sessionDir, "cli-sessions.json"); + await mkdir(sessionDir); + await writeFile(sessionFile, JSON.stringify({ version: 1, sessions: {} }), { encoding: "utf-8", mode: 0o666 }); + await chmod(sessionDir, 0o777); + await chmod(sessionFile, 0o666); + + vi.spyOn(os, "homedir").mockReturnValue(homeDir); + const { createSessionManager } = await import("../../../plugins/innoclaw-cli/src/session-client.mjs"); + const manager = createSessionManager("http://localhost:3000", () => ({ + requestJson: vi.fn(), + })); + + await manager.save({ + cookies: { + innoclaw_session: "token-456", + innoclaw_session_expires: "2026-06-20T00:00:00.000Z", + innoclaw_session_sig: "sig-456", + }, + expiresAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-18T00:00:00.000Z", + }); + + const sessionDirMode = (await stat(sessionDir)).mode & 0o777; + const sessionFileMode = (await stat(sessionFile)).mode & 0o777; + const saved = JSON.parse(await readFile(sessionFile, "utf-8")); + expect(sessionDirMode).toBe(0o700); + expect(sessionFileMode).toBe(0o600); + expect(saved.sessions["http://localhost:3000"].cookies.innoclaw_session).toBe("token-456"); + }); + + it("does not write token material directly into an existing permissive POSIX session file", async () => { + if (process.platform === "win32") { + return; + } + + const homeDir = await mkdtemp(path.join(os.tmpdir(), "innoclaw-cli-session-")); + const sessionDir = path.join(homeDir, ".innoclaw"); + const sessionFile = path.join(sessionDir, "cli-sessions.json"); + await mkdir(sessionDir); + await writeFile(sessionFile, JSON.stringify({ version: 1, sessions: {} }), { encoding: "utf-8", mode: 0o666 }); + await chmod(sessionDir, 0o777); + await chmod(sessionFile, 0o666); + + const actualFs = await vi.importActual("node:fs/promises"); + const directWrites: string[] = []; + vi.doMock("node:fs/promises", () => ({ + ...actualFs, + writeFile: vi.fn(async (file: Parameters[0], data: Parameters[1], options) => { + if (path.resolve(String(file)) === path.resolve(sessionFile) && String(data).includes("token-direct-write")) { + directWrites.push(String(file)); + } + return actualFs.writeFile(file, data, options); + }), + })); + + vi.spyOn(os, "homedir").mockReturnValue(homeDir); + const { createSessionManager } = await import("../../../plugins/innoclaw-cli/src/session-client.mjs"); + const manager = createSessionManager("http://localhost:3000", () => ({ + requestJson: vi.fn(), + })); + + const beforeStat = await stat(sessionFile); + await manager.save({ + cookies: { + innoclaw_session: "token-direct-write", + innoclaw_session_expires: "2026-06-20T00:00:00.000Z", + innoclaw_session_sig: "sig-direct-write", + }, + expiresAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-18T00:00:00.000Z", + }); + const afterStat = await stat(sessionFile); + + expect(directWrites).toEqual([]); + expect(afterStat.ino).not.toBe(beforeStat.ino); + expect(afterStat.mode & 0o777).toBe(0o600); + }); +});