diff --git a/src/dashboard/__tests__/session-launch.test.ts b/src/dashboard/__tests__/session-launch.test.ts index 66f0e4e..8518a50 100644 --- a/src/dashboard/__tests__/session-launch.test.ts +++ b/src/dashboard/__tests__/session-launch.test.ts @@ -80,6 +80,14 @@ async function testValidateBodyRejectsMalformed(): Promise { assert.match(validateSessionStartBody({ task: " " }) ?? "", /missing or empty 'task'/i) assert.match(validateSessionStartBody({ task: "x", repo: 42 }) ?? "", /'repo' must be a non-empty string/i) assert.match(validateSessionStartBody({ task: "x", mode: "" }) ?? "", /'mode' must be a non-empty string/i) + assert.match( + validateSessionStartBody({ task: "x", model: "bad model" }) ?? "", + /valid identifier/i, + ) + assert.match( + validateSessionStartBody({ task: "x", model: "m".repeat(256) }) ?? "", + /255 characters/i, + ) // Valid bodies pass. assert.equal(validateSessionStartBody({ task: "do the thing" }), undefined) assert.equal(validateSessionStartBody({ task: "x", repo: "/tmp/r", mode: "code" }), undefined) @@ -93,19 +101,30 @@ async function testValidateBodyRejectsMalformed(): Promise { * file existed and the process was still running shortly after the response — * proving the endpoint truly detached (spawn + unref), not awaited. */ -async function makeFakeCli(): Promise<{ dir: string; script: string; marker: string }> { +async function makeFakeCli(): Promise<{ + dir: string + script: string + marker: string + argvMarker: string +}> { const dir = await tmpRepo("hc-launch-fakecli-") const script = path.join(dir, "fake-cli.mjs") const marker = path.join(dir, "started.marker") + const argvMarker = path.join(dir, "argv.marker") await fs.writeFile( script, `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(marker)}, process.pid.toString(), "utf-8"); +writeFileSync( + ${JSON.stringify(argvMarker)}, + JSON.stringify(process.argv.slice(2)), + "utf-8", +); await new Promise((r) => setTimeout(r, 5000)); `, "utf-8", ) - return { dir, script, marker } + return { dir, script, marker, argvMarker } } async function testStartSpawnsDetachedProcess(): Promise { @@ -187,6 +206,10 @@ async function testStartHttpEndpointValidatesAndSpawns(): Promise { const bad = await postJson(base, "/api/session/start", { mode: "code" }) assert.equal(bad.status, 400) assert.match(String((bad.body as { error?: string }).error ?? ""), /missing or empty 'task'/i) + const badModel = await postJson(base, "/api/session/start", { + task: "do work", model: "bad model", + }) + assert.equal(badModel.status, 400) // Invalid JSON body -> 400. const badJsonRes = await fetch(base + "/api/session/start", { @@ -202,13 +225,19 @@ async function testStartHttpEndpointValidatesAndSpawns(): Promise { // Valid -> 200 with a sessionId + pid, and the fake's marker file // gets written (proving the child really was spawned). - const ok = await postJson(base, "/api/session/start", { task: "do work", mode: "code" }) + const ok = await postJson(base, "/api/session/start", { + task: "do work", mode: "code", model: "provider/model-v1:latest", + }) assert.equal(ok.status, 200) const body = ok.body as { sessionId: string; pid: number } assert.ok(typeof body.sessionId === "string" && body.sessionId.length > 0) assert.ok(Number.isInteger(body.pid) && body.pid > 0) await sleep(300) await fs.access(fake.marker) + const argv = JSON.parse(await fs.readFile(fake.argvMarker, "utf-8")) as string[] + const modelIndex = argv.indexOf("--model") + assert.ok(modelIndex >= 0, "selected model must reach the spawned CLI") + assert.equal(argv[modelIndex + 1], "provider/model-v1:latest") } finally { server.close() } diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 6e8dc52..7019203 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -496,7 +496,9 @@ async function handleRequest( .end(JSON.stringify({ error: validationError })) return } - const start = body as { task: string; repo?: string; mode?: string } + const start = body as { + task: string; repo?: string; mode?: string; model?: string + } const repo = start.repo?.trim() || options.repo if (!repo) { res @@ -512,6 +514,7 @@ async function handleRequest( workspace: repo, task: start.task, mode: start.mode, + model: start.model, cli: options.cli, repoRoot: options.repoRoot, }) diff --git a/src/dashboard/session-launch.ts b/src/dashboard/session-launch.ts index c78695a..d2abfb5 100644 --- a/src/dashboard/session-launch.ts +++ b/src/dashboard/session-launch.ts @@ -60,6 +60,8 @@ export interface SessionStartRequest { task?: string /** Mode slug (default: multi-agent-orchestrator-headless). */ mode?: string + /** Optional coding model identifier forwarded to the session CLI. */ + model?: string } /** Request body for POST /api/session/:id/answer. */ @@ -208,7 +210,7 @@ export function validateSessionStartBody(body: unknown): string | undefined { if (body === null || typeof body !== "object" || Array.isArray(body)) { return "body must be a JSON object" } - const { task, repo, mode } = body as Record + const { task, repo, mode, model } = body as Record if (typeof task !== "string" || task.trim() === "") { return "missing or empty 'task' — provide the prompt text to run" } @@ -218,9 +220,17 @@ export function validateSessionStartBody(body: unknown): string | undefined { if (mode !== undefined && (typeof mode !== "string" || mode.trim() === "")) { return "'mode' must be a non-empty string slug" } + if (model !== undefined && !isValidModelIdentifier(model)) { + return "'model' must be a valid identifier of at most 255 characters" + } return undefined } +function isValidModelIdentifier(value: unknown): value is string { + return typeof value === "string" && + /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,254}$/.test(value.trim()) +} + /** Split a CLI command string into argv (whitespace-separated, no shell). */ function splitCli(cli: string): string[] { return cli.split(/\s+/).filter((part) => part.length > 0) @@ -238,6 +248,7 @@ export async function launchSession( workspace: string task: string mode?: string + model?: string /** * CLI invocation template, e.g. "npx tsx src/cli.ts". Tests point it * at a tiny fake script for fast, deterministic assertions. @@ -249,6 +260,10 @@ export async function launchSession( ): Promise { const sessionId = randomUUID() const mode = options.mode?.trim() ? options.mode.trim() : DEFAULT_LAUNCH_MODE + const model = options.model?.trim() + if (model && !isValidModelIdentifier(model)) { + throw new Error("model must be a valid identifier of at most 255 characters") + } const workspace = path.resolve(options.workspace) const cli = options.cli ?? "npx tsx src/cli.ts" // The dashboard may have been started from a different cwd than this repo; @@ -256,7 +271,11 @@ export async function launchSession( // matter where the dashboard process was launched from. const repoRoot = path.resolve(options.repoRoot ?? process.cwd()) - const argv = [...splitCli(cli), "--task", options.task, "--mode", mode, "--workspace", workspace, "--session-id", sessionId] + const argv = [ + ...splitCli(cli), "--task", options.task, "--mode", mode, + "--workspace", workspace, "--session-id", sessionId, + ...(model ? ["--model", model] : []), + ] // NOTE (control-plane): the child inherits this process's environment — // HEADLESSCODE_OPENROUTER_API_KEY and OPENROUTER_BASE_URL (the e2e mock) included. The