diff --git a/README.md b/README.md index f49f8a7..138a98b 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 headless automation, `DISABLE_AUTH=true` remains supported. + +Headless local run mode: + +```bash +DISABLE_AUTH=true npm run dev +``` + > **Security**: InnoClaw includes shell execution and remote job submission capabilities. See [SECURITY.md](SECURITY.md) for deployment hardening and trust boundary documentation.
@@ -137,6 +153,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. #### 2026-04-17 @@ -305,4 +322,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..8e9bbb8 100755 --- a/dev-start.sh +++ b/dev-start.sh @@ -5,6 +5,26 @@ cd "$(dirname "$0")" PORT=3000 +server_responding() { + command -v curl >/dev/null 2>&1 || return 1 + curl --noproxy "*" -fsS -o /dev/null -I "http://127.0.0.1:$PORT/login" >/dev/null 2>&1 +} + +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)" +} + # Check if already running if [ -f .dev.pid ]; then PID=$(cat .dev.pid) @@ -12,8 +32,31 @@ 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 server_responding; then + echo "Dev server is already running (PID: $PID)" + exit 0 + fi + + PID_AGE=$(pid_elapsed_seconds "$PID") + if is_repo_dev_process "$PID" && [ -n "$PID_AGE" ] && [ "$PID_AGE" -le 30 ]; then + echo "Dev server is still starting (PID: $PID, age: ${PID_AGE}s)" + exit 0 + fi + + if is_repo_dev_process "$PID"; then + 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 + else + 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." + fi + rm -f .dev.pid else rm -f .dev.pid fi diff --git a/docs/usage/api-reference.md b/docs/usage/api-reference.md index 88a98c8..c66b1ef 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 `DISABLE_AUTH=true`, the headless admin compatibility path remains available. + **Response:** ```json @@ -35,7 +103,8 @@ POST /api/workspaces ```json { - "path": "/data/research/my-project" + "name": "my-project", + "folderPath": "/data/research/my-project" } ``` diff --git a/middleware.ts b/middleware.ts index 5be98c3..efc42ef 100644 --- a/middleware.ts +++ b/middleware.ts @@ -72,11 +72,19 @@ function isPublicApi(pathname: string): boolean { return AUTH_PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } +function hasCliHandoffParams(request: NextRequest): boolean { + return request.nextUrl.searchParams.has("cliCallback") && request.nextUrl.searchParams.has("cliNonce"); +} + export async function middleware(request: NextRequest) { + if (process.env.DISABLE_AUTH === "true") { + return NextResponse.next(); + } + const { pathname } = request.nextUrl; if (isPublicPath(pathname) || isPublicApi(pathname)) { - if (AUTH_PUBLIC_PATHS.has(pathname) && await hasValidSessionMarker(request)) { + if (AUTH_PUBLIC_PATHS.has(pathname) && !hasCliHandoffParams(request) && await hasValidSessionMarker(request)) { return NextResponse.redirect(new URL("/", request.url)); } return NextResponse.next(); diff --git a/plugins/innoclaw-cli/README.md b/plugins/innoclaw-cli/README.md index 145e52b..9e18a09 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 headless runs, start the app with `DISABLE_AUTH=true npm run dev`. ## 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 +DISABLE_AUTH=true npm run dev +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..30b0691 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,21 @@ 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 + +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 +96,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 +151,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 +219,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 +232,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 +249,246 @@ 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"), + 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, + 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..28112f0 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 headless and CI-style runs, start the app with `DISABLE_AUTH=true npm run dev`. + +## 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..ac66ca0 --- /dev/null +++ b/plugins/innoclaw-cli/src/batch-client.mjs @@ -0,0 +1,215 @@ +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"; + +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`); + } + return { + id: typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : `item-${index + 1}`, + prompt: prompt.trim(), + 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"); +} + +export async function runBatch(apiClient, { + inputPath, + defaultCwd, + defaultSkill = null, + defaultProvider = null, + defaultModel = null, + workers = 2, + startId = null, + ids = [], + limit = null, + outputDir = null, + jsonl = false, + failFast = false, +} = {}) { + 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 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: "agent", + 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, + 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, + 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..a35f6b0 --- /dev/null +++ b/plugins/innoclaw-cli/src/http.mjs @@ -0,0 +1,131 @@ +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}`; +} + +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..d9af7b5 --- /dev/null +++ b/plugins/innoclaw-cli/src/repl.mjs @@ -0,0 +1,232 @@ +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 }) { + 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 || "DISABLE_AUTH"}`); + console.log(`mode: agent${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, + 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, + }); + + 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: "agent", + skillId: skill, + paramValues: skill ? { user_input: input } : undefined, + llmProvider: activeProvider, + llmModel: activeModel, + onHeaders(headers) { + if (headers.provider || headers.model) { + sessionState = sessionState; + } + }, + 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..782809d --- /dev/null +++ b/plugins/innoclaw-cli/src/server-client.mjs @@ -0,0 +1,77 @@ +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}/login`, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(3_000), + }); + if (response.status >= 200 && response.status < 400) { + 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..26e3d51 --- /dev/null +++ b/plugins/innoclaw-cli/src/session-client.mjs @@ -0,0 +1,413 @@ +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 { mkdir, readFile, 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 }); + await writeFile(SESSION_FILE, JSON.stringify(store, null, 2), "utf-8"); +} + +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..edb78f5 --- /dev/null +++ b/src/app/api/auth/cli-session/route.ts @@ -0,0 +1,39 @@ +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"; + +export async function POST(request: NextRequest) { + try { + 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/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index 6fc9054..d519f6c 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -4,7 +4,7 @@ import { workspaces } from "@/lib/db/schema"; import { desc, eq } from "drizzle-orm"; import { nanoid } from "nanoid"; import { pathExists, isDirectory, addWorkspaceRoot } from "@/lib/files/filesystem"; -import { requireAuth } from "@/lib/auth/server"; +import { requireAuth, HEADLESS_ADMIN_ID } from "@/lib/auth/server"; import { ownedWorkspaceFilter } from "@/lib/auth/ownership"; import { jsonError, jsonException } from "@/lib/api-errors"; @@ -18,7 +18,7 @@ export async function GET(request: NextRequest) { const allWorkspaces = await db .select() .from(workspaces) - .where(ownedWorkspaceFilter(auth)) + .where(auth.user.id === HEADLESS_ADMIN_ID ? undefined : ownedWorkspaceFilter(auth)) .orderBy(desc(workspaces.lastOpenedAt)); return NextResponse.json(allWorkspaces); @@ -72,6 +72,13 @@ export async function POST(request: NextRequest) { if (existingOwner && existingOwner !== auth.user.id) { return jsonError("This folder is already registered to another account", 409); } + if (!existingOwner && auth.user.id === HEADLESS_ADMIN_ID) { + await db + .update(workspaces) + .set({ lastOpenedAt: new Date().toISOString() }) + .where(eq(workspaces.id, existing[0].id)); + return NextResponse.json(existing[0]); + } if (!existingOwner && auth.user.role === "admin") { await db .update(workspaces) @@ -86,7 +93,7 @@ export async function POST(request: NextRequest) { await db.insert(workspaces).values({ id, - ownerUserId: auth.user.id, + ownerUserId: auth.user.id === HEADLESS_ADMIN_ID ? null : auth.user.id, name, folderPath, isGitRepo: isGitRepo || false, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 452987d..9927ea2 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,43 +1,94 @@ "use client"; -import { FormEvent, useState } from "react"; +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, +} from "@/lib/auth/cli-handoff"; export default function LoginPage() { const router = useRouter(); + const searchParams = useSearchParams(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const registerHref = buildAuthPageHref("/register", searchParams); + + useEffect(() => { + let cancelled = false; + + async function resumeCliSession() { + if (!parseCliHandoffParams(searchParams)) { + return; + } + + setLoading(true); + try { + const res = await fetch("/api/auth/me"); + if (!res.ok) { + return; + } + await completeCliBrowserHandoff(searchParams); + if (cancelled) { + return; + } + router.replace(searchParams.get("next") || "/"); + router.refresh(); + } catch (resumeError) { + if (!cancelled) { + setError(resumeError instanceof Error ? resumeError.message : "Failed to resume CLI session"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void resumeCliSession(); + + return () => { + cancelled = true; + }; + }, [router, searchParams]); async function handleSubmit(event: FormEvent) { event.preventDefault(); 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); - const next = new URLSearchParams(window.location.search).get("next") || "/"; - router.replace(next); - router.refresh(); + const next = searchParams.get("next") || "/"; + router.replace(next); + router.refresh(); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "Login failed"); + } finally { + setLoading(false); + } } return ( @@ -84,7 +135,7 @@ export default function LoginPage() {

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

diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 69f086f..96ab72c 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -1,43 +1,95 @@ "use client"; -import { FormEvent, useState } from "react"; +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, +} from "@/lib/auth/cli-handoff"; export default function RegisterPage() { const router = useRouter(); + const searchParams = useSearchParams(); const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const loginHref = buildAuthPageHref("/login", searchParams); + + useEffect(() => { + let cancelled = false; + + async function resumeCliSession() { + if (!parseCliHandoffParams(searchParams)) { + return; + } + + setLoading(true); + try { + const res = await fetch("/api/auth/me"); + if (!res.ok) { + return; + } + await completeCliBrowserHandoff(searchParams); + if (cancelled) { + return; + } + router.replace(searchParams.get("next") || "/"); + router.refresh(); + } catch (resumeError) { + if (!cancelled) { + setError(resumeError instanceof Error ? resumeError.message : "Failed to resume CLI session"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void resumeCliSession(); + + return () => { + cancelled = true; + }; + }, [router, searchParams]); async function handleSubmit(event: FormEvent) { event.preventDefault(); 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(data.requiresSetup ? "/settings" : "/"); - router.refresh(); + const next = searchParams.get("next") || (data.requiresSetup ? "/settings" : "/"); + router.replace(next); + router.refresh(); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "Registration failed"); + } finally { + setLoading(false); + } } return ( @@ -94,7 +146,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..64e2fd6 --- /dev/null +++ b/src/lib/auth/cli-handoff.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + AUTH_SESSION_COOKIE, + AUTH_SESSION_EXPIRES_COOKIE, + AUTH_SESSION_SIGNATURE_COOKIE, +} from "./constants"; +import { + buildAuthPageHref, + createCliSessionHandoffPayload, + isLoopbackHostname, + parseCliHandoffParams, +} from "./cli-handoff"; + +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("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", + }, + }); + }); +}); diff --git a/src/lib/auth/cli-handoff.ts b/src/lib/auth/cli-handoff.ts new file mode 100644 index 0000000..513df7a --- /dev/null +++ b/src/lib/auth/cli-handoff.ts @@ -0,0 +1,129 @@ +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; +} + +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 buildAuthPageHref(basePath: string, searchParams: SearchParamSource): string { + const url = new URL(basePath, "http://innoclaw.local"); + + for (const key of ["next", "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/auth/ownership.ts b/src/lib/auth/ownership.ts index a591cf4..a5ef686 100644 --- a/src/lib/auth/ownership.ts +++ b/src/lib/auth/ownership.ts @@ -4,7 +4,11 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { deepResearchSessions, hfDatasets, notes, scheduledTasks, skills, workspaces } from "@/lib/db/schema"; import { isWithinWorkspace } from "@/lib/files/filesystem"; -import { forbiddenResponse, requireAuth, type AuthContext } from "./server"; +import { forbiddenResponse, requireAuth, HEADLESS_ADMIN_ID, type AuthContext } from "./server"; + +function isHeadless(auth: AuthContext): boolean { + return auth.user.id === HEADLESS_ADMIN_ID; +} export function ownedWorkspaceFilter(auth: AuthContext) { if (auth.user.role === "admin") { @@ -42,7 +46,7 @@ export async function requireWorkspaceAccess( const [workspace] = await db .select() .from(workspaces) - .where(and(eq(workspaces.id, workspaceId), ownedWorkspaceFilter(auth))) + .where(isHeadless(auth) ? eq(workspaces.id, workspaceId) : and(eq(workspaces.id, workspaceId), ownedWorkspaceFilter(auth))) .limit(1); if (!workspace) { @@ -109,7 +113,7 @@ export async function requireSkillAccess( const [skill] = await db .select() .from(skills) - .where(and(eq(skills.id, skillId), ownedSkillFilter(auth))) + .where(isHeadless(auth) ? eq(skills.id, skillId) : and(eq(skills.id, skillId), ownedSkillFilter(auth))) .limit(1); if (!skill) { @@ -180,6 +184,10 @@ export async function requireWorkspacePathsAccess( return auth; } + if (isHeadless(auth)) { + return { auth }; + } + const rows = await db .select({ folderPath: workspaces.folderPath }) .from(workspaces) diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts index 536fdbe..4d7218c 100644 --- a/src/lib/auth/server.ts +++ b/src/lib/auth/server.ts @@ -315,7 +315,27 @@ export function forbiddenResponse(message = "Forbidden"): NextResponse { return NextResponse.json({ error: message }, { status: 403 }); } +export const HEADLESS_ADMIN_ID = "headless"; + +const HEADLESS_ADMIN_CONTEXT: AuthContext = { + user: { + id: HEADLESS_ADMIN_ID, + email: "headless@local", + name: "Headless Runner", + role: "admin", + isActive: true, + lastLoginAt: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }, + session: { id: HEADLESS_ADMIN_ID, expiresAt: "2099-01-01T00:00:00.000Z" }, + token: HEADLESS_ADMIN_ID, +}; + export async function requireAuth(request: NextRequest): Promise { + if (process.env.DISABLE_AUTH === "true") { + return HEADLESS_ADMIN_CONTEXT; + } const auth = await getAuthContext(request); if (!auth) { return unauthorizedResponse(); diff --git a/src/lib/files/filesystem.test.ts b/src/lib/files/filesystem.test.ts new file mode 100644 index 0000000..de84abc --- /dev/null +++ b/src/lib/files/filesystem.test.ts @@ -0,0 +1,39 @@ +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 }; + +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(); + }); +}); diff --git a/src/lib/files/filesystem.ts b/src/lib/files/filesystem.ts index 6f2211e..fd8634a 100644 --- a/src/lib/files/filesystem.ts +++ b/src/lib/files/filesystem.ts @@ -68,8 +68,10 @@ 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. 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 newRoots = [...roots, resolved].join(","); updateEnvLocal({ WORKSPACE_ROOTS: newRoots }); process.env.WORKSPACE_ROOTS = newRoots; } 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..c4287f5 --- /dev/null +++ b/src/lib/innoclaw-cli/server-client.test.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.fn(); +const delayMock = vi.fn(() => Promise.resolve()); + +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({ status: 200 }); + 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("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({ status: 200 }); + 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/login", expect.any(Object)); + expect(fetchMock).toHaveBeenNthCalledWith(2, "http://127.0.0.1:3000/login", expect.any(Object)); + }); +}); 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..7a4cec0 --- /dev/null +++ b/src/lib/innoclaw-cli/session-client.test.ts @@ -0,0 +1,31 @@ +import { EventEmitter } from "node:events"; +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.resetModules(); + }); + + 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(); + }); +});