Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/dashboard/__tests__/session-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ async function testValidateBodyRejectsMalformed(): Promise<void> {
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)
Expand All @@ -93,19 +101,30 @@ async function testValidateBodyRejectsMalformed(): Promise<void> {
* 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<void> {
Expand Down Expand Up @@ -187,6 +206,10 @@ async function testStartHttpEndpointValidatesAndSpawns(): Promise<void> {
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", {
Expand All @@ -202,13 +225,19 @@ async function testStartHttpEndpointValidatesAndSpawns(): Promise<void> {

// 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()
}
Expand Down
5 changes: 4 additions & 1 deletion src/dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -512,6 +514,7 @@ async function handleRequest(
workspace: repo,
task: start.task,
mode: start.mode,
model: start.model,
cli: options.cli,
repoRoot: options.repoRoot,
})
Expand Down
23 changes: 21 additions & 2 deletions src/dashboard/session-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, unknown>
const { task, repo, mode, model } = body as Record<string, unknown>
if (typeof task !== "string" || task.trim() === "") {
return "missing or empty 'task' — provide the prompt text to run"
}
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -249,14 +260,22 @@ export async function launchSession(
): Promise<LaunchSessionResult> {
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;
// the CLI runs with repoRoot as cwd so `npx tsx` finds src/cli.ts no
// 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
Expand Down