feat: add web executor model configuration

This commit is contained in:
liujing
2026-07-20 16:59:24 +08:00
parent 4026f04b93
commit 64b3ef6c60
8 changed files with 1507 additions and 17 deletions
+59
View File
@@ -62,6 +62,65 @@ export function loadGlobalWorkflowConfig(): WorkflowProjectConfig | undefined {
return validateProjectConfig(raw, filePath);
}
/**
* Atomically update executors.<kind>.model in the global config.
* Pass `null` or `undefined` to remove the model override for an executor.
* Preserves all unrelated fields, including per-executor binary/agent/profile/budget.
*/
export function saveGlobalExecutorModels(models: Partial<Record<ExecutorKind, string | null | undefined>>): void {
const filePath = globalConfigPath();
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
let existing: WorkflowProjectConfig | undefined;
try {
existing = loadGlobalWorkflowConfig();
} catch (err) {
// If the existing file is corrupt or otherwise unreadable, fail instead of silently overwriting it.
throw new WorkflowConfigError(`Cannot update global config: ${(err as Error).message}`);
}
const base: WorkflowProjectConfig = existing
? {
...existing,
executors: existing.executors
? Object.fromEntries(
Object.entries(existing.executors).map(([kind, cfg]) => [kind, { ...(cfg as ExecutorConfig) }]),
)
: {},
}
: { version: "1", executors: {} };
if (!base.executors) base.executors = {};
for (const kind of EXECUTOR_KINDS) {
if (!Object.prototype.hasOwnProperty.call(models, kind)) continue;
const value = models[kind];
if (!base.executors[kind]) {
(base.executors as Record<string, ExecutorConfig>)[kind] = {};
}
const exec = base.executors[kind] as Record<string, unknown>;
if (value === null || value === undefined) {
delete exec.model;
} else {
const trimmed = value.trim();
if (!trimmed) {
throw new WorkflowConfigError(`Executor "${kind}" model must be a non-empty string`);
}
exec.model = trimmed;
}
if (Object.keys(exec).length === 0) {
delete (base.executors as Record<string, ExecutorConfig>)[kind];
}
}
if (base.executors && Object.keys(base.executors).length === 0) {
delete base.executors;
}
const tmp = `${filePath}.tmp.${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(base, null, 2) + "\n", "utf8");
fs.renameSync(tmp, filePath);
}
function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectConfig {
if (!raw || typeof raw !== "object") {
throw new WorkflowConfigError(`${filePath}: must be a JSON object`);
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { extractModelIds, discoverExecutorModels } from "./workflow-model-discovery.js";
import { saveGlobalExecutorModels, loadGlobalWorkflowConfig } from "./workflow-config.js";
describe("workflow-model-discovery helpers", () => {
it("extracts model ids from data[] shape", () => {
expect(extractModelIds({
data: [
{ id: "claude-sonnet-4" },
{ id: "claude-opus-4" },
{ name: "ignored" },
],
})).toEqual(["claude-sonnet-4", "claude-opus-4"]);
});
it("extracts model ids from models[] shape", () => {
expect(extractModelIds({
models: [
{ id: "model-a" },
"model-b",
],
})).toEqual(["model-a", "model-b"]);
});
});
describe("saveGlobalExecutorModels", () => {
let tmpHome: string;
let originalHome: string | undefined;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-global-config-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it("atomically updates only executor models and preserves unrelated fields", () => {
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
version: "1",
defaultExecutor: "reasonix",
maxCycles: 7,
executors: {
reasonix: { binary: "/usr/local/bin/reasonix", model: "old-model", profile: "fast" },
claude: { binary: "claude" },
},
}, null, 2));
saveGlobalExecutorModels({
reasonix: "new-reasonix",
claude: "claude-sonnet-4",
agy: null,
});
const loaded = loadGlobalWorkflowConfig()!;
expect(loaded.defaultExecutor).toBe("reasonix");
expect(loaded.maxCycles).toBe(7);
expect(loaded.executors?.reasonix).toEqual({
binary: "/usr/local/bin/reasonix",
model: "new-reasonix",
profile: "fast",
});
expect(loaded.executors?.claude).toEqual({
binary: "claude",
model: "claude-sonnet-4",
});
expect(loaded.executors?.agy).toBeUndefined();
});
it("null clears a model override without dropping other executor fields", () => {
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
version: "1",
executors: {
agy: { binary: "agy", model: "to-remove", agent: "code-assistant" },
},
}));
saveGlobalExecutorModels({ agy: null });
const loaded = loadGlobalWorkflowConfig()!;
expect(loaded.executors?.agy).toEqual({ binary: "agy", agent: "code-assistant" });
});
});
describe("discoverExecutorModels isolation", () => {
let tmpHome: string;
let originalHome: string | undefined;
let originalFetch: typeof globalThis.fetch | undefined;
const ANTHROPIC_VARS = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
] as const;
const savedVars: Record<string, string | undefined> = {};
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-discovery-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
originalFetch = globalThis.fetch;
for (const key of ANTHROPIC_VARS) {
savedVars[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
for (const key of ANTHROPIC_VARS) {
if (savedVars[key] === undefined) delete process.env[key];
else process.env[key] = savedVars[key];
}
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
fs.rmSync(tmpHome, { recursive: true, force: true });
vi.restoreAllMocks();
});
function writeClaudeSettings(env: Record<string, string>): void {
const claudeDir = path.join(tmpHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ env }));
}
it("keeps Claude discovery isolated and never returns credentials", async () => {
process.env.ANTHROPIC_API_KEY = "secret-key-value";
process.env.ANTHROPIC_BASE_URL = "https://user:pass@api.example.com/v1?token=abc#frag";
globalThis.fetch = vi.fn(async () => {
throw new Error("network down secret-key-value");
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("error");
expect(result.claude.error).toBeTruthy();
expect(JSON.stringify(result)).not.toContain("secret-key-value");
expect(JSON.stringify(result)).not.toContain("user:pass");
expect(result.agy.status === "ok" || result.agy.status === "error").toBe(true);
expect(result.reasonix.status === "ok" || result.reasonix.status === "error").toBe(true);
});
it("resolves Claude settings from ~/.claude/settings.json when process env is unset", async () => {
// Process env is fully cleared in beforeEach; settings.json is the only source.
writeClaudeSettings({
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
ANTHROPIC_DEFAULT_SONNET_MODEL: "gateway-sonnet",
ANTHROPIC_DEFAULT_OPUS_MODEL: "gateway-opus",
});
let capturedUrl = "";
let capturedAuth: string | undefined;
let capturedApiKey: string | undefined;
globalThis.fetch = vi.fn(async (input: any, init: any) => {
capturedUrl = String(input);
const headers = new Headers(init?.headers);
capturedAuth = headers.get("authorization") ?? undefined;
capturedApiKey = headers.get("x-api-key") ?? undefined;
return {
ok: true,
status: 200,
json: async () => ({ data: [{ id: "gateway-model-1" }, { id: "gateway-model-2" }] }),
};
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("ok");
expect(capturedUrl.startsWith("https://gateway.internal.example")).toBe(true);
// Auth token must use the Authorization: Bearer header, not x-api-key.
expect(capturedAuth).toBe("Bearer token-from-settings");
expect(capturedApiKey == null).toBe(true);
expect(result.claude.models).toContain("gateway-model-1");
// Configured default model candidates are surfaced.
expect(result.claude.defaults).toContain("gateway-sonnet");
expect(result.claude.defaults).toContain("gateway-opus");
// The resolved token must never appear anywhere in the browser-visible result.
expect(JSON.stringify(result)).not.toContain("token-from-settings");
});
it("redacts a settings.json auth token from browser-visible errors", async () => {
writeClaudeSettings({
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
});
globalThis.fetch = vi.fn(async () => {
throw new Error("upstream failed for token-from-settings");
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("error");
expect(result.claude.error).toBeTruthy();
expect(JSON.stringify(result)).not.toContain("token-from-settings");
});
});
+386
View File
@@ -0,0 +1,386 @@
/**
* Executor model discovery.
*
* Discovers available models for each supported executor by querying the same
* binaries/configuration the CLI uses. Discovery failures are isolated per
* executor and never expose credentials to callers.
*/
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ExecutorConfig, ExecutorKind } from "./workflow-types.js";
import { EXECUTOR_KINDS } from "./workflow-types.js";
import { loadGlobalWorkflowConfig } from "./workflow-config.js";
/** Bounded discovery timeout so web requests cannot hang indefinitely. */
const DISCOVERY_TIMEOUT_MS = 3_000;
export interface ExecutorModelDiscovery {
status: "ok" | "error";
/** Discovered model identifiers (executor-specific). */
models?: string[];
/** Suggested default candidates used when discovery fails or is unavailable. */
defaults?: string[];
/** Human-readable failure message when status is "error". */
error?: string;
}
export interface ModelDiscoveryResult {
claude: ExecutorModelDiscovery;
agy: ExecutorModelDiscovery;
reasonix: ExecutorModelDiscovery;
/** Current global model overrides, if any. */
current: Partial<Record<ExecutorKind, string | null>>;
}
function getExecutorConfig(kind: ExecutorKind): ExecutorConfig {
const global = loadGlobalWorkflowConfig();
return (global?.executors?.[kind] as ExecutorConfig) ?? {};
}
function getBinary(config: ExecutorConfig, defaultBin: string): string {
return config.binary || defaultBin;
}
/** Anthropic connection settings resolved for Claude discovery. */
interface ClaudeSettings {
baseUrl: string;
/** Credential value, if any (api key or auth token). */
credential?: string;
/** Which authentication header to use for the resolved credential. */
authHeader?: "x-api-key" | "authorization";
/** Configured default model candidates from ANTHROPIC_DEFAULT_*_MODEL. */
defaultModels: string[];
/** Every resolved credential value that must be redacted from errors. */
secrets: string[];
}
/** Read ~/.claude/settings.json's `env` block, if present. */
function readClaudeSettingsEnv(): Record<string, string> {
const settingsPath = path.join(process.env.HOME || os.homedir(), ".claude", "settings.json");
try {
if (!fs.existsSync(settingsPath)) return {};
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object") return {};
const env = (parsed as Record<string, unknown>).env;
if (!env || typeof env !== "object") return {};
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
if (typeof value === "string") result[key] = value;
}
return result;
} catch {
return {};
}
}
/**
* Resolve Claude discovery settings from the running process environment first,
* then fall back to ~/.claude/settings.json (the same settings the CLI reads).
*/
function resolveClaudeSettings(): ClaudeSettings {
const settingsEnv = readClaudeSettingsEnv();
const pick = (key: string): string | undefined => {
const fromProcess = process.env[key]?.trim();
if (fromProcess) return fromProcess;
const fromSettings = settingsEnv[key]?.trim();
return fromSettings || undefined;
};
const rawBaseUrl = pick("ANTHROPIC_BASE_URL") || "https://api.anthropic.com";
const baseUrl = stripUrlCredentials(rawBaseUrl);
const apiKey = pick("ANTHROPIC_API_KEY");
const authToken = pick("ANTHROPIC_AUTH_TOKEN");
const secrets: string[] = [];
if (apiKey) secrets.push(apiKey);
if (authToken) secrets.push(authToken);
let credential: string | undefined;
let authHeader: "x-api-key" | "authorization" | undefined;
// Prefer an explicit API key; otherwise use the auth token as a bearer credential.
if (apiKey) {
credential = apiKey;
authHeader = "x-api-key";
} else if (authToken) {
credential = authToken;
authHeader = "authorization";
}
const defaultModels: string[] = [];
for (const key of ["ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL"]) {
const value = pick(key);
if (value && !defaultModels.includes(value)) defaultModels.push(value);
}
return { baseUrl, credential, authHeader, defaultModels, secrets };
}
function redactSecrets(message: string, secrets: string[]): string {
let result = message;
for (const secret of secrets) {
if (secret && secret.length > 0) {
result = result.split(secret).join("[redacted]");
}
}
return result;
}
function cleanError(err: unknown, secrets: string[] = []): string {
let message = err instanceof Error ? err.message : String(err);
message = message.replace(/\r?\n/g, " ");
// Never echo resolved credentials that may appear in fetch/network error text.
message = redactSecrets(message, secrets);
message = message.replace(/https?:\/\/[^/\s]*:[^/\s]*@/gi, "https://[redacted]@");
return message;
}
function runCommand(
command: string,
args: string[],
timeoutMs = DISCOVERY_TIMEOUT_MS,
): Promise<{ status: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
reject(new Error(`Timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
if (stdout.length > 1_000_000) stdout = stdout.slice(-1_000_000);
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
if (stderr.length > 1_000_000) stderr = stderr.slice(-1_000_000);
});
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(err);
});
child.on("close", (status) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ status, stdout, stderr });
});
});
}
function stripUrlCredentials(urlStr: string): string {
try {
const url = new URL(urlStr);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return urlStr;
}
}
function parseLineDelimitedModels(output: string): string[] {
return output
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
}
export function extractModelIds(data: unknown): string[] {
if (!data || typeof data !== "object") return [];
const candidates: string[] = [];
const obj = data as Record<string, unknown>;
if (Array.isArray(obj.data)) {
for (const item of obj.data) {
if (item && typeof item === "object" && typeof (item as Record<string, unknown>).id === "string") {
candidates.push((item as Record<string, unknown>).id as string);
}
}
}
if (candidates.length === 0 && Array.isArray(obj.models)) {
for (const item of obj.models) {
if (item && typeof item === "object" && typeof (item as Record<string, unknown>).id === "string") {
candidates.push((item as Record<string, unknown>).id as string);
} else if (typeof item === "string") {
candidates.push(item);
}
}
}
return candidates;
}
function extractReasonixProviders(data: unknown): string[] {
if (!data || typeof data !== "object") return [];
const obj = data as Record<string, unknown>;
const providers: string[] = [];
if (Array.isArray(obj.providers)) {
for (const provider of obj.providers) {
if (provider && typeof provider === "object" && typeof (provider as Record<string, unknown>).name === "string") {
providers.push((provider as Record<string, unknown>).name as string);
}
}
}
if (providers.length === 0 && Array.isArray(obj.models)) {
for (const item of obj.models) {
if (typeof item === "string") {
providers.push(item);
} else if (item && typeof item === "object" && typeof (item as Record<string, unknown>).provider === "string") {
providers.push((item as Record<string, unknown>).provider as string);
}
}
}
return [...new Set(providers)];
}
const CLAUDE_DEFAULTS = [
"claude-sonnet-4",
"claude-opus-4",
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet-20241022",
];
async function discoverClaudeModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
const settings = resolveClaudeSettings();
// Configured ANTHROPIC_DEFAULT_*_MODEL candidates take precedence over the built-in list.
const defaults = [...new Set([...settings.defaultModels, ...CLAUDE_DEFAULTS])];
// Without a resolved credential we cannot query the gateway; surface the fallback candidates.
if (!settings.credential || !settings.authHeader) {
return { status: "ok", models: defaults, defaults };
}
try {
const url = new URL("/v1/models", settings.baseUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
const headers: Record<string, string> = {
"anthropic-version": "2023-06-01",
"content-type": "application/json",
};
if (settings.authHeader === "x-api-key") {
headers["x-api-key"] = settings.credential;
} else {
headers["authorization"] = `Bearer ${settings.credential}`;
}
let res: Response;
try {
res = await fetch(url.toString(), { method: "GET", headers, signal: controller.signal });
} finally {
clearTimeout(timer);
}
if (!res.ok) {
throw new Error(`Anthropic API returned ${res.status}`);
}
const data = (await res.json()) as unknown;
const models = extractModelIds(data);
return { status: "ok", models: models.length > 0 ? models : defaults, defaults };
} catch (err) {
return { status: "error", error: cleanError(err, settings.secrets), defaults };
}
}
async function discoverAgyModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
const defaults: string[] = [];
const bin = getBinary(config, "agy");
try {
const result = await runCommand(bin, ["models"]);
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `agy models exited with code ${result.status ?? "unknown"}`);
}
const stdout = result.stdout?.trim() ?? "";
let models: string[] = [];
if (stdout.startsWith("{") || stdout.startsWith("[")) {
try {
const parsed = JSON.parse(stdout) as unknown;
models = extractModelIds(parsed);
if (models.length === 0 && Array.isArray(parsed)) {
models = parsed.filter((item): item is string => typeof item === "string");
}
} catch {
models = parseLineDelimitedModels(stdout);
}
} else {
models = parseLineDelimitedModels(stdout);
}
return { status: "ok", models: models.length > 0 ? models : defaults, defaults };
} catch (err) {
return { status: "error", error: cleanError(err), defaults };
}
}
async function discoverReasonixModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
const defaults: string[] = [];
const bin = getBinary(config, "reasonix");
try {
const result = await runCommand(bin, ["doctor", "--json"]);
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `reasonix doctor exited with code ${result.status ?? "unknown"}`);
}
let data: unknown;
try {
data = JSON.parse(result.stdout?.trim() ?? "{}");
} catch (err) {
throw new Error(`Failed to parse reasonix doctor output: ${cleanError(err)}`);
}
const providers = extractReasonixProviders(data);
return { status: "ok", models: providers.length > 0 ? providers : defaults, defaults };
} catch (err) {
return { status: "error", error: cleanError(err), defaults };
}
}
/** Discover available models for Claude, Agy, and Reasonix. */
export async function discoverExecutorModels(): Promise<ModelDiscoveryResult> {
const global = loadGlobalWorkflowConfig();
const current: Partial<Record<ExecutorKind, string | null>> = {};
for (const kind of EXECUTOR_KINDS) {
const model = global?.executors?.[kind]?.model;
current[kind] = model ?? null;
}
const configs = {
claude: getExecutorConfig("claude"),
agy: getExecutorConfig("agy"),
reasonix: getExecutorConfig("reasonix"),
};
const [claude, agy, reasonix] = await Promise.all([
discoverClaudeModels(configs.claude),
discoverAgyModels(configs.agy),
discoverReasonixModels(configs.reasonix),
]);
return { claude, agy, reasonix, current };
}
+481
View File
@@ -79,6 +79,210 @@ export function getAssetsHtml(): string {
gap: 10px;
}
.sidebar-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
width: 34px;
height: 34px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.icon-btn:hover {
color: var(--text);
border-color: var(--accent);
background-color: var(--bg-card);
}
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(7, 9, 16, 0.72);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.settings-overlay.open {
display: flex;
}
.settings-panel {
width: min(920px, 100%);
max-height: min(90vh, 900px);
overflow: auto;
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.settings-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 6px;
}
.settings-subtitle {
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.5;
}
.settings-body {
padding: 20px 24px 8px;
display: flex;
flex-direction: column;
gap: 16px;
}
.executor-settings-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.executor-settings-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.executor-settings-title h3 {
font-size: 1rem;
font-weight: 600;
}
.status-pill {
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 999px;
font-weight: 600;
white-space: nowrap;
}
.status-pill.ok {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.status-pill.error {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.status-pill.loading {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.settings-field label {
font-size: 0.85rem;
color: var(--text-muted);
}
.settings-field-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
}
.settings-help {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.45;
}
.settings-error {
font-size: 0.82rem;
color: #f87171;
margin-top: 4px;
}
.settings-footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
padding: 16px 24px 24px;
border-top: 1px solid var(--border);
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.primary-btn {
background: var(--accent);
border: 1px solid var(--accent);
color: white;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
}
.primary-btn:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.primary-btn:disabled,
.control-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.settings-message {
min-height: 1.2em;
font-size: 0.85rem;
color: var(--text-muted);
}
.settings-message.success {
color: #34d399;
}
.settings-message.error {
color: #f87171;
}
.sidebar-header h1 {
font-size: 1.25rem;
font-weight: 600;
@@ -502,6 +706,20 @@ export function getAssetsHtml(): string {
.control-group {
justify-content: space-between;
}
.settings-field-row {
grid-template-columns: 1fr;
}
.settings-footer {
flex-direction: column;
align-items: stretch;
}
.settings-actions {
width: 100%;
}
.settings-actions .control-btn,
.settings-actions .primary-btn {
flex: 1;
}
}
</style>
</head>
@@ -515,6 +733,14 @@ export function getAssetsHtml(): string {
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<h1>智能体执行监控</h1>
<div class="sidebar-header-actions">
<button id="open-settings-btn" class="icon-btn" title="全局模型配置" aria-label="打开全局模型配置">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
</div>
<div class="filters-section">
<input type="text" id="search-box" class="search-input" placeholder="搜索项目或运行记录...">
@@ -684,10 +910,94 @@ export function getAssetsHtml(): string {
</div>
</div>
<div id="settings-overlay" class="settings-overlay" aria-hidden="true">
<div class="settings-panel" role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="settings-header">
<div>
<h2 id="settings-title">全局执行器模型配置</h2>
<div class="settings-subtitle">分别配置 Claude、Agy、Reasonix 的全局模型。仅写入 ~/.agent-workflow/config.json 中的 executors.&lt;kind&gt;.model;项目配置与 CLI 仍可覆盖全局值。清空后恢复 CLI 默认选择。</div>
</div>
<button id="close-settings-btn" class="icon-btn" title="关闭" aria-label="关闭全局配置">✕</button>
</div>
<div class="settings-body">
<div class="executor-settings-card" data-executor="claude">
<div class="executor-settings-title">
<h3>Claude Code</h3>
<span class="status-pill loading" id="status-claude">加载中</span>
</div>
<div class="settings-field">
<label for="model-select-claude">已发现模型</label>
<div class="settings-field-row">
<select id="model-select-claude" class="select-input"></select>
<button class="control-btn" type="button" onclick="applyDiscoveredModel('claude')">填入</button>
</div>
</div>
<div class="settings-field">
<label for="model-input-claude">手动输入模型</label>
<input id="model-input-claude" class="search-input" type="text" placeholder="例如 claude-sonnet-4 或留空使用默认">
<div class="settings-help">支持从候选列表选择,也可始终手动输入任意非空模型标识。</div>
<div class="settings-error" id="error-claude"></div>
</div>
</div>
<div class="executor-settings-card" data-executor="agy">
<div class="executor-settings-title">
<h3>Agy</h3>
<span class="status-pill loading" id="status-agy">加载中</span>
</div>
<div class="settings-field">
<label for="model-select-agy">已发现模型</label>
<div class="settings-field-row">
<select id="model-select-agy" class="select-input"></select>
<button class="control-btn" type="button" onclick="applyDiscoveredModel('agy')">填入</button>
</div>
</div>
<div class="settings-field">
<label for="model-input-agy">手动输入模型</label>
<input id="model-input-agy" class="search-input" type="text" placeholder="例如 gemini-2.5-pro 或留空使用默认">
<div class="settings-help">通过本地 agy models 发现候选;发现失败时仍可手动输入。</div>
<div class="settings-error" id="error-agy"></div>
</div>
</div>
<div class="executor-settings-card" data-executor="reasonix">
<div class="executor-settings-title">
<h3>Reasonix</h3>
<span class="status-pill loading" id="status-reasonix">加载中</span>
</div>
<div class="settings-field">
<label for="model-select-reasonix">已发现模型</label>
<div class="settings-field-row">
<select id="model-select-reasonix" class="select-input"></select>
<button class="control-btn" type="button" onclick="applyDiscoveredModel('reasonix')">填入</button>
</div>
</div>
<div class="settings-field">
<label for="model-input-reasonix">手动输入模型</label>
<input id="model-input-reasonix" class="search-input" type="text" placeholder="例如 anthropic 或留空使用默认">
<div class="settings-help">Reasonix 使用 provider 名称作为 --model;通过 reasonix doctor --json 发现。</div>
<div class="settings-error" id="error-reasonix"></div>
</div>
</div>
</div>
<div class="settings-footer">
<div id="settings-message" class="settings-message"></div>
<div class="settings-actions">
<button id="refresh-models-btn" class="control-btn" type="button">刷新发现</button>
<button id="clear-models-btn" class="control-btn" type="button">清空为默认</button>
<button id="save-models-btn" class="primary-btn" type="button">保存配置</button>
</div>
</div>
</div>
</div>
<script>
let runs = [];
let activeRun = null;
let sseConn = null;
let settingsState = null;
let settingsLoading = false;
let settingsSaving = false;
// Log state
let selectedCycle = 1;
@@ -1435,6 +1745,177 @@ export function getAssetsHtml(): string {
document.getElementById('executor-filter').addEventListener('change', renderRuns);
document.getElementById('status-filter').addEventListener('change', renderRuns);
const EXECUTOR_KINDS_UI = ['claude', 'agy', 'reasonix'];
const EXECUTOR_LABELS = {
claude: 'Claude Code',
agy: 'Agy',
reasonix: 'Reasonix',
};
function setSettingsMessage(text, kind) {
const el = document.getElementById('settings-message');
el.textContent = text || '';
el.className = 'settings-message' + (kind ? ' ' + kind : '');
}
function setSettingsBusy(busy) {
document.getElementById('refresh-models-btn').disabled = busy;
document.getElementById('clear-models-btn').disabled = busy;
document.getElementById('save-models-btn').disabled = busy;
}
function openSettings() {
const overlay = document.getElementById('settings-overlay');
overlay.classList.add('open');
overlay.setAttribute('aria-hidden', 'false');
loadModelSettings(false);
}
function closeSettings() {
const overlay = document.getElementById('settings-overlay');
overlay.classList.remove('open');
overlay.setAttribute('aria-hidden', 'true');
}
function applyDiscoveredModel(kind) {
const select = document.getElementById('model-select-' + kind);
const input = document.getElementById('model-input-' + kind);
if (select && input && select.value) {
input.value = select.value;
}
}
function renderExecutorSettings(kind, discovery) {
const select = document.getElementById('model-select-' + kind);
const input = document.getElementById('model-input-' + kind);
const status = document.getElementById('status-' + kind);
const error = document.getElementById('error-' + kind);
const current = (settingsState && settingsState.current && settingsState.current[kind]) || '';
const models = Array.isArray(discovery && discovery.models) ? discovery.models : [];
const defaults = Array.isArray(discovery && discovery.defaults) ? discovery.defaults : [];
const candidates = [];
const seen = new Set();
[...models, ...defaults].forEach((model) => {
if (typeof model === 'string' && model.trim() && !seen.has(model)) {
seen.add(model);
candidates.push(model);
}
});
if (current && !seen.has(current)) {
candidates.unshift(current);
}
select.innerHTML = '';
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = candidates.length ? '选择已发现模型…' : '暂无候选,请手动输入';
select.appendChild(emptyOpt);
candidates.forEach((model) => {
const opt = document.createElement('option');
opt.value = model;
opt.textContent = model;
select.appendChild(opt);
});
if (current && seen.has(current)) {
select.value = current;
}
input.value = current || '';
if (discovery && discovery.status === 'error') {
status.textContent = '发现失败';
status.className = 'status-pill error';
error.textContent = discovery.error || '模型发现失败';
} else {
status.textContent = '就绪';
status.className = 'status-pill ok';
error.textContent = '';
}
}
async function loadModelSettings(silent) {
if (settingsLoading) return;
settingsLoading = true;
setSettingsBusy(true);
if (!silent) setSettingsMessage('正在发现可用模型…');
EXECUTOR_KINDS_UI.forEach((kind) => {
const status = document.getElementById('status-' + kind);
status.textContent = '加载中';
status.className = 'status-pill loading';
document.getElementById('error-' + kind).textContent = '';
});
try {
const res = await fetch('/api/config/models');
if (!res.ok) {
throw new Error(await res.text() || ('HTTP ' + res.status));
}
settingsState = await res.json();
EXECUTOR_KINDS_UI.forEach((kind) => renderExecutorSettings(kind, settingsState[kind]));
if (!silent) setSettingsMessage('模型列表已刷新', 'success');
} catch (err) {
EXECUTOR_KINDS_UI.forEach((kind) => {
const status = document.getElementById('status-' + kind);
status.textContent = '发现失败';
status.className = 'status-pill error';
document.getElementById('error-' + kind).textContent = err.message || String(err);
});
setSettingsMessage('刷新失败:' + (err.message || String(err)), 'error');
} finally {
settingsLoading = false;
setSettingsBusy(false);
}
}
function collectModelPayload(clearAll) {
const payload = {};
EXECUTOR_KINDS_UI.forEach((kind) => {
if (clearAll) {
payload[kind] = null;
return;
}
const value = document.getElementById('model-input-' + kind).value.trim();
payload[kind] = value ? value : null;
});
return payload;
}
async function saveModelSettings(clearAll) {
if (settingsSaving) return;
settingsSaving = true;
setSettingsBusy(true);
setSettingsMessage(clearAll ? '正在清空全局模型覆盖…' : '正在保存全局模型配置…');
try {
const payload = collectModelPayload(clearAll);
const res = await fetch('/api/config/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(await res.text() || ('HTTP ' + res.status));
}
settingsState = await res.json();
EXECUTOR_KINDS_UI.forEach((kind) => renderExecutorSettings(kind, settingsState[kind]));
setSettingsMessage(clearAll ? '已清空全局模型覆盖,后续周期/重试将按 CLI 默认解析。' : '已保存。活动执行器不会中断;后续周期、范围修复与重试会重新加载最新模型。', 'success');
} catch (err) {
setSettingsMessage('保存失败:' + (err.message || String(err)), 'error');
} finally {
settingsSaving = false;
setSettingsBusy(false);
}
}
document.getElementById('open-settings-btn').addEventListener('click', openSettings);
document.getElementById('close-settings-btn').addEventListener('click', closeSettings);
document.getElementById('settings-overlay').addEventListener('click', (event) => {
if (event.target === event.currentTarget) closeSettings();
});
document.getElementById('refresh-models-btn').addEventListener('click', () => loadModelSettings(false));
document.getElementById('clear-models-btn').addEventListener('click', () => saveModelSettings(true));
document.getElementById('save-models-btn').addEventListener('click', () => saveModelSettings(false));
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeSettings();
});
// Initial load
loadRuns();
// Poll for list updates periodically
+190 -9
View File
@@ -12,21 +12,23 @@ import type { WorkflowState } from "./workflow-types.js";
function makeRequest(
url: string,
method = "GET",
headers: Record<string, string> = { "Host": "127.0.0.1:14319" }
headers: Record<string, string> = { "Host": "127.0.0.1:14319" },
body?: string,
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(url, { method, headers }, (res) => {
let body = "";
res.on("data", (chunk) => { body += chunk; });
let responseBody = "";
res.on("data", (chunk) => { responseBody += chunk; });
res.on("end", () => {
resolve({
status: res.statusCode || 0,
headers: res.headers,
body,
body: responseBody,
});
});
});
req.on("error", reject);
if (body !== undefined) req.write(body);
req.end();
});
}
@@ -165,6 +167,172 @@ describe("workflow-web", () => {
expect(res.headers["allow"]).toBe("GET, HEAD");
});
it("serves Chinese global model settings UI assets", async () => {
const res = await makeRequest(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(res.body).toContain("全局执行器模型配置");
expect(res.body).toContain("open-settings-btn");
expect(res.body).toContain("/api/config/models");
expect(res.body).toContain("手动输入模型");
});
it("serves model discovery config endpoint", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`);
expect(res.status).toBe(200);
const payload = JSON.parse(res.body);
expect(payload).toHaveProperty("claude");
expect(payload).toHaveProperty("agy");
expect(payload).toHaveProperty("reasonix");
expect(payload).toHaveProperty("current");
}, 15_000);
it("rejects config mutation without same-origin origin header", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ claude: "claude-sonnet-4" }));
expect(res.status).toBe(403);
});
it("rejects config mutation with non-json content type", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "text/plain",
}, "claude=claude-sonnet-4");
expect(res.status).toBe(415);
});
it("rejects invalid model values on config mutation", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ claude: " " }));
expect(res.status).toBe(400);
});
it("rejects config mutation from a different localhost port (same-origin enforced)", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:9999",
"Content-Type": "application/json",
}, JSON.stringify({ claude: "claude-sonnet-4" }));
expect(res.status).toBe(403);
});
it("rejects config mutation with HTTPS Origin against the HTTP server", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "https://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ claude: "claude-sonnet-4" }));
expect(res.status).toBe(403);
});
it("rejects an array payload on config mutation", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify(["claude-sonnet-4"]));
expect(res.status).toBe(400);
});
it("rejects unknown top-level executor keys on config mutation", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ gpt: "some-model" }));
expect(res.status).toBe(400);
});
it("rejects model identifiers with control characters", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ claude: "claude\nsonnet" }));
expect(res.status).toBe(400);
});
it("rejects excessively long model identifiers", async () => {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ claude: "x".repeat(500) }));
expect(res.status).toBe(400);
});
it("accepts legitimate model values with spaces and punctuation", async () => {
const previousHome = process.env.HOME;
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-model-ok-"));
process.env.HOME = tmpHome;
try {
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ agy: "Claude Sonnet 4.6 (Thinking)" }));
expect(res.status).toBe(200);
const saved = JSON.parse(res.body);
expect(saved.current.agy).toBe("Claude Sonnet 4.6 (Thinking)");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
}
}, 15_000);
it("returns an observable 413 for oversized bodies instead of resetting the socket", async () => {
const oversized = JSON.stringify({ claude: "x".repeat(70_020) });
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, oversized);
expect(res.status).toBe(413);
});
it("saves and clears global model overrides through the API", async () => {
const previousHome = process.env.HOME;
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-config-"));
process.env.HOME = tmpHome;
try {
const saveRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ claude: "claude-sonnet-4", reasonix: "anthropic" }));
expect(saveRes.status).toBe(200);
const saved = JSON.parse(saveRes.body);
expect(saved.current.claude).toBe("claude-sonnet-4");
expect(saved.current.reasonix).toBe("anthropic");
const configPath = path.join(tmpHome, ".agent-workflow", "config.json");
expect(fs.existsSync(configPath)).toBe(true);
const onDisk = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(onDisk.executors.claude.model).toBe("claude-sonnet-4");
expect(onDisk.executors.reasonix.model).toBe("anthropic");
const clearRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json",
}, JSON.stringify({ claude: null, reasonix: null }));
expect(clearRes.status).toBe(200);
const cleared = JSON.parse(clearRes.body);
expect(cleared.current.claude).toBeNull();
expect(cleared.current.reasonix).toBeNull();
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
}
}, 15_000);
it("supports HEAD request", async () => {
const res = await makeRequest(`${baseUrl}/api/runs`, "HEAD");
expect(res.status).toBe(200);
@@ -237,17 +405,30 @@ describe("workflow-web", () => {
// Test that the script can be evaluated without SyntaxError
const vm = require("node:vm");
const script = new vm.Script(scriptContent);
const sandbox = {
const el = () => ({
style: {},
classList: { add() {}, remove() {}, contains() { return false; } },
addEventListener() {},
setAttribute() {},
getAttribute() { return null; },
textContent: "",
value: "",
innerHTML: "",
disabled: false,
appendChild() {},
});
const sandbox: any = {
window: {},
document: {
getElementById: () => ({ style: {}, addEventListener: () => {} }),
getElementById: () => el(),
querySelectorAll: () => ([]),
createElement: () => ({ style: {} }),
createElement: () => el(),
addEventListener() {},
},
fetch: async () => ({ json: async () => ({}) }),
fetch: async () => ({ ok: true, json: async () => ({}), text: async () => "" }),
EventSource: class { addEventListener() {} },
setInterval: () => {},
console: { log: () => {}, error: () => {} }
console: { log: () => {}, error: () => {} },
};
vm.createContext(sandbox);
+179 -4
View File
@@ -11,6 +11,9 @@ import {
readState
} from "./workflow-state.js";
import { getAssetsHtml } from "./workflow-web-assets.js";
import { discoverExecutorModels } from "./workflow-model-discovery.js";
import { saveGlobalExecutorModels, WorkflowConfigError } from "./workflow-config.js";
import { EXECUTOR_KINDS, type ExecutorKind } from "./workflow-types.js";
// Helper to write JSON responses
function sendJson(res: http.ServerResponse, status: number, obj: any) {
@@ -32,6 +35,118 @@ function sendError(res: http.ServerResponse, status: number, message: string) {
res.end(message);
}
const MAX_CONFIG_BODY_BYTES = 64 * 1024;
class RequestBodyTooLargeError extends Error {
constructor() {
super("Request body too large");
this.name = "RequestBodyTooLargeError";
}
}
function readBody(req: http.IncomingMessage, limit: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let received = 0;
let tooLarge = false;
req.on("data", (chunk: Buffer) => {
if (tooLarge) return; // Keep draining without buffering so the socket can be reused.
received += chunk.length;
if (received > limit) {
tooLarge = true;
// Do NOT destroy the socket here; draining lets the caller send an
// observable 413 response instead of resetting the connection.
chunks.length = 0;
req.resume();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (tooLarge) return reject(new RequestBodyTooLargeError());
resolve(Buffer.concat(chunks));
});
req.on("error", (err) => reject(err));
});
}
/**
* True same-origin check for mutations: the Origin must be plain HTTP (the
* server only serves http://127.0.0.1), and its hostname and port must match
* the request Host. HTTPS origins are rejected even when host/port match.
*/
function isSameLocalOrigin(origin: unknown, host: string): boolean {
if (typeof origin !== "string" || origin.length === 0) return false;
let originUrl: URL;
try {
originUrl = new URL(origin);
} catch {
return false;
}
// startWorkflowWebServer serves plain HTTP only — never accept https:// origins.
if (originUrl.protocol !== "http:") return false;
if (!/^(localhost|127\.0\.0\.1)$/i.test(originUrl.hostname)) return false;
// Compare against the request Host using the server's actual scheme (http).
let hostUrl: URL;
try {
hostUrl = new URL(`http://${host}`);
} catch {
return false;
}
const originPort = originUrl.port || "80";
const hostPort = hostUrl.port || "80";
return originUrl.hostname.toLowerCase() === hostUrl.hostname.toLowerCase() && originPort === hostPort;
}
/** Maximum accepted length of a model identifier. */
const MAX_MODEL_ID_LENGTH = 200;
function validateConfigModelsPayload(raw: unknown): Partial<Record<ExecutorKind, string | null>> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error("Request body must be a JSON object");
}
const obj = raw as Record<string, unknown>;
// Reject unknown top-level keys so malformed payloads never reach the config writer.
for (const key of Object.keys(obj)) {
if (!(EXECUTOR_KINDS as readonly string[]).includes(key)) {
throw new Error(`Unknown executor key: "${key}"`);
}
}
const result: Partial<Record<ExecutorKind, string | null>> = {};
for (const kind of EXECUTOR_KINDS) {
const value = obj[kind];
if (value === undefined) continue;
if (value === null) {
result[kind] = null;
continue;
}
if (typeof value !== "string") {
throw new Error(`Executor "${kind}" model must be a non-empty string or null`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`Executor "${kind}" model must be a non-empty string or null`);
}
if (trimmed.length > MAX_MODEL_ID_LENGTH) {
throw new Error(`Executor "${kind}" model is too long (max ${MAX_MODEL_ID_LENGTH} characters)`);
}
// Reject control characters (including newlines/tabs) while allowing spaces
// and punctuation that legitimate model identifiers may contain.
if (/[\u0000-\u001F\u007F]/.test(trimmed)) {
throw new Error(`Executor "${kind}" model contains invalid control characters`);
}
result[kind] = trimmed;
}
return result;
}
export function splitCompleteUtf8(buffer: Buffer): { complete: Buffer; residual: Buffer } {
const len = buffer.length;
if (len === 0) return { complete: buffer, residual: Buffer.alloc(0) };
@@ -450,13 +565,26 @@ export function startWorkflowWebServer(port = 4317): Promise<http.Server> {
}
const method = req.method;
if (method !== "GET" && method !== "HEAD") {
res.writeHead(405, { "Allow": "GET, HEAD" });
const parsedUrl = new URL(req.url || "", `http://${host}`);
const pathname = parsedUrl.pathname;
const isMutationPath = pathname === "/api/config/models";
if (method !== "GET" && method !== "HEAD" && !(isMutationPath && method === "POST")) {
res.writeHead(405, { "Allow": "GET, HEAD" + (isMutationPath ? ", POST" : "") });
return res.end("Method Not Allowed");
}
const parsedUrl = new URL(req.url || "", `http://${host}`);
const pathname = parsedUrl.pathname;
if (method === "POST") {
// Mutation endpoint: require a true same-origin localhost request (scheme,
// hostname, and port must match the request Host), JSON content type, and bounded body.
if (!isSameLocalOrigin(origin, host)) {
return sendError(res, 403, "Forbidden: Invalid Origin");
}
const contentType = req.headers["content-type"] || "";
if (!contentType.toLowerCase().startsWith("application/json")) {
return sendError(res, 415, "Unsupported Media Type: application/json required");
}
}
if (pathname === "/" || pathname === "/index.html") {
if (method === "HEAD") {
@@ -646,6 +774,53 @@ export function startWorkflowWebServer(port = 4317): Promise<http.Server> {
return handleSseStream(req, res, runId);
}
if (pathname === "/api/config/models") {
if (method === "GET" || method === "HEAD") {
if (method === "HEAD") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end();
}
return void discoverExecutorModels()
.then((result) => sendJson(res, 200, result))
.catch((err) => sendError(res, 500, `Failed to discover models: ${(err as Error).message}`));
}
if (method === "POST") {
return void readBody(req, MAX_CONFIG_BODY_BYTES)
.then((body) => {
let parsed: unknown;
try {
parsed = JSON.parse(body.toString("utf8") || "{}");
} catch {
return sendError(res, 400, "Bad Request: Invalid JSON body");
}
let models: Partial<Record<ExecutorKind, string | null>>;
try {
models = validateConfigModelsPayload(parsed);
} catch (err) {
return sendError(res, 400, `Bad Request: ${(err as Error).message}`);
}
try {
saveGlobalExecutorModels(models);
} catch (err) {
if (err instanceof WorkflowConfigError) {
return sendError(res, 400, err.message);
}
return sendError(res, 500, `Failed to save config: ${(err as Error).message}`);
}
return discoverExecutorModels()
.then((result) => sendJson(res, 200, result))
.catch((err) => sendError(res, 500, `Failed to discover models: ${(err as Error).message}`));
})
.catch((err) => {
if (err instanceof RequestBodyTooLargeError || (err as Error).message === "Request body too large") {
return sendError(res, 413, "Payload Too Large");
}
return sendError(res, 400, `Bad Request: ${(err as Error).message}`);
});
}
}
return sendError(res, 404, "Not Found");
});