feat: add web executor model configuration
This commit is contained in:
@@ -174,15 +174,16 @@ agent-workflow logs --run <run-id> --tail 200
|
|||||||
|
|
||||||
### `agent-workflow web`
|
### `agent-workflow web`
|
||||||
|
|
||||||
Start a local read-only web server to monitor agent executions, runs, cycles, and logs in real-time.
|
Start a local web console to monitor agent executions, runs, cycles, and logs in real-time, and to manage host-wide executor model overrides.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-workflow web [--port <n>]
|
agent-workflow web [--port <n>]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`--port <n>`** — specify a custom port to listen on (default: `4317`).
|
- **`--port <n>`** — specify a custom port to listen on (default: `4317`).
|
||||||
- **Security & Scope** — The server binds exclusively to the loopback interface (`127.0.0.1`) for local-only access. It refuses any external or remote listener configurations. Since executor logs can contain sensitive code, configuration details, or tokens/credentials, binding to `127.0.0.1` ensures that the console remains local.
|
- **Security & Scope** — The server binds exclusively to the loopback interface (`127.0.0.1`) for local-only access. It refuses any external or remote listener configurations. Since executor logs can contain sensitive code, configuration details, or tokens/credentials, binding to `127.0.0.1` ensures that the console remains local. Mutation endpoints require same-origin localhost requests, JSON content type, bounded bodies, and validated model values. Credentials are never returned to the browser.
|
||||||
- **Observability Boundaries & Limits** — The console is completely read-only and provides no endpoints to modify or control workflows. It directly parses and displays the execution progress, cycles, timeline, and stdout/stderr output. It cannot recover or display private reasoning tokens/processes of underlying models that are not output to the console.
|
- **Workflow monitoring remains read-only** — The console provides no endpoints to start, stop, decide, or otherwise control workflow runs. It parses and displays execution progress, cycles, timeline, and stdout/stderr output. It cannot recover or display private reasoning tokens/processes of underlying models that are not output to the console.
|
||||||
|
- **Writable global model settings** — The Chinese “全局执行器模型配置” view can independently set Claude, Agy, and Reasonix models. Discovery uses the current CLI environment: Claude via Anthropic `/v1/models` (with configured-default fallback), Agy via `agy models`, and Reasonix via `reasonix doctor --json` provider names. Discovery failures are isolated per executor, and manual entry always remains available. Saving atomically updates only `executors.<kind>.model` in `~/.agent-workflow/config.json`, preserves unrelated fields, and clears an override when the value is emptied. Active executors are not interrupted; saved models take effect on later cycles, scope remediation, extensions, and executor retries through the existing reload path. Project configuration continues to override global values.
|
||||||
- **Interaction with `logs --follow`** — The web server directly reads the persisted run files without taking any logs follow locks or interfering with the main executor loop, allowing you to use both the CLI `logs --follow` and the web console concurrently.
|
- **Interaction with `logs --follow`** — The web server directly reads the persisted run files without taking any logs follow locks or interfering with the main executor loop, allowing you to use both the CLI `logs --follow` and the web console concurrently.
|
||||||
|
|
||||||
### `agent-workflow abort`
|
### `agent-workflow abort`
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ Executor has been running for ~2m. Current activity: Editing src/parser.ts (PID
|
|||||||
|
|
||||||
Start that `logs --follow` command at most once for a given Run in the current Codex thread. If it reports that a follower is already active, do not retry the same command; use `agent-workflow status --run <run-id> --json` for progress instead.
|
Start that `logs --follow` command at most once for a given Run in the current Codex thread. If it reports that a follower is already active, do not retry the same command; use `agent-workflow status --run <run-id> --json` for progress instead.
|
||||||
|
|
||||||
The `agent-workflow web` command starts a local read-only web server that reads workflow state files without taking follower locks or interrupting the main execution loop. It only binds to the local loopback interface (127.0.0.1) for safety, as logs can contain sensitive code and environment details. The web interface displays status, execution events, cycle summaries, and live log updates. The console displays the CLI's actual stdout/stderr outputs and parsed structured activity; it cannot reconstruct unlogged private model reasoning.
|
The `agent-workflow web` command starts a local web server that reads workflow state files without taking follower locks or interrupting the main execution loop. Workflow monitoring remains read-only: it displays status, execution events, cycle summaries, and live log updates, and cannot reconstruct unlogged private model reasoning. The console also exposes a Chinese global model settings view that can write only `executors.<kind>.model` overrides in `~/.agent-workflow/config.json`. Discovery uses each executor's current CLI environment, isolates failures per executor, never returns credentials, and always allows manual model entry. Project configuration can still override global values, and saved models take effect on later cycles/retries without restarting active executors. The server binds only to 127.0.0.1.
|
||||||
|
|
||||||
`timeoutSeconds` is a consecutive inactivity timeout. Observable executor output refreshes the deadline, so do not treat total runtime beyond that value as a timeout while activity continues.
|
`timeoutSeconds` is a consecutive inactivity timeout. Observable executor output refreshes the deadline, so do not treat total runtime beyond that value as a timeout while activity continues.
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,65 @@ export function loadGlobalWorkflowConfig(): WorkflowProjectConfig | undefined {
|
|||||||
return validateProjectConfig(raw, filePath);
|
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 {
|
function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectConfig {
|
||||||
if (!raw || typeof raw !== "object") {
|
if (!raw || typeof raw !== "object") {
|
||||||
throw new WorkflowConfigError(`${filePath}: must be a JSON object`);
|
throw new WorkflowConfigError(`${filePath}: must be a JSON object`);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -79,6 +79,210 @@ export function getAssetsHtml(): string {
|
|||||||
gap: 10px;
|
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 {
|
.sidebar-header h1 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -502,6 +706,20 @@ export function getAssetsHtml(): string {
|
|||||||
.control-group {
|
.control-group {
|
||||||
justify-content: space-between;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -515,6 +733,14 @@ export function getAssetsHtml(): string {
|
|||||||
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||||
</svg>
|
</svg>
|
||||||
<h1>智能体执行监控</h1>
|
<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>
|
||||||
<div class="filters-section">
|
<div class="filters-section">
|
||||||
<input type="text" id="search-box" class="search-input" placeholder="搜索项目或运行记录...">
|
<input type="text" id="search-box" class="search-input" placeholder="搜索项目或运行记录...">
|
||||||
@@ -684,10 +910,94 @@ export function getAssetsHtml(): string {
|
|||||||
</div>
|
</div>
|
||||||
</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.<kind>.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>
|
<script>
|
||||||
let runs = [];
|
let runs = [];
|
||||||
let activeRun = null;
|
let activeRun = null;
|
||||||
let sseConn = null;
|
let sseConn = null;
|
||||||
|
let settingsState = null;
|
||||||
|
let settingsLoading = false;
|
||||||
|
let settingsSaving = false;
|
||||||
|
|
||||||
// Log state
|
// Log state
|
||||||
let selectedCycle = 1;
|
let selectedCycle = 1;
|
||||||
@@ -1435,6 +1745,177 @@ export function getAssetsHtml(): string {
|
|||||||
document.getElementById('executor-filter').addEventListener('change', renderRuns);
|
document.getElementById('executor-filter').addEventListener('change', renderRuns);
|
||||||
document.getElementById('status-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
|
// Initial load
|
||||||
loadRuns();
|
loadRuns();
|
||||||
// Poll for list updates periodically
|
// Poll for list updates periodically
|
||||||
|
|||||||
+190
-9
@@ -12,21 +12,23 @@ import type { WorkflowState } from "./workflow-types.js";
|
|||||||
function makeRequest(
|
function makeRequest(
|
||||||
url: string,
|
url: string,
|
||||||
method = "GET",
|
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 }> {
|
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = http.request(url, { method, headers }, (res) => {
|
const req = http.request(url, { method, headers }, (res) => {
|
||||||
let body = "";
|
let responseBody = "";
|
||||||
res.on("data", (chunk) => { body += chunk; });
|
res.on("data", (chunk) => { responseBody += chunk; });
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
resolve({
|
resolve({
|
||||||
status: res.statusCode || 0,
|
status: res.statusCode || 0,
|
||||||
headers: res.headers,
|
headers: res.headers,
|
||||||
body,
|
body: responseBody,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
|
if (body !== undefined) req.write(body);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,172 @@ describe("workflow-web", () => {
|
|||||||
expect(res.headers["allow"]).toBe("GET, HEAD");
|
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 () => {
|
it("supports HEAD request", async () => {
|
||||||
const res = await makeRequest(`${baseUrl}/api/runs`, "HEAD");
|
const res = await makeRequest(`${baseUrl}/api/runs`, "HEAD");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -237,17 +405,30 @@ describe("workflow-web", () => {
|
|||||||
// Test that the script can be evaluated without SyntaxError
|
// Test that the script can be evaluated without SyntaxError
|
||||||
const vm = require("node:vm");
|
const vm = require("node:vm");
|
||||||
const script = new vm.Script(scriptContent);
|
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: {},
|
window: {},
|
||||||
document: {
|
document: {
|
||||||
getElementById: () => ({ style: {}, addEventListener: () => {} }),
|
getElementById: () => el(),
|
||||||
querySelectorAll: () => ([]),
|
querySelectorAll: () => ([]),
|
||||||
createElement: () => ({ style: {} }),
|
createElement: () => el(),
|
||||||
|
addEventListener() {},
|
||||||
},
|
},
|
||||||
fetch: async () => ({ json: async () => ({}) }),
|
fetch: async () => ({ ok: true, json: async () => ({}), text: async () => "" }),
|
||||||
EventSource: class { addEventListener() {} },
|
EventSource: class { addEventListener() {} },
|
||||||
setInterval: () => {},
|
setInterval: () => {},
|
||||||
console: { log: () => {}, error: () => {} }
|
console: { log: () => {}, error: () => {} },
|
||||||
};
|
};
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
|
|
||||||
|
|||||||
+179
-4
@@ -11,6 +11,9 @@ import {
|
|||||||
readState
|
readState
|
||||||
} from "./workflow-state.js";
|
} from "./workflow-state.js";
|
||||||
import { getAssetsHtml } from "./workflow-web-assets.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
|
// Helper to write JSON responses
|
||||||
function sendJson(res: http.ServerResponse, status: number, obj: any) {
|
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);
|
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 } {
|
export function splitCompleteUtf8(buffer: Buffer): { complete: Buffer; residual: Buffer } {
|
||||||
const len = buffer.length;
|
const len = buffer.length;
|
||||||
if (len === 0) return { complete: buffer, residual: Buffer.alloc(0) };
|
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;
|
const method = req.method;
|
||||||
if (method !== "GET" && method !== "HEAD") {
|
const parsedUrl = new URL(req.url || "", `http://${host}`);
|
||||||
res.writeHead(405, { "Allow": "GET, HEAD" });
|
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");
|
return res.end("Method Not Allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedUrl = new URL(req.url || "", `http://${host}`);
|
if (method === "POST") {
|
||||||
const pathname = parsedUrl.pathname;
|
// 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 (pathname === "/" || pathname === "/index.html") {
|
||||||
if (method === "HEAD") {
|
if (method === "HEAD") {
|
||||||
@@ -646,6 +774,53 @@ export function startWorkflowWebServer(port = 4317): Promise<http.Server> {
|
|||||||
return handleSseStream(req, res, runId);
|
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");
|
return sendError(res, 404, "Not Found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user