feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
@@ -0,0 +1,41 @@
# Fixture: AAO Gateway server enabled, with a backend pool + virtual keys.
# v1 layout (no config_version key). Tests that the gateway.* block passes
# through untouched (it's already first-class in v1 and v2) and that the
# worker side still gets a v2 llm block from provider.*.
provider:
model: qwen3:8b
base_url: http://localhost:11434/v1
metrics:
enabled: true
prefix: aao_worker
bearer_token: ${AAO_WORKER_METRICS_BEARER_TOKEN}
allowed_hosts:
- 127.0.0.1
- ::1
gateway:
enabled: true
listen_port: 4000
request_timeout_sec: 600
upstream_timeout_sec: 30
shutdown_graceful_sec: 30
backends:
- id: gpu-rtx-a
endpoint: http://10.0.0.10:11434/v1
model: qwen3:8b
max_slots: 4
api_key: ${GPU_RTX_A_KEY}
- id: gpu-rtx-b
endpoint: http://10.0.0.10:11434/v1
model: qwen3:8b
max_slots: 4
virtual_keys:
- key: ${TEAM_A_GATEWAY_KEY}
team: team-a
tokens_budget: 500000
rate_limit_rpm: 60
metrics:
enabled: true
prefix: aao_gateway
bearer_token: ${AAO_GATEWAY_METRICS_BEARER_TOKEN}
@@ -0,0 +1,29 @@
# Fixture: deployment with MCP servers + SSH subsystem enabled, plus user
# folder customisations. Tests that:
# - mcp.* and ssh.* pass through untouched
# - user_folder_root migrates into storage.user_folder_root
# - tools.knowledge_namespaces[*].api_key keeps ${ENV} reference literal
provider:
model: qwen3:32b
base_url: http://localhost:11434/v1
worktree_dir: /var/lib/agent-orchestrator/workspaces
user_folder_root: /opt/aao/data/users
tools:
knowledge_service_url: http://dks-server:8100
knowledge_namespaces:
eng:
api_key: ${DKS_ENG_KEY}
ops:
api_key: ${DKS_OPS_KEY}
task_upload_max_size_mb: 100
mcp:
enabled: true
call_timeout_seconds: 30
ssh:
enabled: true
master_key_path: ./data/secrets/ssh-master.key
@@ -0,0 +1,42 @@
# Fixture: Phase 3c dogfooding shape — three workers, one of them an
# LLM gateway (LiteLLM Proxy / AAO Gateway) using a per-team API key
# carried via ${ENV} reference. Tests:
# - per-worker model override (gpu2 overrides provider.model)
# - default model fallback (gpu1 inherits provider.model)
# - proxy:true → connection_type: aao_gateway
# - ${VAR} reference preserved literally in api_key
# - roles[] preserved (gpu3 reflection-only)
provider:
model: qwen3:32b
timeout_minutes: 15
workers:
- id: gpu1
endpoint: http://10.0.0.10:11434/v1
enabled: true
max_concurrency: 2
roles: [auto, fast]
- id: gpu2
endpoint: http://10.0.0.10:11434/v1
model: qwen3:14b
max_concurrency: 1
roles: [auto, quality]
vlm: true
- id: team-pool
endpoint: http://litellm-proxy:4000/v1
proxy: true
proxy_type: litellm
api_key: ${TEAM_A_LITELLM_KEY}
model: qwen3:8b
max_concurrency: 4
roles: [quality]
- id: gpu-reflection
endpoint: http://10.0.0.10:11434/v1
model: qwen3:8b
max_concurrency: 1
roles: [reflection]
worktree_dir: /var/lib/agent-orchestrator/workspaces
@@ -0,0 +1,19 @@
# Fixture: most common single-Ollama deployment. v1 layout.
# Used by src/config-normalize.test.ts to lock the migration shape.
provider:
model: qwen3:32b
base_url: http://localhost:11434/v1
timeout_minutes: 10
retry:
max_attempts: 3
backoff_ms: [2000, 5000, 15000]
retryable_status: [429, 500, 502, 503, 504]
worktree_dir: /var/lib/agent-orchestrator/workspaces
custom_pieces_dir: ./custom-pieces
user_folder_root: ./data/users
tools:
task_upload_max_size_mb: 50
trash_retention_days: 30
+83
View File
@@ -0,0 +1,83 @@
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import { logger } from '../logger.js';
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
};
export interface FixtureServer {
port: number;
close(): Promise<void>;
}
/**
* Serve files under `rootDir` over HTTP on a random localhost port.
* Path traversal is rejected. Used for benchmark fixtures so tasks
* that need WebFetch/BrowseWeb stay reproducible without external network.
*/
export async function startFixtureServer(rootDir: string): Promise<FixtureServer> {
const root = path.resolve(rootDir);
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
throw new Error(`Fixture server root does not exist or is not a directory: ${root}`);
}
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://localhost');
const requested = decodeURIComponent(url.pathname);
// Resolve and ensure the result still lives under root.
const resolved = path.resolve(root, '.' + (requested === '/' ? '/index.html' : requested));
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
res.statusCode = 403;
res.end('forbidden');
return;
}
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
res.statusCode = 404;
res.end('not found');
return;
}
const ext = path.extname(resolved).toLowerCase();
res.statusCode = 200;
res.setHeader('Content-Type', MIME_TYPES[ext] ?? 'application/octet-stream');
const stream = fs.createReadStream(resolved);
stream.pipe(res);
} catch (err) {
res.statusCode = 500;
res.end(`error: ${(err as Error).message}`);
}
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', reject);
resolve();
});
});
const address = server.address();
if (typeof address !== 'object' || address === null) {
throw new Error('Fixture server did not bind to an address');
}
const port = address.port;
logger.info(`[bench/fixture-server] listening on http://127.0.0.1:${port} root=${root}`);
return {
port,
async close(): Promise<void> {
await new Promise<void>((resolve) => server.close(() => resolve()));
logger.info(`[bench/fixture-server] closed port=${port}`);
},
};
}
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from 'vitest';
import {
computeTotal,
gradeChecklist,
gradeInstructionsProgrammatic,
gradeTools,
} from './grader.js';
import type { BenchTask, RawJobResult, ToolCallObservation } from './types.js';
function tc(name: string, summary: string): ToolCallObservation {
const filePath = /(input|output)\/[\w\-./ ]+/.exec(summary)?.[0];
return { name, inputSummary: summary, filePath };
}
function makeRaw(overrides: Partial<RawJobResult>): RawJobResult {
return {
taskId: 1,
jobId: 'job-1',
status: 'succeeded',
iterations: null,
promptTokens: null,
completionTokens: null,
workspacePath: '/tmp/x',
activityLog: '',
toolCalls: [],
outputFiles: {},
durationMs: 1000,
...overrides,
};
}
const baseTask: BenchTask = {
id: 'unit-test',
title: 't',
prompt: 'p',
expected: { must_use_tools: [], forbidden_tools: [] },
};
describe('gradeTools', () => {
it('rewards must_use_tools and penalizes forbidden tools', () => {
const task: BenchTask = {
...baseTask,
expected: { must_use_tools: ['ReadExcel', 'Write'], forbidden_tools: ['Bash'] },
};
const raw = makeRaw({ toolCalls: [tc('ReadExcel', 'input/x.xlsx'), tc('Write', 'output/x.md')] });
const r = gradeTools(task, raw);
expect(r.score).toBe(1);
});
it('penalizes forbidden tool use', () => {
const task: BenchTask = {
...baseTask,
expected: { must_use_tools: ['Write'], forbidden_tools: ['Bash'] },
};
const raw = makeRaw({ toolCalls: [tc('Write', 'output/x.md'), tc('Bash', 'rm -rf /tmp/x')] });
const r = gradeTools(task, raw);
expect(r.score).toBeLessThan(1);
});
it('detects forbidden_tool_for_ext (Read on .xlsx)', () => {
const task: BenchTask = {
...baseTask,
expected: {
must_use_tools: [],
forbidden_tool_for_ext: { Read: ['.xlsx'] },
},
};
const raw = makeRaw({ toolCalls: [tc('Read', 'input/data.xlsx')] });
const r = gradeTools(task, raw);
expect(r.score).toBeLessThan(1);
expect(r.details.some((d) => d.includes('Read on .xlsx'))).toBe(true);
});
it('does not penalize Read on .txt when xlsx is forbidden', () => {
const task: BenchTask = {
...baseTask,
expected: {
must_use_tools: ['Read'],
forbidden_tool_for_ext: { Read: ['.xlsx'] },
},
};
const raw = makeRaw({ toolCalls: [tc('Read', 'input/notes.md')] });
const r = gradeTools(task, raw);
expect(r.score).toBe(1);
});
});
describe('gradeChecklist', () => {
const task: BenchTask = {
...baseTask,
checklist: { required_tools: ['CreateChecklist', 'CheckItem', 'GetChecklist'], min_check_item_calls: 3 },
};
it('full credit when all 3 required tools used and CheckItem >= min', () => {
const raw = makeRaw({
toolCalls: [
tc('CreateChecklist', '...'),
tc('CheckItem', '...'),
tc('CheckItem', '...'),
tc('CheckItem', '...'),
tc('GetChecklist', '...'),
],
});
expect(gradeChecklist(task, raw).score).toBe(1);
});
it('partial credit when CheckItem under min', () => {
const raw = makeRaw({
toolCalls: [tc('CreateChecklist', '...'), tc('CheckItem', '...'), tc('GetChecklist', '...')],
});
expect(gradeChecklist(task, raw).score).toBeCloseTo(2 / 3, 5);
});
it('zero when no checklist tool used', () => {
expect(gradeChecklist(task, makeRaw({})).score).toBe(0);
});
it('returns 1 when checklist is not configured', () => {
expect(gradeChecklist(baseTask, makeRaw({})).score).toBe(1);
});
});
describe('gradeInstructionsProgrammatic', () => {
it('penalizes when status is not in expected', () => {
const task: BenchTask = {
...baseTask,
expected: { completion_status: ['succeeded'] },
};
const raw = makeRaw({ status: 'failed' });
const r = gradeInstructionsProgrammatic(task, raw);
expect(r.score).toBe(0);
});
it('checks file existence and constraints', () => {
const task: BenchTask = {
...baseTask,
expected: { must_produce_files: ['output/report.md'] },
grading: {
programmatic: {
constraints: [
{ type: 'file_first_line_equals', file: 'output/report.md', line: '# サマリー' },
{ type: 'file_must_contain_in_order', file: 'output/report.md', sections: ['## A', '## B'] },
{ type: 'file_section_max_lines', file: 'output/report.md', section: 'A', max: 2 },
{ type: 'file_line_starts_with', file: 'output/report.md', prefix: '-', min_lines: 2, section: 'B' },
{ type: 'file_line_max_chars', file: 'output/report.md', max: 30, section: 'B' },
{ type: 'file_no_pattern', file: 'output/report.md', pattern: '!\\[' },
],
},
},
};
const goodOutput = [
'# サマリー',
'',
'## A',
'short line',
'short line 2',
'',
'## B',
'- 短い行 1',
'- 短い行 2',
].join('\n');
const raw = makeRaw({ outputFiles: { 'output/report.md': goodOutput } });
expect(gradeInstructionsProgrammatic(task, raw).score).toBe(1);
});
it('flags out-of-order sections', () => {
const task: BenchTask = {
...baseTask,
grading: {
programmatic: {
constraints: [
{ type: 'file_must_contain_in_order', file: 'out.md', sections: ['## A', '## B', '## C'] },
],
},
},
};
const wrongOrder = '# H\n## C\n## B\n## A\n';
const raw = makeRaw({ outputFiles: { 'out.md': wrongOrder } });
const r = gradeInstructionsProgrammatic(task, raw);
expect(r.score).toBeLessThan(1);
});
it('flags forbidden Markdown image patterns', () => {
const task: BenchTask = {
...baseTask,
grading: {
programmatic: {
constraints: [{ type: 'file_no_pattern', file: 'out.md', pattern: '!\\[' }],
},
},
};
const withImg = '# H\n\n![alt](img.png)\n';
const raw = makeRaw({ outputFiles: { 'out.md': withImg } });
const r = gradeInstructionsProgrammatic(task, raw);
// 1 status check (true) + 1 forbidden pattern check (false) = 1/2 = 0.5
expect(r.score).toBeLessThan(1);
expect(r.details.some((d) => d.includes('no_pattern'))).toBe(true);
});
});
describe('computeTotal', () => {
it('weights axes 30/15/30/25', () => {
const total = computeTotal({
tools: { score: 1, details: [] },
checklist: { score: 1, details: [] },
instructions: { score: 1, details: [] },
reasoning: { score: 1, details: [] },
});
expect(total).toBe(100);
});
it('partial credit example', () => {
const total = computeTotal({
tools: { score: 0.9, details: [] }, // 27
checklist: { score: 1.0, details: [] }, // 15
instructions: { score: 0.7, details: [] }, // 21
reasoning: { score: 0.7, details: [] }, // 17.5 → 17 or 18 after rounding
});
// 27 + 15 + 21 + 17.5 = 80.5 → 81
expect(total).toBe(81);
});
});
+266
View File
@@ -0,0 +1,266 @@
import * as path from 'path';
import type {
AxisScore,
BenchTask,
ProgrammaticConstraint,
RawJobResult,
} from './types.js';
function clamp01(v: number): number {
return Math.max(0, Math.min(1, v));
}
function avg(xs: number[]): number {
if (xs.length === 0) return 0;
return xs.reduce((a, b) => a + b, 0) / xs.length;
}
/**
* Axis A — tool calling correctness.
*
* Components:
* - +1 for each must_use tool actually called (averaged)
* - -1 for each forbidden_tool used
* - -1 for each forbidden_tool_for_ext violation (e.g. Read on .xlsx)
*/
export function gradeTools(task: BenchTask, raw: RawJobResult): AxisScore {
const must = task.expected.must_use_tools ?? [];
const forbidden = task.expected.forbidden_tools ?? [];
const forbiddenForExt = task.expected.forbidden_tool_for_ext ?? {};
const used = new Set(raw.toolCalls.map((c) => c.name));
const details: string[] = [];
let mustHits = 0;
for (const t of must) {
if (used.has(t)) {
mustHits++;
details.push(`✓ used ${t}`);
} else {
details.push(`✗ missing ${t}`);
}
}
let forbiddenViolations = 0;
for (const t of forbidden) {
if (used.has(t)) {
forbiddenViolations++;
details.push(`✗ forbidden tool used: ${t}`);
}
}
let extViolations = 0;
for (const [tool, exts] of Object.entries(forbiddenForExt)) {
for (const call of raw.toolCalls) {
if (call.name !== tool) continue;
const fp = call.filePath ?? call.inputSummary;
const ext = path.extname(fp).toLowerCase();
if (exts.includes(ext)) {
extViolations++;
details.push(`${tool} on ${ext}: ${fp}`);
}
}
}
const mustScore = must.length === 0 ? 1 : mustHits / must.length;
const penalty = (forbiddenViolations + extViolations) * 0.5;
const score = clamp01(mustScore - penalty);
return { score, details };
}
/**
* Axis B — checklist tool usage.
*
* +1/3 for CreateChecklist used, +1/3 for GetChecklist used, +1/3 if
* CheckItem invoked at least min_check_item_calls times.
*/
export function gradeChecklist(task: BenchTask, raw: RawJobResult): AxisScore {
const cfg = task.checklist;
if (!cfg) return { score: 1, details: ['(checklist not required)'] };
const calls = raw.toolCalls.map((c) => c.name);
const required = cfg.required_tools;
const checkItemCalls = calls.filter((n) => n === 'CheckItem').length;
const details: string[] = [];
let satisfied = 0;
for (const t of required) {
if (t === 'CheckItem') {
if (checkItemCalls >= cfg.min_check_item_calls) {
satisfied++;
details.push(`✓ CheckItem ${checkItemCalls}/${cfg.min_check_item_calls}`);
} else {
details.push(`✗ CheckItem ${checkItemCalls}/${cfg.min_check_item_calls}`);
}
continue;
}
if (calls.includes(t)) {
satisfied++;
details.push(`${t}`);
} else {
details.push(`✗ missing ${t}`);
}
}
return { score: required.length === 0 ? 1 : satisfied / required.length, details };
}
interface OutputView {
text: string;
lines: string[];
/** Line index keyed by section header (## ...). End is exclusive. */
sections: Record<string, { start: number; end: number }>;
}
function buildOutputView(text: string): OutputView {
const lines = text.split('\n');
const sections: Record<string, { start: number; end: number }> = {};
let currentHeader: string | null = null;
let currentStart = 0;
for (let i = 0; i < lines.length; i++) {
const m = /^##\s+(.+)\s*$/.exec(lines[i]!);
if (m) {
if (currentHeader !== null) {
sections[currentHeader] = { start: currentStart, end: i };
}
currentHeader = m[1]!.trim();
currentStart = i + 1;
}
}
if (currentHeader !== null) {
sections[currentHeader] = { start: currentStart, end: lines.length };
}
return { text, lines, sections };
}
function evaluateConstraint(
constraint: ProgrammaticConstraint,
outputs: Record<string, string>,
): { passed: boolean; detail: string } {
const file = (constraint as { file: string }).file;
const text = outputs[file];
if (text === undefined) {
return { passed: false, detail: `[${constraint.type}] file missing: ${file}` };
}
const view = buildOutputView(text);
switch (constraint.type) {
case 'file_first_line_equals': {
const ok = (view.lines[0] ?? '').trim() === constraint.line.trim();
return { passed: ok, detail: `[first_line] ${file}: ${ok ? '✓' : `got "${view.lines[0]}"`}` };
}
case 'file_must_contain_in_order': {
let cursor = 0;
const missing: string[] = [];
for (const sec of constraint.sections) {
const idx = view.text.indexOf(sec, cursor);
if (idx === -1) missing.push(sec);
else cursor = idx + sec.length;
}
return {
passed: missing.length === 0,
detail: `[order] ${file}: ${missing.length === 0 ? '✓' : `missing/out-of-order: ${missing.join(', ')}`}`,
};
}
case 'file_line_starts_with': {
const range = constraint.section ? view.sections[constraint.section] : { start: 0, end: view.lines.length };
if (!range) {
return { passed: false, detail: `[starts_with] section "${constraint.section}" not found` };
}
const target = view.lines.slice(range.start, range.end).filter((l) => l.trim().length > 0);
const matched = target.filter((l) => l.trim().startsWith(constraint.prefix)).length;
const ok = matched >= constraint.min_lines;
return {
passed: ok,
detail: `[starts_with "${constraint.prefix}"] ${constraint.section ?? file}: ${matched}/${constraint.min_lines}`,
};
}
case 'file_line_max_chars': {
const range = constraint.section ? view.sections[constraint.section] : { start: 0, end: view.lines.length };
if (!range) {
return { passed: false, detail: `[max_chars] section "${constraint.section}" not found` };
}
const target = view.lines.slice(range.start, range.end).filter((l) => l.trim().length > 0);
const violations = target.filter((l) => [...l.trim()].length > constraint.max);
return {
passed: violations.length === 0,
detail: `[max_chars ${constraint.max}] ${constraint.section ?? file}: ${violations.length === 0 ? '✓' : `${violations.length} violations`}`,
};
}
case 'file_section_max_lines': {
const range = view.sections[constraint.section];
if (!range) {
return { passed: false, detail: `[max_lines] section "${constraint.section}" not found` };
}
const nonEmpty = view.lines.slice(range.start, range.end).filter((l) => l.trim().length > 0).length;
return {
passed: nonEmpty <= constraint.max,
detail: `[max_lines ${constraint.max}] ${constraint.section}: ${nonEmpty} lines`,
};
}
case 'file_no_pattern': {
const re = new RegExp(constraint.pattern, 'm');
const ok = !re.test(view.text);
return { passed: ok, detail: `[no_pattern /${constraint.pattern}/] ${file}: ${ok ? '✓' : '✗'}` };
}
}
}
/**
* Axis C — instruction adherence (programmatic part).
*
* Combines:
* - must_produce_files coverage
* - each programmatic.constraints check
* - completion_status acceptance
*/
export function gradeInstructionsProgrammatic(task: BenchTask, raw: RawJobResult): AxisScore {
const details: string[] = [];
const checks: boolean[] = [];
const acceptable = task.expected.completion_status ?? ['succeeded'];
const statusOk = acceptable.includes(raw.status as 'succeeded');
checks.push(statusOk);
details.push(`[status] ${raw.status} ${statusOk ? '✓' : `(expected one of ${acceptable.join(',')})`}`);
const mustFiles = task.expected.must_produce_files ?? [];
for (const f of mustFiles) {
const ok = raw.outputFiles[f] !== undefined && raw.outputFiles[f].length > 0;
checks.push(ok);
details.push(`[file] ${f} ${ok ? '✓' : '✗ (empty/missing)'}`);
}
for (const c of task.grading?.programmatic?.constraints ?? []) {
const r = evaluateConstraint(c, raw.outputFiles);
checks.push(r.passed);
details.push(r.detail);
}
return { score: checks.length === 0 ? 1 : checks.filter(Boolean).length / checks.length, details };
}
/**
* Combine programmatic + judge scores for axis C / D, weighted by config.
*/
export function combineAxisScores(programmatic: AxisScore, judge: AxisScore | null, judgeWeight: number): AxisScore {
if (!judge) return programmatic;
const w = clamp01(judgeWeight);
return {
score: (1 - w) * programmatic.score + w * judge.score,
details: [...programmatic.details, ...judge.details.map((d) => `[judge] ${d}`)],
};
}
/**
* Total score in 0..100 with the described axis weights.
*
* tools 30 / checklist 15 / instructions 30 / reasoning 25
*/
export function computeTotal(axes: {
tools: AxisScore;
checklist: AxisScore;
instructions: AxisScore;
reasoning: AxisScore;
}): number {
const sum = axes.tools.score * 30 + axes.checklist.score * 15 + axes.instructions.score * 30 + axes.reasoning.score * 25;
return Math.round(sum);
}
export { avg, clamp01 };
+147
View File
@@ -0,0 +1,147 @@
import type { AxisScore, BenchTask, RawJobResult } from './types.js';
import { logger } from '../logger.js';
export interface JudgeConfig {
endpoint: string; // OpenAI-compat /chat/completions base, e.g. http://localhost:11434/v1
model: string;
apiKey?: string;
timeoutMs?: number;
}
interface JudgeRubricResult {
name: string;
score: number;
max: number;
rationale: string;
}
const SYSTEM_PROMPT = [
'You are an evaluation assistant for an autonomous agent benchmark.',
'Score each rubric item with an integer in [0, max_score].',
'Be strict but fair. Score 0 means the rubric goal is not met at all; max_score means fully met.',
'Return ONLY a JSON object of the form {"results":[{"name":"...","score":N,"rationale":"..."}, ...]}.',
'No prose outside the JSON. No code fences.',
].join('\n');
function buildJudgePrompt(task: BenchTask, raw: RawJobResult): string {
const rubrics = task.grading?.llm_judge?.rubrics ?? [];
const outputView = Object.entries(raw.outputFiles)
.slice(0, 5) // bound size; we don't expect many files
.map(([name, body]) => `--- ${name} ---\n${body.slice(0, 4000)}`)
.join('\n\n');
return [
`## Original task prompt`,
task.prompt,
'',
`## Job status`,
`status=${raw.status} duration_ms=${raw.durationMs}`,
'',
`## Output files`,
outputView || '(no output files)',
'',
'## Rubrics',
...rubrics.map((r, i) => `${i + 1}. name="${r.name}" max_score=${r.max_score ?? 10}\n ${r.prompt}`),
'',
`## Output`,
`Return JSON with one entry per rubric, in the same order.`,
].join('\n');
}
async function callJudge(config: JudgeConfig, userPrompt: string): Promise<string> {
const body = {
model: config.model,
stream: false,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeoutMs ?? 120_000);
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
const res = await fetch(`${config.endpoint.replace(/\/+$/, '')}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`judge endpoint ${res.status}: ${(await res.text()).slice(0, 400)}`);
}
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data.choices?.[0]?.message?.content ?? '';
} finally {
clearTimeout(timeout);
}
}
function parseJudgeJson(content: string): JudgeRubricResult[] {
const trimmed = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
// Some models still embed prose; extract the first {...} block.
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start < 0 || end <= start) throw new Error(`judge response not JSON: ${content.slice(0, 200)}`);
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as { results?: unknown };
if (!Array.isArray(parsed.results)) throw new Error('judge response missing results[]');
return parsed.results.map((r: unknown) => {
const o = r as Record<string, unknown>;
return {
name: String(o['name'] ?? ''),
score: Number(o['score'] ?? 0),
max: Number(o['max'] ?? o['max_score'] ?? 10),
rationale: String(o['rationale'] ?? ''),
};
});
}
/**
* Run the LLM judge for axis D (reasoning). When the task has no llm_judge
* config or `config` is null, returns a 1.0 score so that absence of judge
* does not penalize.
*/
export async function gradeReasoning(
task: BenchTask,
raw: RawJobResult,
config: JudgeConfig | null,
): Promise<AxisScore> {
const rubrics = task.grading?.llm_judge?.rubrics ?? [];
if (!config || rubrics.length === 0) {
return { score: 1, details: ['(LLM judge skipped — no config or no rubrics)'] };
}
const prompt = buildJudgePrompt(task, raw);
let raw_response: string;
try {
raw_response = await callJudge(config, prompt);
} catch (err) {
logger.warn(`[bench/judge] call failed: ${(err as Error).message}`);
return { score: 0, details: [`judge error: ${(err as Error).message}`] };
}
let results: JudgeRubricResult[];
try {
results = parseJudgeJson(raw_response);
} catch (err) {
logger.warn(`[bench/judge] parse failed: ${(err as Error).message}`);
return { score: 0, details: [`judge parse error: ${(err as Error).message}`, `raw=${raw_response.slice(0, 200)}`] };
}
if (results.length === 0) {
return { score: 0, details: ['judge returned no results'] };
}
const normalized = results.map((r) => Math.max(0, Math.min(1, r.score / Math.max(1, r.max))));
const score = normalized.reduce((a, b) => a + b, 0) / normalized.length;
const details = results.map((r) => `${r.name}: ${r.score}/${r.max}${r.rationale}`);
return { score, details };
}
export function loadJudgeConfigFromEnv(fallback: { endpoint: string; model: string; apiKey?: string }): JudgeConfig | null {
const enabled = process.env['BENCH_JUDGE'] !== 'off';
if (!enabled) return null;
return {
endpoint: process.env['BENCH_JUDGE_ENDPOINT'] ?? fallback.endpoint,
model: process.env['BENCH_JUDGE_MODEL'] ?? fallback.model,
apiKey: process.env['BENCH_JUDGE_API_KEY'] ?? fallback.apiKey,
timeoutMs: 120_000,
};
}
+189
View File
@@ -0,0 +1,189 @@
import * as fs from 'fs';
import * as path from 'path';
import { logger } from '../logger.js';
import type { BenchTask, RawJobResult, ToolCallObservation } from './types.js';
const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'aborted', 'cancelled', 'waiting_human']);
export interface RunnerOptions {
serverUrl: string;
benchRoot: string;
pollIntervalMs?: number;
/** Substituted into prompt {WEB_PORT}. */
webPort: number;
}
interface CreateTaskResponse {
task: { id: number; workspacePath: string | null };
jobId: string;
}
interface TaskDetailResponse {
task: {
id: number;
workspacePath: string | null;
latestJob?: {
id: string;
status: string;
contextPromptTokens: number | null;
contextLimitTokens: number | null;
} | null;
};
}
function expandPromptTokens(prompt: string, tokens: Record<string, string>): string {
return prompt.replace(/\{(\w+)\}/g, (_, key) => tokens[key] ?? `{${key}}`);
}
async function postJson(url: string, body: unknown): Promise<unknown> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`POST ${url}${res.status}: ${(await res.text()).slice(0, 400)}`);
return res.json();
}
async function getJson(url: string): Promise<unknown> {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url}${res.status}: ${(await res.text()).slice(0, 400)}`);
return res.json();
}
function buildAttachments(task: BenchTask, benchRoot: string): Array<{ name: string; contentBase64: string }> {
const attachments: Array<{ name: string; contentBase64: string }> = [];
for (const fx of task.fixtures ?? []) {
if (!fx.dest.startsWith('input/')) continue; // web/ fixtures are served by HTTP server, not uploaded
const sourcePath = path.resolve(benchRoot, fx.source);
if (!fs.existsSync(sourcePath)) {
throw new Error(`Fixture source missing: ${sourcePath}`);
}
const name = fx.dest.slice('input/'.length);
attachments.push({ name, contentBase64: fs.readFileSync(sourcePath).toString('base64') });
}
return attachments;
}
function parseToolCalls(activityLog: string): ToolCallObservation[] {
const out: ToolCallObservation[] = [];
// Format from summarizeToolInput: "[time] [worker:..] [mode:..] ToolName: arg-summary"
// tool line shape after metadata strip: "ToolName: <input summary>"
const lines = activityLog.split('\n').map((l) => l.trim()).filter(Boolean);
for (const raw of lines) {
const stripped = raw
.replace(/^\[[^\]]+\]\s+/, '')
.replace(/\[worker:[^\]]+\]\s*/g, '')
.replace(/\[mode:[^\]]+\]\s*/g, '')
.trim();
// Skip non-tool lines (preflight, [movement] start/complete/preview, final, ask, context-action)
if (
stripped.startsWith('preflight:') ||
stripped.startsWith('[llm-preflight:') ||
stripped.startsWith('context-action:') ||
stripped.startsWith('final:') ||
stripped.startsWith('ask:') ||
stripped.startsWith('[')
) {
continue;
}
const m = /^([A-Z][A-Za-z0-9_]+):\s*(.+)$/.exec(stripped);
if (!m) continue;
const name = m[1]!;
const inputSummary = m[2]!;
// Heuristic: extract first quoted/unquoted "input/..." or "output/..." as filePath
const filePath = /(input|output)\/[\w\-./ ]+/.exec(inputSummary)?.[0];
out.push({ name, inputSummary, filePath });
}
return out;
}
function listOutputFiles(workspacePath: string): Record<string, string> {
const outDir = path.join(workspacePath, 'output');
if (!fs.existsSync(outDir)) return {};
const result: Record<string, string> = {};
function walk(dir: string, prefix: string): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
continue;
}
try {
const buf = fs.readFileSync(abs);
// Only return small text files (skip binary/huge)
if (buf.length > 200_000 || buf.includes(0)) continue;
result[rel] = buf.toString('utf-8');
} catch {
/* skip unreadable */
}
}
}
walk(outDir, '');
return result;
}
export async function runOneTask(task: BenchTask, opts: RunnerOptions): Promise<RawJobResult> {
const startedAt = Date.now();
const promptTokens: Record<string, string> = {
WEB_PORT: String(opts.webPort),
...(task.prompt_tokens ?? {}),
};
const expandedPrompt = expandPromptTokens(task.prompt, promptTokens);
const attachments = buildAttachments(task, opts.benchRoot);
logger.info(`[bench/runner] submitting task=${task.id} attachments=${attachments.length}`);
const create = (await postJson(`${opts.serverUrl}/api/local/tasks`, {
title: `[bench] ${task.title}`,
body: expandedPrompt,
piece: task.piece_hint ?? 'chat',
profile: 'auto',
outputFormat: 'markdown',
askPolicy: 'low',
priority: 'medium',
visibility: 'private',
attachments,
})) as CreateTaskResponse;
const taskId = create.task.id;
const jobId = create.jobId;
logger.info(`[bench/runner] created task=${task.id} taskId=${taskId} jobId=${jobId}`);
const timeoutMs = (task.timeout_minutes ?? 10) * 60_000;
const pollMs = opts.pollIntervalMs ?? 3_000;
let detail: TaskDetailResponse['task'] | null = null;
while (Date.now() - startedAt < timeoutMs) {
await new Promise((r) => setTimeout(r, pollMs));
try {
const got = (await getJson(`${opts.serverUrl}/api/local/tasks/${taskId}`)) as TaskDetailResponse;
detail = got.task;
const status = detail.latestJob?.status ?? 'queued';
logger.info(`[bench/runner] task=${task.id} status=${status}`);
if (TERMINAL_STATUSES.has(status)) break;
} catch (err) {
logger.warn(`[bench/runner] poll error: ${(err as Error).message}`);
}
}
if (!detail) throw new Error(`Task ${taskId} never reported state`);
if (!detail.latestJob) throw new Error(`Task ${taskId} has no latestJob`);
const workspacePath = detail.workspacePath ?? '';
const activityLogPath = path.join(workspacePath, 'logs', 'activity.log');
const activityLog = fs.existsSync(activityLogPath) ? fs.readFileSync(activityLogPath, 'utf-8') : '';
const toolCalls = parseToolCalls(activityLog);
const outputFiles = listOutputFiles(workspacePath);
return {
taskId,
jobId: detail.latestJob.id,
status: detail.latestJob.status,
iterations: null,
promptTokens: detail.latestJob.contextPromptTokens,
completionTokens: null,
workspacePath,
activityLog,
toolCalls,
outputFiles,
durationMs: Date.now() - startedAt,
};
}
+104
View File
@@ -0,0 +1,104 @@
import * as fs from 'fs';
import * as path from 'path';
import type { BenchResult } from './types.js';
function pad(s: string, n: number): string {
return s.length >= n ? s : s + ' '.repeat(n - s.length);
}
function bar(score: number): string {
const filled = Math.round(score * 10);
return '█'.repeat(filled) + '░'.repeat(10 - filled);
}
function formatAxis(name: string, score: number, weight: number): string {
return `${pad(name, 16)} ${bar(score)} ${(score * 100).toFixed(0).padStart(3)}% (weight ${weight})`;
}
export function formatResultMarkdown(result: BenchResult): string {
const r = result;
const minutes = (r.raw.durationMs / 60_000).toFixed(1);
return [
`## ${r.taskTitle} (id: \`${r.taskId}\`)`,
'',
`- Started: ${r.startedAt}`,
`- Finished: ${r.finishedAt}`,
`- Status: \`${r.raw.status}\``,
`- Duration: ${minutes} min`,
`- Tool calls: ${r.raw.toolCalls.length}`,
r.raw.promptTokens !== null ? `- Last prompt tokens: ${r.raw.promptTokens?.toLocaleString()}` : null,
`- Workspace: \`${r.raw.workspacePath}\``,
'',
'### Scores',
'',
'```',
formatAxis('A. Tools', r.axes.tools.score, 30),
formatAxis('B. Checklist', r.axes.checklist.score, 15),
formatAxis('C. Instructions', r.axes.instructions.score, 30),
formatAxis('D. Reasoning', r.axes.reasoning.score, 25),
'```',
'',
`**Total: ${r.total} / 100**`,
'',
'### Details',
'',
'<details><summary>A. Tools</summary>',
'',
...r.axes.tools.details.map((d) => `- ${d}`),
'',
'</details>',
'',
'<details><summary>B. Checklist</summary>',
'',
...r.axes.checklist.details.map((d) => `- ${d}`),
'',
'</details>',
'',
'<details><summary>C. Instructions</summary>',
'',
...r.axes.instructions.details.map((d) => `- ${d}`),
'',
'</details>',
'',
'<details><summary>D. Reasoning</summary>',
'',
...r.axes.reasoning.details.map((d) => `- ${d}`),
'',
'</details>',
'',
'<details><summary>Tool call sequence</summary>',
'',
'```',
...r.raw.toolCalls.map((c) => `${c.name}: ${c.inputSummary}`),
'```',
'',
'</details>',
'',
]
.filter((x) => x !== null)
.join('\n');
}
export function writeRunSummary(resultDir: string, results: BenchResult[]): string {
const overallTotal = results.length === 0 ? 0 : Math.round(results.reduce((a, r) => a + r.total, 0) / results.length);
const summary = [
`# Bench run @ ${new Date().toISOString()}`,
'',
`**Overall: ${overallTotal} / 100** (avg of ${results.length} task${results.length === 1 ? '' : 's'})`,
'',
'| Task | Status | Total | A | B | C | D |',
'| --- | --- | ---: | ---: | ---: | ---: | ---: |',
...results.map(
(r) =>
`| \`${r.taskId}\` | ${r.raw.status} | ${r.total} | ${(r.axes.tools.score * 100).toFixed(0)}% | ${(r.axes.checklist.score * 100).toFixed(0)}% | ${(r.axes.instructions.score * 100).toFixed(0)}% | ${(r.axes.reasoning.score * 100).toFixed(0)}% |`,
),
'',
'---',
'',
...results.map((r) => formatResultMarkdown(r)),
].join('\n');
const summaryPath = path.join(resultDir, 'summary.md');
fs.writeFileSync(summaryPath, summary, 'utf-8');
return summaryPath;
}
+96
View File
@@ -0,0 +1,96 @@
export interface BenchFixtureSpec {
/** Path under bench/ root, e.g. "fixtures/sales.xlsx". */
source: string;
/** Destination relative to the task workspace. Either "input/<name>" (uploaded as attachment) or "web/<path>" (served by HTTP server, no upload). */
dest: string;
}
export interface BenchExpectations {
must_use_tools?: string[];
forbidden_tools?: string[];
/** Tools forbidden against specific file extensions: e.g. { Read: [".xlsx", ".docx"] }. */
forbidden_tool_for_ext?: Record<string, string[]>;
must_produce_files?: string[];
/** Acceptable terminal job statuses. Default: ["succeeded"]. */
completion_status?: Array<'succeeded' | 'waiting_human' | 'failed' | 'aborted' | 'cancelled'>;
}
export type ProgrammaticConstraint =
| { type: 'file_first_line_equals'; file: string; line: string }
| { type: 'file_must_contain_in_order'; file: string; sections: string[] }
| { type: 'file_line_starts_with'; file: string; prefix: string; min_lines: number; section?: string }
| { type: 'file_line_max_chars'; file: string; max: number; section?: string }
| { type: 'file_section_max_lines'; file: string; section: string; max: number }
| { type: 'file_no_pattern'; file: string; pattern: string };
export interface BenchGrading {
programmatic?: { weight?: number; constraints?: ProgrammaticConstraint[] };
llm_judge?: {
weight?: number;
rubrics: Array<{
name: string;
prompt: string;
max_score?: number; // default 10
}>;
};
}
export interface BenchTask {
id: string;
title: string;
prompt: string;
piece_hint?: string;
fixtures?: BenchFixtureSpec[];
/** Tokens substituted into the prompt at runtime: e.g. {WEB_PORT}. */
prompt_tokens?: Record<string, string>;
expected: BenchExpectations;
grading?: BenchGrading;
/** Required checklist tools and minimum CheckItem count for axis B. */
checklist?: { required_tools: string[]; min_check_item_calls: number };
timeout_minutes?: number;
}
export interface ToolCallObservation {
name: string;
/** Approximate input shown in activity.log; not the full tool input. */
inputSummary: string;
/** Tool first-arg-as-path heuristic, when available. */
filePath?: string;
}
export interface RawJobResult {
taskId: number;
jobId: string;
status: string;
iterations?: number | null;
promptTokens?: number | null;
completionTokens?: number | null;
workspacePath: string;
activityLog: string;
toolCalls: ToolCallObservation[];
outputFiles: Record<string, string>;
durationMs: number;
}
export interface AxisScore {
/** 0..1 normalized score. */
score: number;
/** Human readable detail entries. */
details: string[];
}
export interface BenchResult {
taskId: string;
taskTitle: string;
startedAt: string;
finishedAt: string;
raw: RawJobResult;
axes: {
tools: AxisScore;
checklist: AxisScore;
instructions: AxisScore;
reasoning: AxisScore;
};
/** Weighted total 0..100. */
total: number;
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js';
import { mountAdminApi } from './admin-api.js';
import { unlinkSync } from 'fs';
describe('Admin API', () => {
let app: express.Application;
let repo: Repository;
const dbPath = './_test_admin_api.db';
beforeEach(() => {
repo = new Repository(dbPath);
runMigrations(repo.getDb());
app = express();
app.use(express.json());
// Mock admin auth
app.use((req, _res, next) => {
(req as any).user = { id: 'admin-1', role: 'admin', status: 'active' };
(req as any).isAuthenticated = () => true;
next();
});
mountAdminApi(app, repo);
});
afterEach(() => {
repo.close();
try { unlinkSync(dbPath); } catch { /* ignore */ }
});
it('GET /api/admin/users returns user list', async () => {
repo.createUser({ email: '[email protected]', name: 'A', role: 'user', status: 'active' });
const res = await request(app).get('/api/admin/users');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].email).toBe('[email protected]');
});
it('GET /api/admin/users returns empty orgs array for user with no Gitea orgs', async () => {
repo.createUser({ email: '[email protected]', name: 'NoOrgs', role: 'user', status: 'active' });
const res = await request(app).get('/api/admin/users');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].orgs).toEqual([]);
});
it('GET /api/admin/users returns populated orgs for user with Gitea orgs', async () => {
const user = repo.createUser({ email: '[email protected]', name: 'WithOrgs', role: 'user', status: 'active' });
repo.replaceUserGiteaOrgs(user.id, [
{ orgId: '10', orgName: 'acme' },
{ orgId: '11', orgName: 'beta' },
]);
const res = await request(app).get('/api/admin/users');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].orgs).toHaveLength(2);
const names = res.body[0].orgs.map((o: { orgName: string }) => o.orgName).sort();
expect(names).toEqual(['acme', 'beta']);
expect(res.body[0].orgs[0]).toHaveProperty('fetchedAt');
});
it('PATCH /api/admin/users/:id updates user', async () => {
const user = repo.createUser({ email: '[email protected]', name: 'B', role: 'user', status: 'pending' });
const res = await request(app)
.patch(`/api/admin/users/${user.id}`)
.send({ status: 'active' });
expect(res.status).toBe(200);
expect(res.body.status).toBe('active');
});
it('DELETE /api/admin/users/:id deletes user', async () => {
const user = repo.createUser({ email: '[email protected]', name: 'C', role: 'user', status: 'active' });
const res = await request(app).delete(`/api/admin/users/${user.id}`);
expect(res.status).toBe(204);
expect(repo.getUserById(user.id)).toBeNull();
});
it('PATCH /api/admin/users/:id returns 404 for unknown user', async () => {
const res = await request(app)
.patch('/api/admin/users/nonexistent')
.send({ status: 'active' });
expect(res.status).toBe(404);
});
it('DELETE /api/admin/users/:id returns 404 for unknown user', async () => {
const res = await request(app).delete('/api/admin/users/nonexistent');
expect(res.status).toBe(404);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { Repository } from '../db/repository.js';
import { requireAdmin } from './auth.js';
const passthrough: RequestHandler = (_req, _res, next) => next();
export function mountAdminApi(app: Application, repo: Repository, authActive = true): void {
const guard = authActive ? requireAdmin : passthrough;
app.get('/api/admin/users', guard, (_req: Request, res: Response) => {
const users = authActive ? repo.listUsers() : [];
const enriched = users.map(u => ({
...u,
orgs: repo.listUserGiteaOrgs(u.id),
}));
res.json(enriched);
});
app.patch('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
const { status, role } = req.body;
const user = repo.getUserById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
repo.updateUser(id, { status, role });
// Invalidate sessions on status/role change
if (status === 'disabled' || status === 'pending' || role) {
repo.deleteSessionsByUserId(id);
}
const updated = repo.getUserById(id);
res.json(updated);
});
app.delete('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
const user = repo.getUserById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
repo.deleteSessionsByUserId(id);
repo.deleteUser(id);
res.status(204).end();
});
}
@@ -0,0 +1,402 @@
/**
* Phase 2b — admin REST API integration tests for budget / rate / usage.
*
* Covers:
* - POST accepts tokensBudget + rateLimitRpm in the body
* - POST rejects malformed budget (NaN, negative, string)
* - PATCH updates fields independently (tokensBudget, rateLimitRpm,
* allowedModels) and supports null reset
* - PATCH rejects config-import keys with 400
* - GET /:id/usage returns currentPeriod stats + history
* - GET /:id/usage reports zero counters when there's no usage row
* - GET /:id/usage reports remaining=0 when over budget
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express, { type Request, type RequestHandler } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
import { currentPeriodYearMonth } from '../gateway/period.js';
import { hashKey } from '../gateway/key-format.js';
function buildAdminApp(repo: Repository): express.Application {
const app = express();
app.use(express.json({ limit: '4kb' }));
const guard: RequestHandler = (req, _res, next) => {
(req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
next();
};
const router = createAdminGatewayApi({
repo,
requireAdmin: guard,
getUserId: (req) => {
const u = (req as Request & { user?: { id?: string } }).user;
return u?.id ?? null;
},
});
app.use('/api/admin/gateway/keys', router);
return app;
}
describe('admin-gateway-api Phase 2b', () => {
let tmpDir: string;
let repo: Repository;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-2b-'));
repo = new Repository(join(tmpDir, 'test.db'));
app = buildAdminApp(repo);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
describe('POST /', () => {
it('accepts tokensBudget + rateLimitRpm and round-trips them on GET', async () => {
const created = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1_000_000,
rateLimitRpm: 60,
});
expect(created.status).toBe(201);
expect(created.body.tokensBudget).toBe(1_000_000);
expect(created.body.rateLimitRpm).toBe(60);
expect(typeof created.body.key).toBe('string');
const get = await request(app).get(`/api/admin/gateway/keys/${created.body.id}`);
expect(get.status).toBe(200);
expect(get.body.tokensBudget).toBe(1_000_000);
expect(get.body.rateLimitRpm).toBe(60);
expect(get.body.key).toBeUndefined();
});
it('defaults to null when budget/rate omitted', async () => {
const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' });
expect(created.body.tokensBudget).toBeNull();
expect(created.body.rateLimitRpm).toBeNull();
});
it.each([
['negative budget', { team: 'alpha', tokensBudget: -1 }],
['zero budget', { team: 'alpha', tokensBudget: 0 }],
['string budget', { team: 'alpha', tokensBudget: 'lots' }],
['negative rpm', { team: 'alpha', rateLimitRpm: -5 }],
['zero rpm', { team: 'alpha', rateLimitRpm: 0 }],
])('rejects %s with 400', async (_name, body) => {
const res = await request(app).post('/api/admin/gateway/keys').send(body);
expect(res.status).toBe(400);
});
// F7: hard caps to keep INTEGER arithmetic exact in SQLite.
it('rejects tokensBudget above 1e12 with 400 (F7: INT overflow guard)', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1e20,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/tokensBudget exceeds maximum/);
});
it('rejects tokensBudget == max+1 with 400', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1_000_000_000_001,
});
expect(res.status).toBe(400);
});
it('accepts tokensBudget == max with 201', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1_000_000_000_000,
});
expect(res.status).toBe(201);
expect(res.body.tokensBudget).toBe(1_000_000_000_000);
});
it('floors fractional tokensBudget to an integer (1.7 → 1)', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1.7,
});
expect(res.status).toBe(201);
expect(res.body.tokensBudget).toBe(1);
});
it('rejects rateLimitRpm above 1e6 with 400', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
rateLimitRpm: 1_000_001,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/rateLimitRpm exceeds maximum/);
});
it('accepts rateLimitRpm at the cap', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
rateLimitRpm: 1_000_000,
});
expect(res.status).toBe(201);
expect(res.body.rateLimitRpm).toBe(1_000_000);
});
});
describe('PATCH /:id', () => {
async function seedAdminKey(): Promise<string> {
const created = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1000,
rateLimitRpm: 30,
allowedModels: ['qwen3:8b'],
});
return created.body.id as string;
}
it('updates tokensBudget only, leaving others alone', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 5000 });
expect(patched.status).toBe(200);
expect(patched.body.tokensBudget).toBe(5000);
expect(patched.body.rateLimitRpm).toBe(30);
expect(patched.body.allowedModels).toEqual(['qwen3:8b']);
});
it('updates allowedModels alone', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ allowedModels: ['qwen3:14b'] });
expect(patched.body.allowedModels).toEqual(['qwen3:14b']);
});
it('explicit null clears tokensBudget back to unlimited', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: null });
expect(patched.body.tokensBudget).toBeNull();
});
it('explicit null clears allowedModels', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ allowedModels: null });
expect(patched.body.allowedModels).toBeNull();
});
it('rejects an empty body with 400', async () => {
const id = await seedAdminKey();
const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({});
expect(res.status).toBe(400);
});
it('returns 404 for unknown id', async () => {
const res = await request(app).patch(`/api/admin/gateway/keys/nope`).send({ tokensBudget: 1 });
expect(res.status).toBe(404);
});
it('PATCH rejects tokensBudget above 1e12 with 400 (F7)', async () => {
const id = await seedAdminKey();
const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 1e20 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/tokensBudget exceeds maximum/);
});
it('rejects PATCH on revoked key with 409 (F6: consistency with rotate)', async () => {
const id = await seedAdminKey();
// Revoke first.
const revokeRes = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`).send({});
expect(revokeRes.status).toBe(200);
// Now PATCH must fail with 409.
const patchRes = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 9999 });
expect(patchRes.status).toBe(409);
expect(patchRes.body.error).toMatch(/revoked/);
expect(patchRes.body.revokedAt).toBeTruthy();
// Original value still intact (PATCH was rejected before write).
const get = await request(app).get(`/api/admin/gateway/keys/${id}`);
expect(get.body.tokensBudget).toBe(1000);
});
it('still allows PATCH on an active key (regression guard for F6)', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 7777 });
expect(patched.status).toBe(200);
expect(patched.body.tokensBudget).toBe(7777);
});
it('PATCH and concurrent revoke are atomic — revoked-mid-patch returns 409 (TOCTOU guard)', async () => {
// Phase 3a follow-up regression guard. Pre-fix the read (revoked
// check) and the UPDATE lived outside any transaction, so this
// sequence:
// 1. PATCH handler reads row → not revoked
// 2. concurrent admin revokes
// 3. PATCH handler UPDATEs the now-revoked row
// left a ghost mutation: the row's policy fields changed AFTER
// the revoke timestamp, but the row could no longer authenticate.
//
// Post-fix the read + update both run inside a single
// better-sqlite3 transaction. To prove the read-time guard fires
// we stub findGatewayVirtualKeyById to return a revoked row on
// the first call (the one made inside the PATCH txn) — this
// simulates what the txn's read would observe if a concurrent
// revoke had committed just before the txn started.
//
// We can't reproduce a true concurrent-write race via repo.revoke
// from inside the txn because that write would be rolled back
// along with everything else when the txn aborts; mocking the
// observed read isolates the handler's behavior cleanly.
const id = await seedAdminKey();
const original = repo.findGatewayVirtualKeyById.bind(repo);
let raced = false;
const spy = vi.spyOn(repo, 'findGatewayVirtualKeyById').mockImplementation((targetId: string) => {
const out = original(targetId);
if (!raced && targetId === id && out && out.revokedAt === null) {
raced = true;
// Simulate a concurrent revoke that committed just before the
// PATCH txn read. Returning a synthesized revoked snapshot
// forces the handler down the 409 path.
return { ...out, revokedAt: '2026-05-19T00:00:00.000Z', revokedBy: 'racing-admin' };
}
return out;
});
try {
const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 7777 });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/revoked/);
expect(raced).toBe(true);
} finally {
spy.mockRestore();
}
// Crucially: the budget did NOT change to 7777 — the transaction
// aborted before the UPDATE. Pre-fix this would be 7777 because
// the validation lived outside the write path. After restoring
// the spy we read the row directly: budget unchanged, NOT
// actually revoked (the spy was synthesizing the revoked state).
const fresh = repo.findGatewayVirtualKeyById(id);
expect(fresh?.tokensBudget).toBe(1000);
});
it('true cross-connection race: revoke from a sibling Repository before PATCH still 409s (atomicity smoke)', async () => {
// Belt-and-suspenders for the TOCTOU fix: open a second Repository
// pointing at the SAME DB file, revoke through it, and confirm
// the original handler's PATCH txn sees the revoke and 409s. This
// exercises the actual sqlite isolation semantics (better-sqlite3
// uses the file lock per-connection so writes are visible to other
// connections at txn-commit boundaries).
const id = await seedAdminKey();
// Reach into the test fixture's tmpDir via the repo's db path.
// The Repository keeps the path internally but not on the public
// API; we rely on the well-known location from the test fixture.
const dbPath = (repo.getDb() as unknown as { name?: string }).name;
if (!dbPath || typeof dbPath !== 'string') {
// Should not happen with better-sqlite3 — guard so the test
// surfaces a clear failure rather than a cryptic crash.
throw new Error('test setup: could not read repo db path');
}
const sibling = new Repository(dbPath);
try {
// Concurrent revoke commits BEFORE the PATCH txn starts.
expect(sibling.revokeGatewayVirtualKey(id, 'sibling-admin')).toBe(true);
// PATCH must observe the revoked state inside its own txn
// (better-sqlite3 reads via the journal so the revoke is
// visible) and return 409. Pre-fix this depended on the same
// observation but the validation was outside the write path,
// so a revoke landing AFTER the read but BEFORE the UPDATE
// would slip through. Post-fix the atomic txn guarantees no
// such window exists.
const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 1234 });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/revoked/);
const fresh = repo.findGatewayVirtualKeyById(id);
expect(fresh?.tokensBudget).toBe(1000);
expect(fresh?.revokedAt).not.toBeNull();
} finally {
sibling.close();
}
});
it('rejects PATCH on config-import row with 400', async () => {
// Seed a config-import row directly via the Repository — admin
// POST always uses 'admin' source so this is the only way to
// exercise the guard.
const created = repo.createGatewayVirtualKey({
keyHash: hashKey('config-key-raw'),
keyPrefix: 'config-key-r',
team: 'imported',
source: 'config-import',
});
const res = await request(app).patch(`/api/admin/gateway/keys/${created.id}`).send({ tokensBudget: 9999 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/config-import/);
});
});
describe('GET /:id/usage', () => {
async function seedAdminKeyWithBudget(budget = 1000): Promise<string> {
const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha', tokensBudget: budget });
return created.body.id as string;
}
it('returns zero counters when no usage row exists yet', async () => {
const id = await seedAdminKeyWithBudget();
const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
expect(res.status).toBe(200);
expect(res.body.keyId).toBe(id);
expect(res.body.currentPeriod).toBe(currentPeriodYearMonth());
expect(res.body.tokensIn).toBe(0);
expect(res.body.tokensOut).toBe(0);
expect(res.body.tokensTotal).toBe(0);
expect(res.body.tokensBudget).toBe(1000);
expect(res.body.remaining).toBe(1000);
expect(res.body.requestsThisMonth).toBe(0);
expect(res.body.history).toEqual([]);
// F9: rateRecentRequests was dead (always null, UI never read it)
// and is dropped from the wire shape entirely.
expect(res.body).not.toHaveProperty('rateRecentRequests');
});
it('reports current-period totals and remaining headroom', async () => {
const id = await seedAdminKeyWithBudget(1000);
repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 200, tokensOut: 300, requests: 5 });
const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
expect(res.body.tokensIn).toBe(200);
expect(res.body.tokensOut).toBe(300);
expect(res.body.tokensTotal).toBe(500);
expect(res.body.remaining).toBe(500);
expect(res.body.requestsThisMonth).toBe(5);
});
it('clamps remaining at 0 when over budget', async () => {
const id = await seedAdminKeyWithBudget(500);
repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 600, tokensOut: 100 });
const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
expect(res.body.tokensTotal).toBe(700);
expect(res.body.remaining).toBe(0);
});
it('returns null remaining for unlimited budget', async () => {
const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
repo.incrementGatewayKeyUsage({ keyId: created.body.id, period: currentPeriodYearMonth(), tokensIn: 9999 });
const res = await request(app).get(`/api/admin/gateway/keys/${created.body.id}/usage`);
expect(res.body.tokensBudget).toBeNull();
expect(res.body.remaining).toBeNull();
});
it('history contains older periods, ordered DESC, excluding current period', async () => {
const id = await seedAdminKeyWithBudget();
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-03', tokensIn: 10, requests: 1 });
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-04', tokensIn: 20, requests: 2 });
repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 30, requests: 3 });
const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
const periods = (res.body.history as Array<{ period: string }>).map(h => h.period);
expect(periods).toEqual(['2026-04', '2026-03']);
});
it('returns 404 for unknown id', async () => {
const res = await request(app).get(`/api/admin/gateway/keys/missing/usage`);
expect(res.status).toBe(404);
});
});
});
@@ -0,0 +1,136 @@
/**
* Phase 3b post-review — admin mutations drop per-key Prometheus
* gauge labels so the registry doesn't grow unbounded over the key
* lifecycle (issue → revoke → issue → revoke … leaves a permanent
* `budgetUsedRatio{team, key_prefix}` series for every dead key
* without this fix).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express, { type Request, type RequestHandler } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Registry } from 'prom-client';
import { Repository } from '../db/repository.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
import { createGatewayMetrics, type GatewayMetrics } from '../metrics/gateway-metrics.js';
function buildApp(repo: Repository, metrics: GatewayMetrics): express.Application {
const app = express();
app.use(express.json({ limit: '4kb' }));
const guard: RequestHandler = (req, _res, next) => {
(req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
next();
};
const router = createAdminGatewayApi({
repo,
requireAdmin: guard,
getUserId: (req) => {
const u = (req as Request & { user?: { id?: string } }).user;
return u?.id ?? null;
},
gatewayMetrics: metrics,
});
app.use('/api/admin/gateway/keys', router);
return app;
}
describe('admin-gateway-api metric label removal (Phase 3b post-review)', () => {
let tmpDir: string;
let repo: Repository;
let reg: Registry;
let metrics: GatewayMetrics;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-metrics-'));
repo = new Repository(join(tmpDir, 'test.db'));
reg = new Registry();
metrics = createGatewayMetrics(reg, 'aao_gateway_ml');
app = buildApp(repo, metrics);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it('revoke removes the budgetUsedRatio label for the revoked key', async () => {
const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
expect(create.status).toBe(201);
const id = create.body.id as string;
const prefix = id.slice(0, 8);
// Simulate the gauge being set as it would by the bootstrap recordUsage callback.
metrics.budgetUsedRatio.labels({ team: 'alpha', key_prefix: prefix }).set(0.42);
let dump = await reg.metrics();
expect(dump).toMatch(
new RegExp(`aao_gateway_ml_virtual_key_budget_used_ratio\\{team="alpha",key_prefix="${prefix}"\\} 0\\.42`),
);
// Now revoke.
const rev = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`);
expect(rev.status).toBe(200);
// After remove() the label series should no longer appear.
dump = await reg.metrics();
expect(dump).not.toMatch(new RegExp(`key_prefix="${prefix}"`));
});
it('rotate removes the OLD key prefix label (new key creates its own on next usage)', async () => {
const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' });
const oldId = create.body.id as string;
const oldPrefix = oldId.slice(0, 8);
metrics.budgetUsedRatio.labels({ team: 'beta', key_prefix: oldPrefix }).set(0.55);
let dump = await reg.metrics();
expect(dump).toMatch(new RegExp(`key_prefix="${oldPrefix}"`));
const rot = await request(app).post(`/api/admin/gateway/keys/${oldId}/rotate`);
expect(rot.status).toBe(201);
dump = await reg.metrics();
expect(dump).not.toMatch(new RegExp(`key_prefix="${oldPrefix}"`));
});
it('delete removes the label too', async () => {
const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'gamma' });
const id = create.body.id as string;
const prefix = id.slice(0, 8);
metrics.budgetUsedRatio.labels({ team: 'gamma', key_prefix: prefix }).set(0.7);
const del = await request(app).delete(`/api/admin/gateway/keys/${id}`);
expect(del.status).toBe(204);
const dump = await reg.metrics();
expect(dump).not.toMatch(new RegExp(`key_prefix="${prefix}"`));
});
it('revoke without an existing gauge label is a safe no-op', async () => {
const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'delta' });
const id = create.body.id as string;
// Don't pre-set the gauge — remove() of an unknown label is a noop.
const rev = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`);
expect(rev.status).toBe(200);
});
it('missing gatewayMetrics handle (cross-process deploy) does not block mutations', async () => {
// Build a separate app without a metrics handle to verify the
// admin API stays functional when the gateway runs in a different
// process.
const app2 = express();
app2.use(express.json({ limit: '4kb' }));
const guard: RequestHandler = (req, _res, next) => {
(req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
next();
};
const router = createAdminGatewayApi({
repo,
requireAdmin: guard,
getUserId: (req) => {
const u = (req as Request & { user?: { id?: string } }).user;
return u?.id ?? null;
},
// No gatewayMetrics. dropKeyMetricLabels is a no-op.
});
app2.use('/api/admin/gateway/keys', router);
const create = await request(app2).post('/api/admin/gateway/keys').send({ team: 'eps' });
expect(create.status).toBe(201);
const id = create.body.id as string;
const rev = await request(app2).post(`/api/admin/gateway/keys/${id}/revoke`);
expect(rev.status).toBe(200);
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* AAO Gateway Phase 2a — admin REST API integration tests.
*
* Covers:
* - POST issues fresh sk-aao-* with raw key once; subsequent GETs hide it
* - GET supports ?team= and ?activeOnly=true
* - Revoke is idempotent (409 on second call) and hides from active list
* - Rotate is atomic: new key active, old key revoked, raw key returned
* - DELETE rejects source='config-import'
* - requireAdmin guard blocks non-admin callers
* - Validation: team regex, allowedModels shape
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express, { type Request, type RequestHandler } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
function buildAppWithAdmin(repo: Repository, role: 'admin' | 'user' = 'admin'): express.Application {
const app = express();
app.use(express.json({ limit: '4kb' }));
// Stub admin guard inline; mirrors the auth flow without Passport.
const guard: RequestHandler = (req, res, next) => {
if (role !== 'admin') {
res.status(403).json({ error: 'Forbidden' });
return;
}
(req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
next();
};
const router = createAdminGatewayApi({
repo,
requireAdmin: guard,
getUserId: (req) => {
const u = (req as Request & { user?: { id?: string } }).user;
return u?.id ?? null;
},
});
app.use('/api/admin/gateway/keys', router);
return app;
}
describe('admin-gateway-api', () => {
let tmpDir: string;
let repo: Repository;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-test-'));
repo = new Repository(join(tmpDir, 'test.db'));
app = buildAppWithAdmin(repo);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
describe('POST /', () => {
it('issues a fresh key with sk-aao prefix and returns raw once', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
expect(res.status).toBe(201);
expect(res.body.team).toBe('alpha');
expect(res.body.source).toBe('admin');
expect(typeof res.body.key).toBe('string');
expect(res.body.key.startsWith('sk-aao-')).toBe(true);
expect(res.body.keyPrefix.startsWith('sk-aao-')).toBe(true);
expect(res.body.allowedModels).toBeNull();
// GET should not include raw key.
const list = await request(app).get('/api/admin/gateway/keys');
expect(list.body.keys[0].key).toBeUndefined();
});
it('validates team format', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'has spaces' });
expect(res.status).toBe(400);
});
it('accepts allowedModels and round-trips it', async () => {
const res = await request(app)
.post('/api/admin/gateway/keys')
.send({ team: 'alpha', allowedModels: ['qwen3:8b', 'qwen3:14b'] });
expect(res.status).toBe(201);
expect(res.body.allowedModels).toEqual(['qwen3:8b', 'qwen3:14b']);
});
it('rejects malformed allowedModels', async () => {
const res = await request(app)
.post('/api/admin/gateway/keys')
.send({ team: 'alpha', allowedModels: [42, ''] });
expect(res.status).toBe(400);
});
});
describe('GET / and GET /:id', () => {
it('lists with team filter and hides revoked when activeOnly=true', async () => {
const a = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' });
const old = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
await request(app).post(`/api/admin/gateway/keys/${old.id}/revoke`).send({});
const alphaAll = await request(app).get('/api/admin/gateway/keys?team=alpha');
expect(alphaAll.body.keys).toHaveLength(2);
const alphaActive = await request(app).get('/api/admin/gateway/keys?team=alpha&activeOnly=true');
expect(alphaActive.body.keys).toHaveLength(1);
expect(alphaActive.body.keys[0].id).toBe(a.id);
});
it('GET /:id returns 404 for unknown id', async () => {
const res = await request(app).get('/api/admin/gateway/keys/nope');
expect(res.status).toBe(404);
});
});
describe('POST /:id/revoke', () => {
it('revokes and is idempotent (409 second time)', async () => {
const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
const first = await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({});
expect(first.status).toBe(200);
expect(first.body.ok).toBe(true);
expect(first.body.revokedAt).toBeTruthy();
const second = await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({});
expect(second.status).toBe(409);
});
it('returns 404 for unknown id', async () => {
const res = await request(app).post('/api/admin/gateway/keys/nope/revoke').send({});
expect(res.status).toBe(404);
});
});
describe('POST /:id/rotate', () => {
it('atomically issues a new key and revokes the old', async () => {
const old = (await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
allowedModels: ['qwen3:8b'],
})).body;
const res = await request(app).post(`/api/admin/gateway/keys/${old.id}/rotate`).send({});
expect(res.status).toBe(201);
expect(typeof res.body.key).toBe('string');
expect(res.body.id).not.toBe(old.id);
expect(res.body.allowedModels).toEqual(['qwen3:8b']);
expect(res.body.team).toBe('alpha');
const oldRefetch = await request(app).get(`/api/admin/gateway/keys/${old.id}`);
expect(oldRefetch.body.revokedAt).toBeTruthy();
});
it('refuses to rotate a revoked key', async () => {
const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({});
const res = await request(app).post(`/api/admin/gateway/keys/${created.id}/rotate`).send({});
expect(res.status).toBe(409);
});
});
describe('DELETE /:id', () => {
it('hard-deletes an admin-issued key', async () => {
const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
const res = await request(app).delete(`/api/admin/gateway/keys/${created.id}`);
expect(res.status).toBe(204);
const after = await request(app).get(`/api/admin/gateway/keys/${created.id}`);
expect(after.status).toBe(404);
});
it('refuses to delete a config-import key', async () => {
const k = repo.createGatewayVirtualKey({
keyHash: 'cfg-hash',
keyPrefix: 'sk-conf-import',
team: 'imported',
source: 'config-import',
createdBy: 'config',
});
const res = await request(app).delete(`/api/admin/gateway/keys/${k.id}`);
expect(res.status).toBe(400);
expect(repo.findGatewayVirtualKeyById(k.id)).not.toBeNull();
});
});
describe('auth gating', () => {
it('non-admin caller receives 403', async () => {
const adminLess = buildAppWithAdmin(repo, 'user');
const res = await request(adminLess).get('/api/admin/gateway/keys');
expect(res.status).toBe(403);
});
});
describe('auth-disabled mount policy', () => {
// Server-level guard: createCoreServer refuses to mount this router
// when authActive=false because mounting it with a passthrough guard
// would let any anonymous caller mint valid sk-aao-* bearer tokens.
// We simulate the no-auth path by NOT mounting the router and
// asserting requests 404, matching the production behavior.
function buildAppWithoutAuth(): express.Application {
const app = express();
app.use(express.json({ limit: '4kb' }));
// Intentionally do NOT mount /api/admin/gateway/keys — this is the
// path server.ts takes when authActive===false.
return app;
}
it('returns 404 for POST when auth is disabled (route not mounted)', async () => {
const noAuthApp = buildAppWithoutAuth();
const res = await request(noAuthApp)
.post('/api/admin/gateway/keys')
.send({ team: 'alpha' });
expect(res.status).toBe(404);
});
it('returns 404 for GET when auth is disabled (route not mounted)', async () => {
const noAuthApp = buildAppWithoutAuth();
const res = await request(noAuthApp).get('/api/admin/gateway/keys');
expect(res.status).toBe(404);
});
it('returns 401 when auth IS active but caller is unauthenticated', async () => {
// With auth active, server.ts mounts requireAdmin (which 401s for
// missing user) BEFORE the router. We simulate that by wiring a
// requireAdmin that 401s without a user, then the router behind it.
const app = express();
app.use(express.json({ limit: '4kb' }));
const requireAdminLike: RequestHandler = (req, res, next) => {
const u = (req as Request & { user?: unknown }).user;
if (!u) {
res.status(401).json({ error: 'authentication required' });
return;
}
next();
};
app.use(
'/api/admin/gateway/keys',
requireAdminLike,
createAdminGatewayApi({
repo,
requireAdmin: (_req, _res, next) => next(),
getUserId: (req) =>
(req as Request & { user?: { id?: string } }).user?.id ?? null,
}),
);
const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
expect(res.status).toBe(401);
});
});
});
+554
View File
@@ -0,0 +1,554 @@
/**
* AAO Gateway Phase 2a — admin REST API for virtual key management.
*
* Mounted on worker-mode server.ts (not on gateway-mode server.ts; the
* gateway is intentionally read-only over auth state). Path prefix:
* /api/admin/gateway/keys
*
* Endpoint shape (see also docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md
* § Phase 2a / Admin REST API):
* POST / — issue (raw key returned ONCE here)
* GET / — list (raw key never returned)
* GET /:id — single (raw key never returned)
* POST /:id/revoke — soft delete
* POST /:id/rotate — atomic re-issue (new key returned, old revoked)
* DELETE /:id — hard delete (rejects source='config-import')
*
* Visibility: returned objects expose `keyPrefix` (sk-aao-XXXXXX) and
* meta only. The raw bearer is surfaced exactly by issue/rotate; lose
* it and you must rotate the key.
*/
import { Router, type RequestHandler, type Request } from 'express';
import type { Repository, GatewayVirtualKey } from '../db/repository.js';
import { generateVirtualKey } from '../gateway/key-format.js';
import { currentPeriodYearMonth } from '../gateway/period.js';
import type { KeyCache } from '../gateway/key-cache.js';
import type { GatewayMetrics } from '../metrics/gateway-metrics.js';
import { logger } from '../logger.js';
export interface AdminGatewayApiDeps {
repo: Repository;
/** Existing requireAdmin middleware (or a passthrough for auth-disabled deploys). */
requireAdmin: RequestHandler;
/**
* Extract the acting admin's user id from the request (for
* createdBy / revokedBy). Returns null when auth is disabled; the
* router records 'admin' in that case so audit history isn't blank.
*/
getUserId: (req: Request) => string | null;
/**
* Phase 3a F4: optional shared cache between auth + budget + rate
* middlewares. When wired by the same-process deployment, admin
* mutations (PATCH / revoke / rotate / delete) call cache.invalidate
* so the next request sees fresh state immediately. Cross-process
* setups fall back to the cache's 5s TTL.
*/
keyCache?: KeyCache;
/**
* Phase 3b post-review: optional gateway metrics handle. When wired,
* the revoke / rotate / delete handlers remove the
* `budgetUsedRatio{team, key_prefix}` series for the gone key — left
* in place those labels would grow unbounded over the key lifecycle.
*/
gatewayMetrics?: GatewayMetrics;
}
/** Wire-format DTO (camelCase JSON). Raw key is opt-in for issue/rotate. */
interface GatewayKeyDto {
id: string;
object: 'gateway.key';
keyPrefix: string;
team: string;
allowedModels: string[] | null;
source: GatewayVirtualKey['source'];
createdAt: string;
createdBy: string | null;
revokedAt: string | null;
revokedBy: string | null;
lastUsedAt: string | null;
/** Phase 2b: monthly tokens budget (null = unlimited). */
tokensBudget: number | null;
/** Phase 2b: per-minute requests cap (null = unlimited). */
rateLimitRpm: number | null;
/** Only present on POST / rotate responses. NEVER on list / get. */
key?: string;
}
function toDto(row: GatewayVirtualKey, raw?: string): GatewayKeyDto {
const dto: GatewayKeyDto = {
id: row.id,
object: 'gateway.key',
keyPrefix: row.keyPrefix,
team: row.team,
allowedModels: row.allowedModels,
source: row.source,
createdAt: row.createdAt,
createdBy: row.createdBy,
revokedAt: row.revokedAt,
revokedBy: row.revokedBy,
lastUsedAt: row.lastUsedAt,
tokensBudget: row.tokensBudget,
rateLimitRpm: row.rateLimitRpm,
};
if (raw !== undefined) dto.key = raw;
return dto;
}
/**
* Hard caps to keep policy values comfortably inside SQLite's INTEGER
* (54-bit) precision window. Past 2^53 numeric values get coerced to
* REAL on UPSERT arithmetic and start dropping low bits — so a stated
* budget like 1e20 would silently round and the running total could
* never reach it. Either limit is well beyond any sane real-world usage:
*
* - tokensBudget: 1 trillion tokens / month. At GPT-4-class pricing
* that's well over US$10M; if you legitimately need more, split keys.
* - rateLimitRpm: 1,000,000 requests / minute. The in-memory window
* array is bounded at 2× this, so the cap also keeps memory predictable.
*/
const MAX_TOKENS_BUDGET = 1_000_000_000_000;
const MAX_RATE_LIMIT_RPM = 1_000_000;
/**
* Parse a {tokensBudget?, rateLimitRpm?} pair from a request body.
* Returns the parsed values as positive integers, null (explicit
* unlimited), or undefined (don't touch).
*
* Strict: a number that's NaN / Infinity / negative is a 400 — we
* don't silently coerce because operators expect their stated limit
* to be applied. Floats are floored (sub-integer budgets are meaningless).
* Values above the hard cap are rejected — see MAX_* constants above
* for the rationale (SQLite INT-vs-REAL precision boundary).
*/
function parseLimitsPatch(body: { tokensBudget?: unknown; rateLimitRpm?: unknown } | undefined):
| { ok: true; tokensBudget?: number | null; rateLimitRpm?: number | null }
| { ok: false; error: string } {
const out: { tokensBudget?: number | null; rateLimitRpm?: number | null } = {};
if (body && Object.prototype.hasOwnProperty.call(body, 'tokensBudget')) {
const v = body.tokensBudget;
if (v === null) {
out.tokensBudget = null;
} else if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
if (v > MAX_TOKENS_BUDGET) {
return { ok: false, error: `tokensBudget exceeds maximum (${MAX_TOKENS_BUDGET})` };
}
out.tokensBudget = Math.floor(v);
} else {
return { ok: false, error: 'tokensBudget must be a positive integer or null' };
}
}
if (body && Object.prototype.hasOwnProperty.call(body, 'rateLimitRpm')) {
const v = body.rateLimitRpm;
if (v === null) {
out.rateLimitRpm = null;
} else if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
if (v > MAX_RATE_LIMIT_RPM) {
return { ok: false, error: `rateLimitRpm exceeds maximum (${MAX_RATE_LIMIT_RPM})` };
}
out.rateLimitRpm = Math.floor(v);
} else {
return { ok: false, error: 'rateLimitRpm must be a positive integer or null' };
}
}
return { ok: true, ...out };
}
const TEAM_REGEX = /^[a-zA-Z0-9._-]{1,64}$/;
const MAX_ALLOWED_MODELS = 64;
const MAX_MODEL_NAME_LEN = 128;
function parseAllowedModels(value: unknown): { ok: true; value: string[] | null } | { ok: false; error: string } {
if (value === undefined || value === null) return { ok: true, value: null };
if (!Array.isArray(value)) return { ok: false, error: 'allowedModels must be an array of strings' };
if (value.length > MAX_ALLOWED_MODELS) {
return { ok: false, error: `allowedModels supports at most ${MAX_ALLOWED_MODELS} entries` };
}
const out: string[] = [];
for (const m of value) {
if (typeof m !== 'string' || m.length === 0 || m.length > MAX_MODEL_NAME_LEN) {
return { ok: false, error: 'allowedModels entries must be non-empty strings ≤ 128 chars' };
}
out.push(m);
}
return { ok: true, value: out };
}
export function createAdminGatewayApi(deps: AdminGatewayApiDeps): Router {
const router = Router();
const { repo, requireAdmin, getUserId, keyCache, gatewayMetrics } = deps;
const actor = (req: Request): string => getUserId(req) ?? 'admin';
// Centralize cache invalidation so every mutation handler follows the
// same pattern: mutate first, invalidate second. Calling on a no-op
// cache (undefined) is a safe noop.
const invalidate = (id: string): void => {
try {
keyCache?.invalidate(id);
} catch (e) {
// Cache invalidation is best-effort; a thrown invalidate would
// most likely mean a bug in the cache, but we still don't want
// it to roll back the user-visible mutation.
logger.warn(`[admin-gateway] keyCache.invalidate threw for id=${id}: ${e instanceof Error ? e.message : String(e)}`);
}
};
// Phase 3b post-review: drop the per-key budget_used_ratio gauge label
// when the key goes away. Without this, every revoked key leaves a
// permanent {team, key_prefix} series in the registry — over enough
// rotations the label space grows without bound. Best-effort: a
// missing metrics handle (Phase 3b disabled), a label that was never
// set (key revoked before its first usage write), or a prom-client
// throw are all swallowed so admin mutation success isn't gated on
// metric bookkeeping.
const dropKeyMetricLabels = (row: { id: string; team: string }): void => {
if (!gatewayMetrics) return;
try {
const prefix = row.id.slice(0, 8);
gatewayMetrics.budgetUsedRatio.remove({ team: row.team, key_prefix: prefix });
} catch (e) {
logger.warn(
`[admin-gateway] metric label remove failed for id=${row.id}: ${e instanceof Error ? e.message : String(e)}`,
);
}
};
// POST / — issue a fresh sk-aao-* key. The raw value is returned in
// the response body once and never again.
router.post('/', requireAdmin, (req, res) => {
const body = req.body as {
team?: unknown;
allowedModels?: unknown;
tokensBudget?: unknown;
rateLimitRpm?: unknown;
} | undefined;
const team = typeof body?.team === 'string' ? body.team.trim() : '';
if (!team || !TEAM_REGEX.test(team)) {
res.status(400).json({ error: 'team must match /^[a-zA-Z0-9._-]{1,64}$/' });
return;
}
const allowed = parseAllowedModels(body?.allowedModels);
if (!allowed.ok) {
res.status(400).json({ error: allowed.error });
return;
}
const limits = parseLimitsPatch(body);
if (!limits.ok) {
res.status(400).json({ error: limits.error });
return;
}
const generated = generateVirtualKey();
let created: GatewayVirtualKey;
try {
created = repo.createGatewayVirtualKey({
keyHash: generated.hash,
keyPrefix: generated.prefix,
team,
allowedModels: allowed.value,
source: 'admin',
createdBy: actor(req),
// Phase 2b: optional budget / rate. Repository normalizer
// accepts both null and undefined as unlimited.
tokensBudget: limits.tokensBudget ?? null,
rateLimitRpm: limits.rateLimitRpm ?? null,
});
} catch (e) {
// randomBytes collision is mathematically negligible; any throw
// here is more likely a transient SQLite locking issue.
logger.warn(`[admin-gateway] create failed: ${e instanceof Error ? e.message : String(e)}`);
res.status(500).json({ error: 'failed to create key' });
return;
}
res.status(201).json(toDto(created, generated.raw));
});
// PATCH /:id — update policy fields (budget, rate limit, allowedModels).
// Bearer / team / source / created_by are immutable here. Refuses to
// touch config-import rows because those are managed via config.yaml
// (consistent with the DELETE rule).
router.patch('/:id', requireAdmin, (req, res) => {
const id = req.params['id']!;
// Parse + validate the body OUTSIDE the transaction so we don't pay
// the SQLite serialization cost on bad input.
const body = req.body as {
tokensBudget?: unknown;
rateLimitRpm?: unknown;
allowedModels?: unknown;
} | undefined;
const limits = parseLimitsPatch(body);
if (!limits.ok) {
res.status(400).json({ error: limits.error });
return;
}
const patch: {
tokensBudget?: number | null;
rateLimitRpm?: number | null;
allowedModels?: string[] | null;
} = {};
if (Object.prototype.hasOwnProperty.call(limits, 'tokensBudget')) patch.tokensBudget = limits.tokensBudget!;
if (Object.prototype.hasOwnProperty.call(limits, 'rateLimitRpm')) patch.rateLimitRpm = limits.rateLimitRpm!;
if (body && Object.prototype.hasOwnProperty.call(body, 'allowedModels')) {
if (body.allowedModels === null) {
patch.allowedModels = null;
} else {
const parsed = parseAllowedModels(body.allowedModels);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
patch.allowedModels = parsed.value;
}
}
if (Object.keys(patch).length === 0) {
res.status(400).json({ error: 'patch body must include at least one of tokensBudget, rateLimitRpm, allowedModels' });
return;
}
// Phase 3a follow-up: close the TOCTOU race between the "is the row
// revoked / config-import?" check and the UPDATE statement. Pre-fix
// the read + update lived outside any transaction; a concurrent
// revoke landing between the two would let PATCH overwrite a revoked
// row (silent ghost mutation in the audit log). Wrap both in a
// single better-sqlite3 transaction so the read and the conditional
// update are atomic, and signal the disallowed conditions back to
// the caller via typed sentinel errors.
//
// Sentinel error pattern (vs. structured return value): better-sqlite3
// transactions don't yet support typed Result returns, so we abuse
// the error channel — caller-side `instanceof` would be cleaner but
// string sentinels keep this contained to a single handler.
let updated: GatewayVirtualKey;
try {
updated = repo.getDb().transaction(() => {
const fresh = repo.findGatewayVirtualKeyById(id);
if (!fresh) throw new Error('PATCH_NOT_FOUND');
// Mirrors the rotate handler which also returns 409 for revoked.
// Pre-fix PATCH would silently update budget / rate / allowedModels
// on a row that can no longer authenticate — the new values
// would never apply to a real request and would mask audit
// history.
if (fresh.revokedAt !== null) {
const err = new Error('PATCH_REVOKED');
(err as Error & { revokedAt?: string }).revokedAt = fresh.revokedAt;
throw err;
}
if (fresh.source === 'config-import') throw new Error('PATCH_CONFIG_IMPORT');
return repo.updateGatewayVirtualKey(id, patch);
})();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'PATCH_NOT_FOUND') {
res.status(404).json({ error: 'key not found' });
return;
}
if (msg === 'PATCH_REVOKED') {
const revokedAt = (e as Error & { revokedAt?: string }).revokedAt ?? null;
res.status(409).json({ error: 'cannot modify a revoked key', revokedAt });
return;
}
if (msg === 'PATCH_CONFIG_IMPORT') {
res.status(400).json({
error:
"cannot PATCH a config-import key (manage tokens_budget / rate_limit_rpm / allowed_models via config.yaml's gateway.virtual_keys instead)",
});
return;
}
logger.warn(`[admin-gateway] patch failed for id=${id}: ${msg}`);
res.status(500).json({ error: 'patch failed' });
return;
}
// F4: drop the stale cache entry so the next auth/budget/rate
// middleware reads the fresh row from DB. The cache will repopulate
// on the next lookup (and stay coherent for 5s after that).
invalidate(id);
res.json(toDto(updated));
});
// GET /:id/usage — current month usage + budget headroom + recent rate
// burn rate + last 12 months of history. Single endpoint so the UI
// can render a key's detail panel in one round-trip.
router.get('/:id/usage', requireAdmin, (req, res) => {
const id = req.params['id']!;
const row = repo.findGatewayVirtualKeyById(id);
if (!row) {
res.status(404).json({ error: 'key not found' });
return;
}
const period = currentPeriodYearMonth();
const current = repo.getGatewayKeyUsage(id, period);
const tokensIn = current?.tokensIn ?? 0;
const tokensOut = current?.tokensOut ?? 0;
const tokensTotal = tokensIn + tokensOut;
const remaining = row.tokensBudget !== null ? Math.max(0, row.tokensBudget - tokensTotal) : null;
// History excludes the current period (UI shows it separately).
const allHistory = repo.listGatewayKeyUsagesByKey(id, { limit: 13 });
const history = allHistory
.filter(u => u.periodStart !== period)
.slice(0, 12)
.map(u => ({
period: u.periodStart,
tokensIn: u.tokensIn,
tokensOut: u.tokensOut,
requests: u.requests,
}));
// Phase 3a F9: the previous `rateRecentRequests: null` field was
// dead — the admin process and the gateway process are normally
// separate, so the live RateLimiter handle was unreachable, and
// the UI never displayed the value. Drop the field to keep the
// wire schema lean. Phase 3b/3c can re-introduce it once gateway
// IPC is in place.
res.json({
keyId: id,
currentPeriod: period,
tokensIn,
tokensOut,
tokensTotal,
tokensBudget: row.tokensBudget,
remaining,
requestsThisMonth: current?.requests ?? 0,
rateLimitRpm: row.rateLimitRpm,
history,
});
});
// GET / — list. Supports ?team= and ?activeOnly=true.
router.get('/', requireAdmin, (req, res) => {
const team = typeof req.query['team'] === 'string' ? req.query['team'] : undefined;
const activeOnly = req.query['activeOnly'] === 'true';
const rows = repo.listGatewayVirtualKeys({ team, activeOnly });
res.json({ keys: rows.map(r => toDto(r)) });
});
// GET /:id — single. Visible even when revoked so audit views work.
router.get('/:id', requireAdmin, (req, res) => {
const row = repo.findGatewayVirtualKeyById(req.params['id']!);
if (!row) {
res.status(404).json({ error: 'key not found' });
return;
}
res.json(toDto(row));
});
// POST /:id/revoke — soft delete. Idempotent: re-revoke is a 409 so
// callers can distinguish "already revoked" from "didn't exist".
router.post('/:id/revoke', requireAdmin, (req, res) => {
const id = req.params['id']!;
const row = repo.findGatewayVirtualKeyById(id);
if (!row) {
res.status(404).json({ error: 'key not found' });
return;
}
if (row.revokedAt !== null) {
res.status(409).json({ error: 'key already revoked', revokedAt: row.revokedAt });
return;
}
const ok = repo.revokeGatewayVirtualKey(id, actor(req));
if (!ok) {
// Lost a race with another revoke; refetch and return 409 for consistency.
const refreshed = repo.findGatewayVirtualKeyById(id);
// Invalidate even on the lost-race path: the cache might still
// hold the pre-revoke row from a hot lookup just before the race.
invalidate(id);
res.status(409).json({ error: 'key already revoked', revokedAt: refreshed?.revokedAt ?? null });
return;
}
// F4: a revoked key MUST NOT keep authenticating from the cache.
// The dbLookup wrapper additionally rejects cached rows with
// revokedAt !== null as defense-in-depth.
invalidate(id);
// Phase 3b post-review: also drop the per-key budgetUsedRatio
// gauge label so the prom-client registry doesn't grow unbounded
// over the key lifecycle.
dropKeyMetricLabels(row);
const refreshed = repo.findGatewayVirtualKeyById(id)!;
res.json({ ok: true, revokedAt: refreshed.revokedAt });
});
// POST /:id/rotate — atomic: issue a new key (inherits team +
// allowedModels), then revoke the old. Performed under a better-sqlite3
// transaction so a crash mid-flight can't leave both active.
router.post('/:id/rotate', requireAdmin, (req, res) => {
const id = req.params['id']!;
const old = repo.findGatewayVirtualKeyById(id);
if (!old) {
res.status(404).json({ error: 'key not found' });
return;
}
if (old.revokedAt !== null) {
res.status(409).json({ error: 'cannot rotate a revoked key' });
return;
}
const generated = generateVirtualKey();
const by = actor(req);
let created: GatewayVirtualKey;
try {
const tx = repo.getDb().transaction(() => {
const c = repo.createGatewayVirtualKey({
keyHash: generated.hash,
keyPrefix: generated.prefix,
team: old.team,
allowedModels: old.allowedModels,
source: 'admin',
createdBy: by,
});
repo.revokeGatewayVirtualKey(old.id, by);
return c;
});
created = tx();
} catch (e) {
logger.warn(`[admin-gateway] rotate failed: ${e instanceof Error ? e.message : String(e)}`);
res.status(500).json({ error: 'rotate failed' });
return;
}
// F4: drop the OLD key from the cache so the prior bearer can't
// re-auth. The newly-created row will be cache-warmed on its first
// hit; no need to pre-populate.
invalidate(old.id);
// Phase 3b post-review: the old key prefix is gone — drop its
// gauge label too. The new key will create its own label on first
// usage write.
dropKeyMetricLabels(old);
res.status(201).json(toDto(created, generated.raw));
});
// DELETE /:id — hard delete. config-import rows are protected: an
// operator should remove the entry from config.yaml instead so it
// doesn't get re-imported on the next boot. The Repository enforces
// the same rule (defense-in-depth) by throwing; we translate that to
// a 400 with a human-readable message instead of leaking a 500.
router.delete('/:id', requireAdmin, (req, res) => {
const id = req.params['id']!;
const row = repo.findGatewayVirtualKeyById(id);
if (!row) {
res.status(404).json({ error: 'key not found' });
return;
}
if (row.source === 'config-import') {
res.status(400).json({
error: "cannot delete a config-import key (remove the entry from config.yaml's gateway.virtual_keys, then restart, or POST /revoke instead)",
});
return;
}
try {
repo.deleteGatewayVirtualKey(id);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Repository's defense-in-depth guard catches the case where the
// row.source changes between our pre-check and the delete (race
// with another writer flipping source via some future code path).
if (/config-import/i.test(msg)) {
res.status(400).json({ error: msg });
return;
}
logger.warn(`[admin-gateway] delete failed: ${msg}`);
res.status(500).json({ error: 'delete failed' });
return;
}
// F4: hard delete must also wipe the cache — the bearer should
// fail-auth on the next request, not after the TTL.
invalidate(id);
// Phase 3b post-review: drop metric label too. Same rationale as
// revoke/rotate — keep registry bounded.
dropKeyMetricLabels(row);
res.status(204).end();
});
return router;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Phase 3c — read-only admin endpoint for the same-process gateway
* mount status.
*
* The Gateway Server settings page polls this to render the status
* badge (running / disabled / starting / etc.) and surface any
* validation errors blocking a start. There is no PATCH/POST here —
* enabling / disabling the gateway is done through the existing
* /api/config PUT (the config-changed event picks up the new value
* and the mount handle reacts).
*/
import { Router, type Request, type Response } from 'express';
import type { GatewayMountHandle } from './gateway-mount.js';
import type { ConfigManager } from '../config-manager.js';
import { readGatewayConfig } from '../gateway/config.js';
export interface AdminGatewayStatusDeps {
/**
* Mount handle from createCoreServer. May be null when the bridge
* was created without a ConfigManager — in that case the endpoint
* reports the gateway as `unavailable` so the UI knows hot reload
* isn't supported in this deploy.
*/
mount: GatewayMountHandle | null;
/** ConfigManager so we can read the current desired-enabled flag. */
configManager: ConfigManager | null;
/**
* Port the worker bridge is listening on. Reported to the UI so the
* Gateway Server form can show `mounted at /v1 (port 9876)` instead
* of forcing the user to remember the port.
*/
workerPort: number;
}
export function createAdminGatewayStatusRouter(deps: AdminGatewayStatusDeps): Router {
const router = Router();
router.get('/', (_req: Request, res: Response) => {
const desiredEnabled = (() => {
try {
if (!deps.configManager) return null;
return readGatewayConfig(deps.configManager.getConfig()).enabled;
} catch {
return null;
}
})();
if (!deps.mount) {
res.json({
state: 'unavailable',
enabled: desiredEnabled,
errors: [],
mounted: false,
sharedPort: deps.workerPort,
message: 'gateway hot-reload unsupported in this deploy (no ConfigManager)',
});
return;
}
res.json({
state: deps.mount.getState(),
enabled: desiredEnabled,
errors: deps.mount.getErrors(),
mounted: deps.mount.getState() === 'running',
// The gateway runs on the same TCP port as the worker UI in
// same-process mode. The UI uses this to hint the user.
sharedPort: deps.workerPort,
});
});
return router;
}
+335
View File
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ログイン - {{APP_NAME}}</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.container {
display: flex;
min-height: 100vh;
}
/* LEFT PANEL */
.left-panel {
flex: 1;
background: linear-gradient(135deg, #1e40af 0%, #4f46e5 50%, #7c3aed 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 40px;
color: white;
}
.left-panel .logo-icon {
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.left-panel .logo-icon svg {
width: 36px;
height: 36px;
}
.left-panel h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 12px;
text-align: center;
}
.left-panel .tagline {
font-size: 0.95rem;
opacity: 0.85;
text-align: center;
line-height: 1.6;
max-width: 320px;
}
.left-panel .feature-list {
margin-top: 40px;
list-style: none;
display: flex;
flex-direction: column;
gap: 14px;
}
.left-panel .feature-list li {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.875rem;
opacity: 0.9;
}
.left-panel .feature-list li::before {
content: '';
display: block;
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.7);
border-radius: 50%;
flex-shrink: 0;
}
/* RIGHT PANEL */
.right-panel {
flex: 1;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 40px;
}
.login-box {
width: 100%;
max-width: 380px;
}
.login-box h2 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 8px;
}
.login-box .subtitle {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 36px;
}
.oauth-button {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
padding: 12px 20px;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
transition: all 0.15s ease;
cursor: pointer;
margin-bottom: 12px;
}
.oauth-button:last-child {
margin-bottom: 0;
}
.oauth-button-google {
background: #ffffff;
color: #1f2937;
border: 1.5px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.oauth-button-google:hover {
background: #f1f5f9;
border-color: #cbd5e1;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.oauth-button-gitea {
background: #2d8a4e;
color: #ffffff;
border: 1.5px solid transparent;
box-shadow: 0 1px 3px rgba(45, 138, 78, 0.3);
}
.oauth-button-gitea:hover {
background: #256e3e;
box-shadow: 0 2px 6px rgba(45, 138, 78, 0.4);
}
.oauth-button .btn-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
color: #94a3b8;
font-size: 0.8125rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
.footer-note {
margin-top: 32px;
font-size: 0.8125rem;
color: #94a3b8;
text-align: center;
line-height: 1.5;
}
/* RESPONSIVE */
@media (max-width: 768px) {
.container {
flex-direction: column;
}
.left-panel {
padding: 40px 24px;
min-height: auto;
}
.left-panel h1 {
font-size: 1.5rem;
}
.left-panel .feature-list {
display: none;
}
.right-panel {
padding: 40px 24px;
background: #ffffff;
}
}
/* DARK MODE */
@media (prefers-color-scheme: dark) {
.right-panel {
background: #0f172a;
}
.login-box h2 {
color: #f1f5f9;
}
.login-box .subtitle {
color: #94a3b8;
}
.oauth-button-google {
background: #1e293b;
color: #e2e8f0;
border-color: #334155;
}
.oauth-button-google:hover {
background: #273549;
border-color: #475569;
}
.divider {
color: #475569;
}
.divider::before,
.divider::after {
background: #1e293b;
}
.footer-note {
color: #475569;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Left Panel -->
<div class="left-panel">
<div class="logo-icon">
<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="8" stroke="white" stroke-width="2.5"/>
<line x1="18" y1="4" x2="18" y2="10" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<line x1="18" y1="26" x2="18" y2="32" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<line x1="4" y1="18" x2="10" y2="18" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<line x1="26" y1="18" x2="32" y2="18" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="18" cy="18" r="3" fill="white"/>
</svg>
</div>
<h1>{{LOGIN_PAGE_TITLE}}</h1>
<p class="tagline">AIエージェントによるタスク自動実行プラットフォーム</p>
<ul class="feature-list">
<li>マルチエージェントによる並列タスク処理</li>
<li>LLM駆動のReActループ</li>
<li>柔軟なワークフロー定義(Piece/Movement</li>
<li>ローカルファーストな設計</li>
</ul>
</div>
<!-- Right Panel -->
<div class="right-panel">
<div class="login-box">
<h2>ログイン</h2>
<p class="subtitle">アカウントでサインインしてください</p>
<!-- GOOGLE_BUTTON_START -->
<!-- Google OAuth Button -->
<a href="/auth/google" class="oauth-button oauth-button-google">
<span class="btn-icon">
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M19.6 10.23c0-.68-.06-1.36-.18-2H10v3.78h5.4a4.62 4.62 0 01-2 3.04v2.52h3.24c1.9-1.75 2.96-4.32 2.96-7.34z" fill="#4285F4"/>
<path d="M10 20c2.7 0 4.96-.9 6.62-2.43l-3.24-2.52c-.9.6-2.04.96-3.38.96-2.6 0-4.8-1.76-5.58-4.12H1.08v2.6A9.99 9.99 0 0010 20z" fill="#34A853"/>
<path d="M4.42 11.89A6.02 6.02 0 014.1 10c0-.65.12-1.29.32-1.89V5.51H1.08A9.99 9.99 0 000 10c0 1.61.38 3.14 1.08 4.49l3.34-2.6z" fill="#FBBC05"/>
<path d="M10 3.96c1.46 0 2.78.5 3.82 1.5l2.84-2.84C14.96.9 12.7 0 10 0A9.99 9.99 0 001.08 5.51l3.34 2.6C5.2 5.72 7.4 3.96 10 3.96z" fill="#EA4335"/>
</svg>
</span>
Google でログイン
</a>
<!-- GOOGLE_BUTTON_END -->
<!-- DIVIDER_START -->
<div class="divider">または</div>
<!-- DIVIDER_END -->
<!-- GITEA_BUTTON_START -->
<!-- Gitea OAuth Button -->
<a href="/auth/gitea" class="oauth-button oauth-button-gitea">
<span class="btn-icon">
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" fill="white">
<path d="M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm0 2c1.54 0 2.97.44 4.18 1.2L3.2 14.18A7.96 7.96 0 012 10c0-4.42 3.58-8 8-8zm0 16a7.96 7.96 0 01-4.18-1.2L16.8 5.82A7.96 7.96 0 0118 10c0 4.42-3.58 8-8 8z"/>
</svg>
</span>
Gitea でログイン
</a>
<!-- GITEA_BUTTON_END -->
<p class="footer-note">
ログインすることで、利用規約とプライバシーポリシーに<br>同意したものとみなされます。
</p>
</div>
</div>
</div>
</body>
</html>
+221
View File
@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>承認待ち - MAESTRO</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
body {
min-height: 100vh;
background: linear-gradient(135deg, #1e40af 0%, #4f46e5 50%, #7c3aed 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: #ffffff;
border-radius: 16px;
padding: 48px 40px;
max-width: 440px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.icon-wrapper {
width: 72px;
height: 72px;
background: #fef3c7;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.icon-wrapper svg {
width: 36px;
height: 36px;
color: #d97706;
}
h1 {
font-size: 1.625rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 12px;
}
.message {
font-size: 0.9375rem;
color: #475569;
line-height: 1.7;
margin-bottom: 32px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: #fef9ec;
color: #92400e;
border: 1px solid #fde68a;
border-radius: 20px;
padding: 6px 16px;
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 32px;
}
.status-badge .dot {
width: 7px;
height: 7px;
background: #f59e0b;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.85); }
}
.logout-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 28px;
background: transparent;
color: #64748b;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
transition: all 0.15s ease;
}
.logout-button:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #475569;
}
.logout-button svg {
width: 16px;
height: 16px;
}
.footer {
margin-top: 36px;
font-size: 0.8125rem;
color: #94a3b8;
}
/* DARK MODE */
@media (prefers-color-scheme: dark) {
.card {
background: #1e293b;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
h1 {
color: #f1f5f9;
}
.message {
color: #94a3b8;
}
.status-badge {
background: #1c1a0e;
color: #fbbf24;
border-color: #4a3800;
}
.logout-button {
color: #94a3b8;
border-color: #334155;
}
.logout-button:hover {
background: #0f172a;
border-color: #475569;
color: #cbd5e1;
}
.footer {
color: #475569;
}
}
</style>
</head>
<body>
<div class="card">
<!-- Icon -->
<div class="icon-wrapper">
<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="16" stroke="#d97706" stroke-width="2.5"/>
<line x1="18" y1="10" x2="18" y2="20" stroke="#d97706" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="18" cy="25" r="1.5" fill="#d97706"/>
</svg>
</div>
<!-- Title -->
<h1>アカウント承認待ち</h1>
<!-- Message -->
<p class="message">
管理者がアカウントを承認するまでお待ちください。<br>
承認後、自動的にサービスをご利用いただけます。
</p>
<!-- Status Badge -->
<div class="status-badge">
<span class="dot"></span>
承認待ち
</div>
<!-- Logout Button -->
<div>
<a href="/auth/logout" class="logout-button">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 14H3a1 1 0 01-1-1V3a1 1 0 011-1h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M10 11l3-3-3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="13" y1="8" x2="6" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
ログアウト
</a>
</div>
<p class="footer">MAESTRO</p>
</div>
<script>
setInterval(async () => {
try {
const res = await fetch('/auth/status');
const data = await res.json();
if (data.status === 'active') {
window.location.href = '/';
} else if (data.status === 'unauthenticated') {
window.location.href = '/auth/login';
}
} catch (e) { /* ignore network errors */ }
}, 5000);
</script>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { requireAuth, requireAdmin, fetchGiteaOrgsForUser } from './auth.js';
import type { Request, Response, NextFunction } from 'express';
function mockReqRes(overrides: Partial<Request> = {}) {
const req = {
isAuthenticated: () => false,
user: undefined,
originalUrl: '/api/test',
headers: { accept: 'application/json' },
...overrides,
} as unknown as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
redirect: vi.fn().mockReturnThis(),
} as unknown as Response;
const next = vi.fn() as NextFunction;
return { req, res, next };
}
describe('requireAuth', () => {
it('calls next() for authenticated active user', () => {
const { req, res, next } = mockReqRes({
isAuthenticated: () => true,
user: { id: '1', role: 'user', status: 'active' },
} as Partial<Request>);
requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
});
it('returns 401 for unauthenticated API request', () => {
const { req, res, next } = mockReqRes();
requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('redirects to /auth/login for unauthenticated non-API request', () => {
const { req, res, next } = mockReqRes({
originalUrl: '/ui',
headers: { accept: 'text/html' },
} as Partial<Request>);
requireAuth(req, res, next);
expect(res.redirect).toHaveBeenCalledWith('/auth/login');
});
});
describe('requireAdmin', () => {
it('calls next() for admin user', () => {
const { req, res, next } = mockReqRes({
isAuthenticated: () => true,
user: { id: '1', role: 'admin', status: 'active' },
} as Partial<Request>);
requireAdmin(req, res, next);
expect(next).toHaveBeenCalled();
});
it('returns 403 for non-admin user', () => {
const { req, res, next } = mockReqRes({
isAuthenticated: () => true,
user: { id: '1', role: 'user', status: 'active' },
} as Partial<Request>);
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('returns 401 for unauthenticated request', () => {
const { req, res, next } = mockReqRes();
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
});
describe('fetchGiteaOrgsForUser', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
vi.restoreAllMocks();
});
function makeRepo(): Repository {
tempDir = mkdtempSync(join(tmpdir(), 'auth-test-'));
return new Repository(join(tempDir, 'db.sqlite'));
}
it('fetches and persists user orgs from Gitea API', async () => {
const repo = makeRepo();
try {
const user = repo.createUser({
email: '[email protected]', name: 'alice', role: 'user', status: 'active',
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true,
json: async () => [
{ id: 10, username: 'marketing' },
{ id: 20, username: 'platform' },
],
} as Response);
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token-xyz');
expect(orgIds.sort()).toEqual(['10', '20']);
expect(repo.listUserGiteaOrgs(user.id).map(o => o.orgName).sort())
.toEqual(['marketing', 'platform']);
} finally {
repo.close();
}
});
it('returns empty array on !res.ok', async () => {
const repo = makeRepo();
try {
const user = repo.createUser({
email: '[email protected]', name: 'alice', role: 'user', status: 'active',
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false, status: 401,
} as Response);
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'bad-token');
expect(orgIds).toEqual([]);
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
} finally {
repo.close();
}
});
it('returns empty array on fetch rejection (network error)', async () => {
const repo = makeRepo();
try {
const user = repo.createUser({
email: '[email protected]', name: 'alice', role: 'user', status: 'active',
});
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED'));
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
expect(orgIds).toEqual([]);
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
} finally {
repo.close();
}
});
it('clears stale cached orgs when fetch fails (prevents permission lag after org removal)', async () => {
const repo = makeRepo();
try {
const user = repo.createUser({
email: '[email protected]', name: 'alice', role: 'user', status: 'active',
});
repo.replaceUserGiteaOrgs(user.id, [
{ orgId: '10', orgName: 'marketing' },
{ orgId: '20', orgName: 'platform' },
]);
expect(repo.listUserGiteaOrgs(user.id)).toHaveLength(2);
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: false, status: 503 } as Response);
await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
repo.replaceUserGiteaOrgs(user.id, [{ orgId: '10', orgName: 'marketing' }]);
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED'));
await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
} finally {
repo.close();
}
});
});
+636
View File
@@ -0,0 +1,636 @@
import { readFileSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import type { Request, Response, NextFunction, RequestHandler, Router } from 'express';
import type { IncomingMessage } from 'http';
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import type { Database } from 'better-sqlite3';
import type { AuthConfig } from '../config.js';
import type { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
/**
* WebSocket upgrade(生 IncomingMessage)から認証済みユーザーを解決するチェッカー。
* Express の上では express-session + passport が自動でこれを担うが、
* server.on('upgrade', ...) は middleware を素通しするので個別に呼ぶ必要がある。
*/
export type UpgradeAuthChecker = (req: IncomingMessage) => Promise<Express.User | null>;
// ── Login Page Renderer ──────────────────────────────────────────────────────
const __authDirname = path.dirname(fileURLToPath(import.meta.url));
export interface LoginBranding {
appName: string;
loginPageTitle: string;
}
const DEFAULT_LOGIN_BRANDING: LoginBranding = {
appName: 'MAESTRO',
loginPageTitle: 'MAESTRO',
};
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* auth-login.html をレンダリングする。
* primary_provider 設定と各プロバイダの configured 状態に応じて
* Google/Gitea ボタンおよび divider を表示/非表示する。
* branding が指定されていれば {{APP_NAME}} / {{LOGIN_PAGE_TITLE}} を差し替える。
*/
function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string {
const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
const primary = authConfig.primaryProvider;
const googleConfigured = !!authConfig.providers.google?.clientId;
const giteaConfigured = !!authConfig.providers.gitea?.clientId;
// Decide which buttons to show
let showGoogle: boolean;
let showGitea: boolean;
if (primary === 'google') {
showGoogle = googleConfigured;
showGitea = false;
} else if (primary === 'gitea') {
showGoogle = false;
showGitea = giteaConfigured;
} else {
// No primary specified: show every configured provider
showGoogle = googleConfigured;
showGitea = giteaConfigured;
}
const stripBlock = (html: string, startMarker: string, endMarker: string): string => {
const re = new RegExp(`<!--\\s*${startMarker}\\s*-->[\\s\\S]*?<!--\\s*${endMarker}\\s*-->`, 'g');
return html.replace(re, '');
};
let out = raw;
if (!showGoogle) out = stripBlock(out, 'GOOGLE_BUTTON_START', 'GOOGLE_BUTTON_END');
if (!showGitea) out = stripBlock(out, 'GITEA_BUTTON_START', 'GITEA_BUTTON_END');
// Hide divider unless both buttons are visible
if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', 'DIVIDER_END');
// Branding placeholders
out = out
.replace(/\{\{APP_NAME\}\}/g, escapeHtml(branding.appName))
.replace(/\{\{LOGIN_PAGE_TITLE\}\}/g, escapeHtml(branding.loginPageTitle));
return out;
}
// ── Global type augmentation ─────────────────────────────────────────────────
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface User {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
role: 'admin' | 'user';
status: 'active' | 'pending' | 'disabled';
orgIds: string[];
defaultVisibility: 'private' | 'org' | 'public';
defaultVisibilityOrgId: string | null;
}
}
}
// ── Middleware ────────────────────────────────────────────────────────────────
/**
* requireAuth: 認証済みかつ status=active のユーザーのみ通過させる。
* API リクエスト(/api/ プレフィックス)には 401 JSON を返す。
* それ以外のリクエストは /auth/login にリダイレクトする。
*/
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (req.isAuthenticated() && req.user && (req.user as Express.User).status === 'active') {
next();
return;
}
if (req.originalUrl.startsWith('/api/')) {
res.status(401).json({ error: 'Unauthorized' });
} else {
res.redirect('/auth/login');
}
}
/**
* requireAdmin: admin ロールのユーザーのみ通過させる。
* 未認証の場合は requireAuth と同じ挙動(401 or redirect)。
* 認証済みだが admin でない場合は 403 を返す。
*/
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.isAuthenticated() || !req.user) {
if (req.originalUrl.startsWith('/api/')) {
res.status(401).json({ error: 'Unauthorized' });
} else {
res.redirect('/auth/login');
}
return;
}
const user = req.user as Express.User;
if (user.role !== 'admin') {
res.status(403).json({ error: 'Forbidden' });
return;
}
next();
}
// ── SQLite Session Store ──────────────────────────────────────────────────────
/**
* Repository の SQLite DB を使ったカスタムセッションストア。
* sessions テーブル (sid, sess, expired) を直接操作する。
*/
function createSqliteSessionStore(db: Database): session.Store {
// session.Store の基底クラスを継承
const Store = session.Store as unknown as new () => session.Store;
class SqliteStore extends Store {
get(sid: string, callback: (err: unknown, session?: session.SessionData | null) => void): void {
try {
const row = db
.prepare('SELECT sess, expired FROM sessions WHERE sid = ?')
.get(sid) as { sess: string; expired: string } | undefined;
if (!row) {
callback(null, null);
return;
}
// 期限切れチェック
if (new Date(row.expired) <= new Date()) {
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
callback(null, null);
return;
}
const sessionData = JSON.parse(row.sess) as session.SessionData;
callback(null, sessionData);
} catch (err) {
callback(err);
}
}
set(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
try {
const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000;
const expired = new Date(Date.now() + ttl).toISOString();
const sess = JSON.stringify(sessionData);
db.prepare(`
INSERT INTO sessions (sid, sess, expired)
VALUES (?, ?, ?)
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expired = excluded.expired
`).run(sid, sess, expired);
callback?.();
} catch (err) {
callback?.(err);
}
}
destroy(sid: string, callback?: (err?: unknown) => void): void {
try {
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
callback?.();
} catch (err) {
callback?.(err);
}
}
touch(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
try {
const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000;
const expired = new Date(Date.now() + ttl).toISOString();
db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid);
callback?.();
} catch (err) {
callback?.(err);
}
}
}
return new SqliteStore();
}
// ── OAuth Callback ────────────────────────────────────────────────────────────
/**
* OAuth コールバック共通処理。
* email から findOrCreateUserByOAuth を呼び出し、
* adminEmails に一致する pending ユーザーは自動で admin に昇格する。
*/
async function handleOAuthCallback(
repo: Repository,
adminEmails: string[],
provider: string,
providerId: string,
email: string,
name: string,
avatarUrl: string | undefined,
done: (err: unknown, user?: Express.User | false) => void
): Promise<void> {
try {
let user = repo.findOrCreateUserByOAuth({
provider,
providerId,
email,
name,
avatarUrl,
});
// adminEmails に一致する pending ユーザーを自動昇格
if (user.status === 'pending' && adminEmails.includes(email)) {
repo.updateUser(user.id, { status: 'active', role: 'admin' });
const updated = repo.getUserById(user.id);
if (updated) user = updated;
}
// deserializeUser will enrich with orgIds + defaults on subsequent requests.
const sessionUser: Express.User = {
...user,
orgIds: [],
defaultVisibility: user.defaultVisibility ?? 'private',
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
};
done(null, sessionUser);
} catch (err) {
done(err);
}
}
// ── Gitea Orgs Fetch ─────────────────────────────────────────────────────────
/**
* Gitea の /api/v1/user/orgs を呼び出してユーザーの所属 org 一覧を取得し、
* Repository に永続化する。返り値は org ID の文字列配列。
* 失敗時は空配列を返し、警告ログを出力する(認証フロー自体は継続)。
*/
export async function fetchGiteaOrgsForUser(
repo: Repository,
userId: string,
baseUrl: string,
accessToken: string,
): Promise<string[]> {
let res: globalThis.Response;
try {
res = await fetch(`${baseUrl}/api/v1/user/orgs`, {
headers: { Authorization: `token ${accessToken}`, Accept: 'application/json' },
});
} catch (err) {
console.warn(`[auth] gitea orgs fetch error: ${(err as Error).message}`);
// Clear stale cache: if we can't confirm membership, don't keep old grants around.
repo.replaceUserGiteaOrgs(userId, []);
return [];
}
if (!res.ok) {
console.warn(`[auth] gitea orgs fetch failed: ${res.status}`);
repo.replaceUserGiteaOrgs(userId, []);
return [];
}
const orgs = (await res.json()) as Array<{ id: number; username: string }>;
const items = orgs.map(o => ({ orgId: String(o.id), orgName: o.username }));
repo.replaceUserGiteaOrgs(userId, items);
return items.map(i => i.orgId);
}
// ── Strategy Registration ─────────────────────────────────────────────────────
function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void {
const googleConfig = authConfig.providers.google;
if (!googleConfig) return;
passport.use(
new GoogleStrategy(
{
clientID: googleConfig.clientId,
clientSecret: googleConfig.clientSecret,
callbackURL: googleConfig.callbackUrl,
},
async (_accessToken, _refreshToken, profile, done) => {
const email = profile.emails?.[0]?.value ?? '';
const name = profile.displayName ?? '';
const avatarUrl = profile.photos?.[0]?.value;
await handleOAuthCallback(
repo,
authConfig.adminEmails,
'google',
profile.id,
email,
name,
avatarUrl,
done as (err: unknown, user?: Express.User | false) => void
);
}
)
);
}
function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
const giteaConfig = authConfig.providers.gitea;
if (!giteaConfig) return;
const baseUrl = giteaConfig.baseUrl ?? '';
passport.use(
'gitea',
new OAuth2Strategy(
{
authorizationURL: `${baseUrl}/login/oauth/authorize`,
tokenURL: `${baseUrl}/login/oauth/access_token`,
clientID: giteaConfig.clientId,
clientSecret: giteaConfig.clientSecret,
callbackURL: giteaConfig.callbackUrl,
},
async (accessToken: string, _refreshToken: string, _params: unknown, _profile: unknown, done: (err: unknown, user?: Express.User | false) => void) => {
try {
// Gitea 専用: アクセストークンでユーザー情報を取得
const response = await fetch(`${baseUrl}/api/v1/user`, {
headers: {
Authorization: `token ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
done(new Error(`Gitea userinfo fetch failed: ${response.status}`));
return;
}
const profile = await response.json() as {
id: number;
login: string;
email: string;
full_name?: string;
avatar_url?: string;
};
const email = profile.email && profile.email.length > 0
? profile.email
: `${profile.login}@gitea.local`;
// Gitea returns full_name="" when the user hasn't set it; `??` would
// keep that empty string, so use `||` to fall through to the login.
const name = profile.full_name || profile.login || '';
const avatarUrl = profile.avatar_url;
// Gitea verify は handleOAuthCallback をインライン化:
// accessToken/baseUrl がこのスコープでしか得られないため、
// user を確定させた後 fetchGiteaOrgsForUser を呼んで orgs を永続化する。
let user = repo.findOrCreateUserByOAuth({
provider: 'gitea',
providerId: String(profile.id),
email,
name,
avatarUrl,
});
if (user.status === 'pending' && authConfig.adminEmails.includes(email)) {
repo.updateUser(user.id, { status: 'active', role: 'admin' });
const updated = repo.getUserById(user.id);
if (updated) user = updated;
}
await fetchGiteaOrgsForUser(repo, user.id, baseUrl, accessToken);
const orgIds = repo.listUserGiteaOrgs(user.id).map(o => o.orgId);
const sessionUser: Express.User = {
...user,
orgIds,
defaultVisibility: user.defaultVisibility ?? 'private',
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
};
done(null, sessionUser);
} catch (err) {
done(err);
}
}
)
);
}
// ── Auth Router ───────────────────────────────────────────────────────────────
function createAuthRouter(
authConfig: AuthConfig,
getBranding?: () => LoginBranding,
): Router {
const router = express.Router();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ログインページ
router.get('/login', (_req: Request, res: Response) => {
const branding = getBranding ? getBranding() : DEFAULT_LOGIN_BRANDING;
res.type('html').send(renderLoginPage(authConfig, branding));
});
// 承認待ちページ(承認済みなら自動リダイレクト)
router.get('/pending', (req, res) => {
if (req.isAuthenticated() && (req.user as Express.User).status === 'active') {
res.redirect('/');
return;
}
res.sendFile(path.join(__dirname, 'auth-pending.html'));
});
// ステータス確認エンドポイント(承認待ちページのポーリング用)
router.get('/status', (req, res) => {
if (!req.isAuthenticated() || !req.user) {
res.json({ status: 'unauthenticated' });
return;
}
res.json({ status: (req.user as Express.User).status });
});
// Google OAuth
if (authConfig.providers.google) {
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
router.get(
'/google/callback',
passport.authenticate('google', { failureRedirect: '/auth/login' }),
(req, res) => {
const user = req.user as Express.User | undefined;
if (user?.status === 'active') {
res.redirect('/');
} else {
res.redirect('/auth/pending');
}
}
);
}
// Gitea OAuth
if (authConfig.providers.gitea) {
router.get('/gitea', passport.authenticate('gitea'));
router.get(
'/gitea/callback',
passport.authenticate('gitea', { failureRedirect: '/auth/login' }),
(req, res) => {
const user = req.user as Express.User | undefined;
if (user?.status === 'active') {
res.redirect('/');
} else {
res.redirect('/auth/pending');
}
}
);
}
// ログアウト
router.get('/logout', (req, res, next) => {
req.logout((err) => {
if (err) {
next(err);
return;
}
res.redirect('/auth/login');
});
});
return router;
}
// ── setupAuth ─────────────────────────────────────────────────────────────────
export interface AuthMiddlewares {
sessionMiddleware: RequestHandler;
passportInit: RequestHandler;
passportSession: RequestHandler;
authRouter: Router;
/**
* Raw HTTP upgradeWebSocket)リクエストから認証済みユーザーを解決する。
* Cookie → セッション → Passport deserialize の順に通し、最終的な req.user を返す。
* 認証されていなければ null を返す。
*/
authenticateUpgrade: UpgradeAuthChecker;
}
/**
* 認証モジュールのセットアップ。
* セッション、Passport、OAuth ストラテジーを設定し、
* ミドルウェアと認証ルーターを返す。
*/
export function setupAuth(
repo: Repository,
authConfig: AuthConfig,
getBranding?: () => LoginBranding,
): AuthMiddlewares {
const db = repo.getDb();
// セッションミドルウェア
const sessionMiddleware = session({
secret: authConfig.sessionSecret,
resave: false,
saveUninitialized: false,
store: createSqliteSessionStore(db),
cookie: {
secure: authConfig.secureCookie,
maxAge: authConfig.sessionMaxAge,
},
});
// Passport シリアライズ/デシリアライズ
passport.serializeUser((user: Express.User, done) => {
done(null, user.id);
});
passport.deserializeUser((id: string, done) => {
try {
const baseUser = repo.getUserById(id);
if (!baseUser) { done(null, false); return; }
const orgs = repo.listUserGiteaOrgs(id);
const enriched: Express.User = {
...baseUser,
orgIds: orgs.map(o => o.orgId),
defaultVisibility: baseUser.defaultVisibility ?? 'private',
defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
};
done(null, enriched);
} catch (err) {
done(err);
}
});
// OAuth ストラテジー登録
registerGoogleStrategy(repo, authConfig);
registerGiteaStrategy(repo, authConfig);
// 認証ルーター
const authRouter = createAuthRouter(authConfig, getBranding);
const passportInit = passport.initialize();
const passportSession = passport.session();
// 生 upgrade リクエスト用の認証チェッカー。
// sessionMiddleware → passportInit → passportSession を順に走らせ、req.user を populate する。
// 失敗時は null を返し、呼び出し側で socket.destroy() する想定。
// 各 middleware が next(err) を呼んだ場合(session store 障害・deserialize 失敗等)は
// ログを出してから null を返す(fail-closed)。
const authenticateUpgrade: UpgradeAuthChecker = (req) => {
return new Promise((resolve) => {
// express-session 等が res.setHeader / res.end を呼ぶことがあるため、
// 必要最小限のメソッドを no-op で備えたスタブを渡す。
const fakeRes = {
setHeader: () => fakeRes,
getHeader: () => undefined,
removeHeader: () => fakeRes,
end: () => fakeRes,
writeHead: () => fakeRes,
statusCode: 200,
on: () => fakeRes,
} as unknown as Response;
const reqAny = req as unknown as Request;
const failClosed = (stage: string, err: unknown): void => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[auth] authenticateUpgrade ${stage} failed: ${msg}`);
resolve(null);
};
sessionMiddleware(reqAny, fakeRes, (sessionErr?: unknown) => {
if (sessionErr) { failClosed('sessionMiddleware', sessionErr); return; }
passportInit(reqAny, fakeRes, (initErr?: unknown) => {
if (initErr) { failClosed('passportInit', initErr); return; }
passportSession(reqAny, fakeRes, (sessErr?: unknown) => {
if (sessErr) { failClosed('passportSession', sessErr); return; }
const user = reqAny.user as Express.User | undefined;
if (!user) {
resolve(null);
return;
}
// status=active のみ認める(disabled/pending を弾く)
if (user.status !== 'active') {
resolve(null);
return;
}
resolve(user);
});
});
});
});
};
return {
sessionMiddleware,
passportInit,
passportSession,
authRouter,
authenticateUpgrade,
};
}
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { ConfigManager } from '../config-manager.js';
import { mountBrandingApi, resolveBranding } from './branding-api.js';
function makeApp(yaml: string, withUpload = false) {
const dir = mkdtempSync(join(tmpdir(), 'branding-api-'));
writeFileSync(join(dir, 'config.yaml'), yaml);
const cm = new ConfigManager(join(dir, 'config.yaml'));
const app = express();
if (withUpload) {
mountBrandingApi(app, cm, {
brandingDir: join(dir, 'branding'),
adminGuard: (_req, _res, next) => next(),
});
} else {
mountBrandingApi(app, cm);
}
return { app, cm, dir };
}
describe('Branding API', () => {
it('returns defaults when branding is not configured', async () => {
const { app } = makeApp('provider:\n model: test-model\n');
const res = await request(app).get('/api/branding');
expect(res.status).toBe(200);
expect(res.body).toEqual({
appName: 'MAESTRO',
primaryColor: '#2563eb',
loginPageTitle: 'MAESTRO',
logoUrl: null,
faviconUrl: null,
footerText: null,
});
});
it('returns configured values when branding is set', async () => {
const { app } = makeApp([
'provider:',
' model: test-model',
'branding:',
' app_name: "My Team AI"',
' primary_color: "#ff5500"',
' login_page_title: "Welcome to My Team"',
' logo_url: "/branding/logo-abc.svg"',
' favicon_url: "/branding/favicon-def.png"',
' footer_text: "© 2026 My Team"',
].join('\n'));
const res = await request(app).get('/api/branding');
expect(res.status).toBe(200);
expect(res.body).toEqual({
appName: 'My Team AI',
primaryColor: '#ff5500',
loginPageTitle: 'Welcome to My Team',
logoUrl: '/branding/logo-abc.svg',
faviconUrl: '/branding/favicon-def.png',
footerText: '© 2026 My Team',
});
});
it('falls back loginPageTitle to appName when only appName is set', async () => {
const { app } = makeApp([
'provider:',
' model: test-model',
'branding:',
' app_name: "Custom App"',
].join('\n'));
const res = await request(app).get('/api/branding');
expect(res.body.appName).toBe('Custom App');
expect(res.body.loginPageTitle).toBe('Custom App');
expect(res.body.primaryColor).toBe('#2563eb');
});
it('ignores empty strings and falls back to defaults', async () => {
const { app } = makeApp([
'provider:',
' model: test-model',
'branding:',
' app_name: ""',
' primary_color: " "',
].join('\n'));
const res = await request(app).get('/api/branding');
expect(res.body.appName).toBe('MAESTRO');
expect(res.body.primaryColor).toBe('#2563eb');
});
it('resolveBranding returns defaults for undefined configManager', () => {
const branding = resolveBranding(undefined);
expect(branding).toEqual({
appName: 'MAESTRO',
primaryColor: '#2563eb',
loginPageTitle: 'MAESTRO',
logoUrl: null,
faviconUrl: null,
footerText: null,
});
});
describe('reactive to config updates', () => {
let cm: ConfigManager;
let app: express.Application;
beforeEach(() => {
const built = makeApp('provider:\n model: test-model\n');
cm = built.cm;
app = built.app;
});
it('reflects runtime updates via ConfigManager', async () => {
const before = await request(app).get('/api/branding');
expect(before.body.appName).toBe('MAESTRO');
const etag = cm.getConfigForApi().etag;
cm.updateConfig({ branding: { appName: 'Hot Reload' } }, etag);
const after = await request(app).get('/api/branding');
expect(after.body.appName).toBe('Hot Reload');
});
});
describe('asset upload', () => {
// 1x1 transparent PNG
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
it('uploads a logo and reflects it in GET /api/branding', async () => {
const { app, dir } = makeApp('provider:\n model: test-model\n', true);
const upload = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'my-logo.png', contentBase64: PNG_BASE64 });
expect(upload.status).toBe(200);
expect(upload.body.ok).toBe(true);
expect(upload.body.url).toMatch(/^\/branding\/logo-[a-f0-9]{12}\.png$/);
// File actually written to branding dir
expect(existsSync(join(dir, 'branding'))).toBe(true);
const files = readdirSync(join(dir, 'branding'));
expect(files.some(f => f.startsWith('logo-') && f.endsWith('.png'))).toBe(true);
// Reflected in public GET
const get = await request(app).get('/api/branding');
expect(get.body.logoUrl).toBe(upload.body.url);
});
it('rejects invalid kind', async () => {
const { app } = makeApp('provider:\n model: test-model\n', true);
const res = await request(app)
.post('/api/branding/upload')
.send({ kind: 'banner', filename: 'x.png', contentBase64: PNG_BASE64 });
expect(res.status).toBe(400);
});
it('rejects disallowed extension for favicon', async () => {
const { app } = makeApp('provider:\n model: test-model\n', true);
const res = await request(app)
.post('/api/branding/upload')
.send({ kind: 'favicon', filename: 'evil.gif', contentBase64: PNG_BASE64 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/extension/);
});
it('rejects files over size limit', async () => {
const { app } = makeApp('provider:\n model: test-model\n', true);
const big = Buffer.alloc(257 * 1024, 0x00).toString('base64'); // > 256KB for favicon
const res = await request(app)
.post('/api/branding/upload')
.send({ kind: 'favicon', filename: 'big.png', contentBase64: big });
expect(res.status).toBe(413);
});
it('DELETE clears the config field and removes the file', async () => {
const { app, dir } = makeApp('provider:\n model: test-model\n', true);
const upload = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'my.png', contentBase64: PNG_BASE64 });
expect(upload.status).toBe(200);
const uploadedName = upload.body.url.replace('/branding/', '');
expect(existsSync(join(dir, 'branding', uploadedName))).toBe(true);
const del = await request(app).delete('/api/branding/upload?kind=logo');
expect(del.status).toBe(200);
expect(existsSync(join(dir, 'branding', uploadedName))).toBe(false);
const get = await request(app).get('/api/branding');
expect(get.body.logoUrl).toBeNull();
});
it('replacing an existing asset cleans up the old file', async () => {
const { app, dir } = makeApp('provider:\n model: test-model\n', true);
const first = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'a.png', contentBase64: PNG_BASE64 });
const firstName = first.body.url.replace('/branding/', '');
const second = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'b.png', contentBase64: PNG_BASE64 });
const secondName = second.body.url.replace('/branding/', '');
expect(firstName).not.toBe(secondName);
const files = readdirSync(join(dir, 'branding')).filter(f => f.startsWith('logo-'));
expect(files).toEqual([secondName]);
});
});
});
+244
View File
@@ -0,0 +1,244 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import express from 'express';
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
import { join, extname, basename } from 'path';
import { randomBytes } from 'crypto';
import type { ConfigManager } from '../config-manager.js';
import { logger } from '../logger.js';
export interface PublicBranding {
appName: string;
primaryColor: string;
loginPageTitle: string;
logoUrl: string | null;
faviconUrl: string | null;
footerText: string | null;
}
const DEFAULTS: PublicBranding = {
appName: 'MAESTRO',
primaryColor: '#2563eb',
loginPageTitle: 'MAESTRO',
logoUrl: null,
faviconUrl: null,
footerText: null,
};
function pickString(obj: Record<string, unknown>, key: string): string | null {
const v = obj[key];
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
return null;
}
export function resolveBranding(configManager: ConfigManager | undefined): PublicBranding {
const raw = configManager?.getConfigForApi()?.config?.branding;
const cfg: Record<string, unknown> =
raw && typeof raw === 'object' && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
const appName = pickString(cfg, 'appName') ?? DEFAULTS.appName;
const primaryColor = pickString(cfg, 'primaryColor') ?? DEFAULTS.primaryColor;
const loginPageTitle = pickString(cfg, 'loginPageTitle') ?? appName;
const logoUrl = pickString(cfg, 'logoUrl');
const faviconUrl = pickString(cfg, 'faviconUrl');
const footerText = pickString(cfg, 'footerText');
return { appName, primaryColor, loginPageTitle, logoUrl, faviconUrl, footerText };
}
// ── Upload handling ──────────────────────────────────────────────────────────
type AssetKind = 'logo' | 'favicon';
const ALLOWED_KINDS: AssetKind[] = ['logo', 'favicon'];
const ALLOWED_EXTENSIONS: Record<AssetKind, string[]> = {
logo: ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'],
favicon: ['.svg', '.png', '.ico', '.webp'],
};
const MAX_SIZE_BYTES: Record<AssetKind, number> = {
logo: 2 * 1024 * 1024, // 2 MB
favicon: 256 * 1024, // 256 KB
};
function sanitizeExt(filename: string): string {
const ext = extname(filename).toLowerCase();
// Defensive: reject anything with path separators / null bytes
if (/[\\/\0]/.test(ext)) return '';
// Extension must be in a safe set
if (!/^\.[a-z0-9]{1,5}$/.test(ext)) return '';
return ext;
}
function removeExistingAsset(brandingDir: string, kind: AssetKind): void {
if (!existsSync(brandingDir)) return;
for (const name of readdirSync(brandingDir)) {
if (name.startsWith(`${kind}-`)) {
try {
unlinkSync(join(brandingDir, name));
} catch (e) {
logger.warn(`[branding] failed to remove old asset ${name}: ${e}`);
}
}
}
}
function assetUrlFromConfig(configManager: ConfigManager | undefined, kind: AssetKind): string | null {
const b = configManager?.getConfigForApi()?.config?.branding;
if (!b || typeof b !== 'object') return null;
const key = kind === 'logo' ? 'logoUrl' : 'faviconUrl';
const v = (b as Record<string, unknown>)[key];
return typeof v === 'string' ? v : null;
}
function removeAssetByUrl(brandingDir: string, url: string | null): void {
if (!url) return;
if (!url.startsWith('/branding/')) return;
const name = basename(url);
// Defense in depth: basename strips any `..` components
if (!name || name.includes('..')) return;
const fullPath = join(brandingDir, name);
if (existsSync(fullPath)) {
try {
unlinkSync(fullPath);
} catch (e) {
logger.warn(`[branding] failed to remove asset ${name}: ${e}`);
}
}
}
export interface MountBrandingOptions {
/** Absolute or relative path where branding assets (logos, favicons) are stored. Created on demand. */
brandingDir: string;
/** Admin-only middleware. When auth is disabled, pass a passthrough. */
adminGuard: RequestHandler;
}
export function mountBrandingApi(
app: Application,
configManager: ConfigManager | undefined,
opts?: MountBrandingOptions,
): void {
// Public GET — no auth required. UI fetches this at startup (even on login page).
app.get('/api/branding', (_req: Request, res: Response) => {
res.json(resolveBranding(configManager));
});
if (!opts) return;
const { brandingDir, adminGuard } = opts;
// Serve uploaded assets. Directory is created lazily if first write happens;
// express.static handles the not-exists case by falling through to 404.
app.use('/branding', express.static(brandingDir, {
maxAge: '7d',
fallthrough: true,
}));
// Upload endpoint: admin only. Body is JSON with base64 content
// (same pattern as task attachment upload in local-tasks-api.ts).
const uploadJson = express.json({ limit: '4mb' });
app.post('/api/branding/upload', uploadJson, adminGuard, (req: Request, res: Response) => {
if (!configManager) {
res.status(503).json({ ok: false, error: 'ConfigManager unavailable' });
return;
}
try {
const { kind, filename, contentBase64 } = req.body ?? {};
if (!ALLOWED_KINDS.includes(kind)) {
res.status(400).json({ ok: false, error: 'kind must be "logo" or "favicon"' });
return;
}
if (typeof filename !== 'string' || typeof contentBase64 !== 'string') {
res.status(400).json({ ok: false, error: 'filename and contentBase64 are required strings' });
return;
}
const ext = sanitizeExt(filename);
if (!ext || !ALLOWED_EXTENSIONS[kind as AssetKind].includes(ext)) {
res.status(400).json({
ok: false,
error: `extension must be one of ${ALLOWED_EXTENSIONS[kind as AssetKind].join(', ')}`,
});
return;
}
let buf: Buffer;
try {
buf = Buffer.from(contentBase64, 'base64');
} catch {
res.status(400).json({ ok: false, error: 'invalid base64 content' });
return;
}
if (buf.length === 0) {
res.status(400).json({ ok: false, error: 'empty file' });
return;
}
if (buf.length > MAX_SIZE_BYTES[kind as AssetKind]) {
res.status(413).json({
ok: false,
error: `file too large (max ${MAX_SIZE_BYTES[kind as AssetKind]} bytes)`,
});
return;
}
// Create branding directory on first upload
if (!existsSync(brandingDir)) {
mkdirSync(brandingDir, { recursive: true });
}
// Clean up any previously stored asset of this kind so we don't
// accumulate orphaned files when the admin re-uploads.
removeExistingAsset(brandingDir, kind as AssetKind);
// Hash-suffixed filename provides unique URL for cache busting
const hash = randomBytes(6).toString('hex');
const storedName = `${kind}-${hash}${ext}`;
writeFileSync(join(brandingDir, storedName), buf);
const publicUrl = `/branding/${storedName}`;
const configKey = kind === 'logo' ? 'logoUrl' : 'faviconUrl';
const result = configManager.updateConfig({ branding: { [configKey]: publicUrl } });
if (!result.ok) {
// Rollback: delete the file we just wrote, otherwise the config and
// filesystem disagree forever.
try { unlinkSync(join(brandingDir, storedName)); } catch { /* best effort */ }
res.status(500).json({ ok: false, error: 'Failed to persist config', detail: result });
return;
}
logger.info(`[branding] uploaded ${kind} -> ${publicUrl} (${buf.length} bytes)`);
res.json({ ok: true, kind, url: publicUrl });
} catch (e) {
logger.warn(`[branding] upload failed: ${e}`);
res.status(500).json({ ok: false, error: String(e) });
}
});
app.delete('/api/branding/upload', adminGuard, (req: Request, res: Response) => {
if (!configManager) {
res.status(503).json({ ok: false, error: 'ConfigManager unavailable' });
return;
}
try {
const kind = req.query.kind;
if (typeof kind !== 'string' || !ALLOWED_KINDS.includes(kind as AssetKind)) {
res.status(400).json({ ok: false, error: 'query.kind must be "logo" or "favicon"' });
return;
}
const currentUrl = assetUrlFromConfig(configManager, kind as AssetKind);
removeAssetByUrl(brandingDir, currentUrl);
const configKey = kind === 'logo' ? 'logoUrl' : 'faviconUrl';
const result = configManager.updateConfig({ branding: { [configKey]: '' } });
if (!result.ok) {
res.status(500).json({ ok: false, error: 'Failed to persist config', detail: result });
return;
}
logger.info(`[branding] cleared ${kind}`);
res.json({ ok: true });
} catch (e) {
logger.warn(`[branding] delete failed: ${e}`);
res.status(500).json({ ok: false, error: String(e) });
}
});
}
+318
View File
@@ -0,0 +1,318 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js';
import { createBrowserApi } from './browser-api.js';
import {
type SessionManager,
type BrowserSession,
CAPTCHA_POOL_SESSION_ID,
} from '../engine/browser-session.js';
import { unlinkSync } from 'fs';
// dev 環境では vendor/noVNC/vnc.html が存在しないため、
// 既存テストの期待 (available: true) を保つために isNovncStaticInstalled
// を true に固定する。novnc_not_installed 経路は専用テストで上書きする。
vi.mock('./novnc-proxy.js', async () => {
const actual = await vi.importActual<typeof import('./novnc-proxy.js')>('./novnc-proxy.js');
return {
...actual,
isNovncStaticInstalled: vi.fn(() => true),
};
});
const novncProxyMock = await import('./novnc-proxy.js');
/**
* 2026-05 redesign: API は CAPTCHA Pool (admin only) と Task Session
* (visibility ベース) を分離。テストダブルも kind / taskId / captchaPending
* を扱える形にしている。
*/
class FakeSessionManager {
private sessions = new Map<string, BrowserSession>();
releaseCalls: string[] = [];
destroyCalls: string[] = [];
preload(partial: Partial<BrowserSession> & Pick<BrowserSession, 'id' | 'kind'>): BrowserSession {
const now = new Date();
const full: BrowserSession = {
id: partial.id,
kind: partial.kind,
taskId: partial.taskId,
userId: partial.userId,
browser: undefined as never,
context: undefined as never,
vncPort: 5900,
novncPort: 6900,
userDataDir: '/tmp/test',
state: partial.state ?? 'ready',
xvfbProcess: undefined as never,
x11vncProcess: undefined as never,
websockifyProcess: undefined as never,
display: ':99',
createdAt: partial.createdAt ?? now,
lastActiveAt: partial.lastActiveAt ?? now,
lockedByJobId: partial.lockedByJobId ?? null,
captchaPending: partial.captchaPending,
};
this.sessions.set(full.id, full);
return full;
}
// --- SessionManager 互換 ---
listSessions(): BrowserSession[] { return Array.from(this.sessions.values()); }
getSession(id: string): BrowserSession | undefined { return this.sessions.get(id); }
async destroySession(id: string): Promise<void> {
this.destroyCalls.push(id);
this.sessions.delete(id);
}
releaseToAgent(id: string): void { this.releaseCalls.push(id); }
}
function makeApp(
sessionManager: FakeSessionManager,
repo: Repository,
user?: { id: string; role: 'admin' | 'user'; orgIds?: string[] },
): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
if (user) {
(req as unknown as { user: unknown }).user = {
id: user.id,
role: user.role,
status: 'active',
orgIds: user.orgIds ?? [],
email: `${user.id}@example.com`,
name: user.id,
avatarUrl: null,
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
}
next();
});
app.use('/api/local/browser/sessions', createBrowserApi(sessionManager as unknown as SessionManager, repo));
return app;
}
async function createTask(
repo: Repository,
ownerId: string | null,
visibility: 'private' | 'org' | 'public' = 'private',
visibilityScopeOrgId: string | null = null,
): Promise<number> {
const task = await repo.createLocalTask({
title: 't', body: 'b', pieceName: 'general',
profile: 'auto', outputFormat: 'plain', askPolicy: 'allow',
priority: 0, ownerId, visibility, visibilityScopeOrgId,
});
return task.id;
}
describe('Browser API', () => {
let repo: Repository;
const dbPath = './_test_browser_api.db';
beforeEach(() => {
repo = new Repository(dbPath);
runMigrations(repo.getDb());
});
afterEach(() => {
repo.close();
try { unlinkSync(dbPath); } catch { /* ignore */ }
});
describe('GET /captcha-pool', () => {
it('returns available:false when no pool exists', async () => {
const sm = new FakeSessionManager();
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false });
});
it('returns pool info to admin', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool', captchaPending: true });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(res.body.available).toBe(true);
expect(res.body.captchaPending).toBe(true);
expect(res.body.novncPath).toContain(CAPTCHA_POOL_SESSION_ID);
});
it('rejects non-admin', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(403);
});
});
describe('DELETE /captcha-pool', () => {
it('admin can destroy the pool', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).delete('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(sm.destroyCalls).toContain(CAPTCHA_POOL_SESSION_ID);
});
it('rejects non-admin', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).delete('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(403);
expect(sm.destroyCalls).toEqual([]);
});
});
describe('GET /task-session/:taskId', () => {
it('returns available:false when no session for that task', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false });
});
it('owner can see their own task session', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body.available).toBe(true);
expect(res.body.sessionId).toBe('sess-1');
});
it('non-owner is told available:false (no info leak)', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice', 'private');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false });
});
it('admin can see any task session', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice', 'private');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body.available).toBe(true);
});
it('public task session is visible to anyone', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice', 'public');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body.available).toBe(true);
});
it('returns reason:novnc_not_installed when vnc.html is missing', async () => {
const spy = vi.mocked(novncProxyMock.isNovncStaticInstalled).mockReturnValueOnce(false);
try {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, reason: 'novnc_not_installed' });
} finally {
spy.mockRestore();
}
});
});
describe('POST /task-session/:taskId/release', () => {
it('owner can release their own task session', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).post(`/api/local/browser/sessions/task-session/${taskId}/release`);
expect(res.status).toBe(200);
expect(sm.destroyCalls).toEqual(['sess-1']);
});
it('non-owner is rejected', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).post(`/api/local/browser/sessions/task-session/${taskId}/release`);
expect(res.status).toBe(403);
expect(sm.destroyCalls).toEqual([]);
});
});
describe('GET /', () => {
it('lists only visible task sessions and excludes the pool', async () => {
const sm = new FakeSessionManager();
const aliceTaskId = await createTask(repo, 'alice', 'private');
const bobTaskId = await createTask(repo, 'bob', 'private');
sm.preload({ id: 'pool', kind: 'pool' }); // 除外されるはず
sm.preload({ id: 'sess-alice', kind: 'task', taskId: String(aliceTaskId), userId: 'alice' });
sm.preload({ id: 'sess-bob', kind: 'task', taskId: String(bobTaskId), userId: 'bob' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get('/api/local/browser/sessions/');
expect(res.status).toBe(200);
const ids = res.body.sessions.map((s: { id: string }) => s.id).sort();
expect(ids).toEqual(['sess-alice']);
});
it('admin sees all task sessions but not the pool', async () => {
const sm = new FakeSessionManager();
const aliceTaskId = await createTask(repo, 'alice');
const bobTaskId = await createTask(repo, 'bob');
sm.preload({ id: 'pool', kind: 'pool' });
sm.preload({ id: 'sess-alice', kind: 'task', taskId: String(aliceTaskId) });
sm.preload({ id: 'sess-bob', kind: 'task', taskId: String(bobTaskId) });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get('/api/local/browser/sessions/');
expect(res.status).toBe(200);
const ids = res.body.sessions.map((s: { id: string }) => s.id).sort();
expect(ids).toEqual(['sess-alice', 'sess-bob']);
});
});
describe('GET /:id', () => {
it('returns 404 for the pool sessionId (must use /captcha-pool)', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get(`/api/local/browser/sessions/${CAPTCHA_POOL_SESSION_ID}`);
expect(res.status).toBe(404);
});
});
describe('DELETE /:id', () => {
it('non-owner cannot destroy', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).delete('/api/local/browser/sessions/sess-1');
expect(res.status).toBe(404);
expect(sm.destroyCalls).toEqual([]);
});
});
});
+272
View File
@@ -0,0 +1,272 @@
import { Router, Request, Response } from 'express';
import { SessionManager, type BrowserSession, CAPTCHA_POOL_SESSION_ID } from '../engine/browser-session.js';
import type { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { buildNovncPath, isNovncStaticInstalled } from './novnc-proxy.js';
import { canUserSeeTask, canEditEntity } from './visibility.js';
/**
* 2026-05 redesign: CAPTCHA Pool (admin 専用) と Task Session (タスク
* visibility ベース) を分離した API。
*
* - GET /captcha-pool : admin only. Pool の noVNC パス + captchaPending
* - DELETE /captcha-pool : admin only. Pool を destroy (次の CAPTCHA で再生成)
* - GET /task-session/:taskId : visibility 通過なら novncPath、なければ available:false
* - POST /task-session/:taskId/release: owner or admin. その taskId の session を destroy
* - GET / : 自分が見えるタスクの task session 一覧 (Pool は除外)
* - GET /:id : 直 sessionId 指定 (admin / 旧来 owner)。kind=='task' のときは taskId 経由を推奨
* - DELETE /:id : admin or task owner
*
* 旧 /search-session, POST / は廃止 (Plan の clean break)。
*/
function isAdmin(req: Request): boolean {
const user = req.user as Express.User | undefined;
return user?.role === 'admin';
}
function getUser(req: Request): Express.User | undefined {
return req.user as Express.User | undefined;
}
/** auth 未設定 (dev モード) では req.user が undefined。その場合は全許可で互換維持 */
function isUnauthenticatedDev(req: Request): boolean {
return getUser(req) === undefined;
}
function serializeTaskSession(session: BrowserSession) {
return {
id: session.id,
kind: session.kind,
taskId: session.taskId,
state: session.state,
novncPath: buildNovncPath(session.id),
lockedByJobId: session.lockedByJobId,
createdAt: session.createdAt.toISOString(),
lastActiveAt: session.lastActiveAt.toISOString(),
};
}
/**
* 指定 task session に user がアクセスできるかを判定する (visibility ベース)。
* Pool は admin only。dev モードは全許可。
*/
async function canViewSession(
req: Request,
session: BrowserSession,
repo: Repository,
): Promise<boolean> {
if (isUnauthenticatedDev(req)) return true;
const user = getUser(req)!;
if (user.role === 'admin') return true;
if (session.kind === 'pool') return false;
if (session.kind === 'task' && session.taskId) {
const taskIdNum = Number(session.taskId);
if (!Number.isFinite(taskIdNum)) return false;
const task = await repo.getLocalTask(taskIdNum);
return task ? canUserSeeTask(user, task) : false;
}
// 旧来 (kind 未設定): owner だけ
return session.userId === user.id;
}
/**
* 指定 task session を user が destroy / release できるかを判定する。
* - admin: 常に可
* - task owner: 可
* - dev モード: 可
*/
async function canControlSession(
req: Request,
session: BrowserSession,
repo: Repository,
): Promise<boolean> {
if (isUnauthenticatedDev(req)) return true;
const user = getUser(req)!;
if (user.role === 'admin') return true;
if (session.kind === 'pool') return false;
if (session.kind === 'task' && session.taskId) {
const taskIdNum = Number(session.taskId);
if (!Number.isFinite(taskIdNum)) return false;
const task = await repo.getLocalTask(taskIdNum);
if (!task) return false;
return canEditEntity(user, task);
}
return session.userId === user.id;
}
export function createBrowserApi(sessionManager: SessionManager | null, repo: Repository): Router {
const router = Router();
// sessionManager が null (Xvfb 等が無い環境) でも /captcha-pool / /task-session
// は available: false を返したい。503 にしてしまうと UI 側でエラー扱いされてしまう。
if (!sessionManager) {
router.get('/captcha-pool', (_req: Request, res: Response) => {
res.json({ available: false });
});
router.get('/task-session/:taskId', (_req: Request, res: Response) => {
res.json({ available: false });
});
router.all('*', (_req: Request, res: Response) => {
res.status(503).json({ error: 'Browser sessions not available (missing system dependencies)' });
});
return router;
}
// --- CAPTCHA Pool (admin only) ---
router.get('/captcha-pool', (req: Request, res: Response) => {
if (!isUnauthenticatedDev(req) && !isAdmin(req)) {
res.status(403).json({ error: 'Admin role required' });
return;
}
const pool = sessionManager.getSession(CAPTCHA_POOL_SESSION_ID);
if (!pool) {
res.json({ available: false });
return;
}
if (!isNovncStaticInstalled()) {
res.json({ available: false, reason: 'novnc_not_installed' });
return;
}
res.json({
available: true,
sessionId: pool.id,
novncPath: buildNovncPath(pool.id),
display: pool.display,
captchaPending: pool.captchaPending === true,
createdAt: pool.createdAt.toISOString(),
});
});
router.delete('/captcha-pool', async (req: Request, res: Response) => {
if (!isUnauthenticatedDev(req) && !isAdmin(req)) {
res.status(403).json({ error: 'Admin role required' });
return;
}
// Pool destroy 時は web.ts の persistentContexts も連動して破棄する
// (Cookie の生残りで認証状態が混乱するのを防ぐ)
try {
const webMod = await import('../engine/tools/web.js') as { clearPersistentContexts?: () => void };
webMod.clearPersistentContexts?.();
} catch { /* ignore */ }
await sessionManager.destroySession(CAPTCHA_POOL_SESSION_ID);
res.json({ ok: true });
});
// --- Task Session (visibility-aware) ---
router.get('/task-session/:taskId', async (req: Request, res: Response) => {
const taskId = req.params.taskId;
const session = sessionManager
.listSessions()
.find((s) => s.kind === 'task' && s.taskId === taskId);
if (!session) {
res.json({ available: false });
return;
}
if (!(await canViewSession(req, session, repo))) {
// 認可失敗は available: false にして session 存在情報を漏らさない
res.json({ available: false });
return;
}
if (!isNovncStaticInstalled()) {
// session は存在するが、iframe で読む vnc.html が配置されていない。
// UI 側で「scripts/setup-novnc.sh を実行してください」と案内する。
res.json({ available: false, reason: 'novnc_not_installed' });
return;
}
res.json({
available: true,
sessionId: session.id,
novncPath: buildNovncPath(session.id),
display: session.display,
state: session.state,
lockedByJobId: session.lockedByJobId,
createdAt: session.createdAt.toISOString(),
lastActiveAt: session.lastActiveAt.toISOString(),
});
});
router.post('/task-session/:taskId/release', async (req: Request, res: Response) => {
const taskId = req.params.taskId;
const session = sessionManager
.listSessions()
.find((s) => s.kind === 'task' && s.taskId === taskId);
if (!session) {
res.status(404).json({ error: 'Task session not found' });
return;
}
if (!(await canControlSession(req, session, repo))) {
res.status(403).json({ error: 'Forbidden' });
return;
}
await sessionManager.destroySession(session.id);
res.json({ ok: true });
});
// --- Generic list / detail (task sessions only; pool excluded) ---
router.get('/', async (req: Request, res: Response) => {
const taskSessions = sessionManager.listSessions().filter((s) => s.kind === 'task');
const visible: BrowserSession[] = [];
for (const s of taskSessions) {
if (await canViewSession(req, s, repo)) visible.push(s);
}
res.json({ sessions: visible.map(serializeTaskSession) });
});
router.get('/:id', async (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.id);
if (!session || session.kind === 'pool') {
// Pool は /captcha-pool 経由でのみアクセスさせる (id 直指定では不可)
res.status(404).json({ error: 'Session not found' });
return;
}
if (!(await canViewSession(req, session, repo))) {
res.status(404).json({ error: 'Session not found' });
return;
}
res.json(serializeTaskSession(session));
});
router.delete('/:id', async (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.id);
if (!session || session.kind === 'pool') {
res.status(404).json({ error: 'Session not found' });
return;
}
if (!(await canControlSession(req, session, repo))) {
res.status(404).json({ error: 'Session not found' });
return;
}
await sessionManager.destroySession(session.id);
res.json({ ok: true });
});
router.post('/:id/release', async (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.id);
if (!session || session.kind === 'pool') {
res.status(404).json({ error: 'Session not found' });
return;
}
if (!(await canControlSession(req, session, repo))) {
res.status(404).json({ error: 'Session not found' });
return;
}
sessionManager.releaseToAgent(session.id);
if (session.lockedByJobId) {
try {
await repo.updateJob(session.lockedByJobId, {
status: 'queued',
waitReason: null,
});
} catch (err) {
logger.warn(`[browser-api] failed to re-queue job ${session.lockedByJobId}: ${(err as Error).message}`);
}
}
res.json({ ok: true, state: 'agent_controlled' });
});
return router;
}
+277
View File
@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository, BrowserSessionRepo } from '../db/repository.js';
import { createBrowserSessionApi } from './browser-session-api.js';
import { initMasterKey, generateUserDek, encryptUserDek } from '../crypto/sessions.js';
import type { SessionManager } from '../engine/browser-session.js';
interface TestContext {
app: express.Application;
repository: Repository;
sessRepo: BrowserSessionRepo;
tempDir: string;
}
function buildApp(userId: string): TestContext {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const db = repository.getDb();
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`)
.run(userId, `${userId}@test`);
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
const master = initMasterKey(masterKeyPath);
// Pre-seed a DEK for the user
sessRepo.setUserDek(userId, encryptUserDek(master, generateUserDek()));
const app = express();
app.use(express.json());
// Stub req.user for auth-required tests
app.use((req, _res, next) => {
(req as { user?: unknown }).user = { id: userId, role: 'active' };
next();
});
app.use('/api/browser-sessions', createBrowserSessionApi({ sessRepo, sessionManager: null, masterKeyPath }));
return { app, repository, sessRepo, tempDir };
}
describe('browser-session-api', () => {
let ctx: TestContext | null = null;
afterEach(() => {
if (ctx) {
ctx.repository.close();
rmSync(ctx.tempDir, { recursive: true, force: true });
ctx = null;
}
});
it('lists empty for a new user', async () => {
ctx = buildApp('u1');
const res = await request(ctx.app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(200);
expect(res.body.profiles).toEqual([]);
});
it('creates a profile (status=pending, label echoes input)', async () => {
ctx = buildApp('u1');
const res = await request(ctx.app).post('/api/browser-sessions/profiles').send({
label: 'GitHub',
startUrl: 'https://github.com',
matchPatterns: ['https://github.com/**'],
storageOrigins: ['https://github.com'],
loginUrlPatterns: ['https://github.com/login**'],
});
expect(res.status).toBe(201);
expect(res.body.profile.status).toBe('pending');
expect(res.body.profile.label).toBe('GitHub');
// Must NOT leak the encrypted blob
expect(res.body.profile.encryptedStateBlob).toBeUndefined();
expect(res.body.profile.encrypted_state_blob).toBeUndefined();
});
it('deletes only profiles the user owns', async () => {
ctx = buildApp('u1');
// Owned profile: 200
const id = ctx.sessRepo.createProfile({
ownerId: 'u1', label: 'X', startUrl: 'https://x.com',
matchPatterns: [], storageOrigins: [], loginUrlPatterns: [],
});
const ok = await request(ctx.app).delete(`/api/browser-sessions/profiles/${id}`);
expect(ok.status).toBe(200);
// Non-existent: 404
const missing = await request(ctx.app).delete(`/api/browser-sessions/profiles/9999`);
expect(missing.status).toBe(404);
// Other user's profile: 404 (owner enforcement)
ctx.repository.getDb().prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES ('u2','u2@test','active','active',datetime('now'),datetime('now'))`).run();
const otherId = ctx.sessRepo.createProfile({
ownerId: 'u2', label: 'Other', startUrl: 'https://other.com',
matchPatterns: [], storageOrigins: [], loginUrlPatterns: [],
});
const forbidden = await request(ctx.app).delete(`/api/browser-sessions/profiles/${otherId}`);
expect(forbidden.status).toBe(404);
// Confirm it was NOT actually deleted (still exists for u2)
expect(ctx.sessRepo.getProfileById(otherId, 'u2')).not.toBeNull();
});
it('rejects unauthenticated requests', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-noauth-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const sessRepo = new BrowserSessionRepo(repository.getDb());
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// No req.user middleware → unauthenticated
app.use('/api/browser-sessions', createBrowserSessionApi({ sessRepo, sessionManager: null, masterKeyPath }));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthenticated');
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
// ── P2b: authActive-aware gate ─────────────────────────────────────────────
it('authActive=true still rejects unauthenticated requests', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-auth-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const sessRepo = new BrowserSessionRepo(repository.getDb());
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// authActive=true + no req.user → must still return 401
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: null,
masterKeyPath,
authActive: true,
}));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthenticated');
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('authActive=false falls back to synthetic local user (no-auth mode)', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-noauth-local-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const db = repository.getDb();
// Insert local user so FK constraints pass
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES ('local', 'local@localhost', 'active', 'active', datetime('now'), datetime('now'))`).run();
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// No req.user middleware, but authActive=false → should inject synthetic local user
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: null,
masterKeyPath,
authActive: false,
}));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(200);
expect(res.body.profiles).toEqual([]);
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
});
describe('login + save flow', () => {
let tempDirsToClean: string[] = [];
let repositoryToClose: Repository | null = null;
afterEach(() => {
if (repositoryToClose) {
repositoryToClose.close();
repositoryToClose = null;
}
for (const d of tempDirsToClean) {
rmSync(d, { recursive: true, force: true });
}
tempDirsToClean = [];
});
it('starts a login session, then save captures storageState and encrypts it', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-loginflow-'));
tempDirsToClean.push(tempDir);
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
repositoryToClose = repository;
const db = repository.getDb();
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`)
.run('u1', 'u1@test');
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
// Intentionally NOT pre-seeding a DEK — /save should lazily create one via ensureUserDek.
const id = sessRepo.createProfile({
ownerId: 'u1',
label: 'X',
startUrl: 'https://example.com',
matchPatterns: [],
storageOrigins: [],
loginUrlPatterns: [],
});
const fakeContext = {
pages: () => [],
newPage: async () => ({ goto: async () => null }),
storageState: async () => ({ cookies: [{ name: 's', value: '1' }], origins: [] }),
};
const fake = {
createLoginSession: async (_opts: unknown) => ({
id: 'sess1',
kind: 'login',
profileId: id,
context: fakeContext,
browser: { isConnected: () => true },
display: ':99',
}),
getSession: () => ({
id: 'sess1',
kind: 'login',
profileId: id,
context: fakeContext,
}),
destroySession: async () => {},
};
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as { user?: unknown }).user = { id: 'u1', role: 'active' };
next();
});
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: fake as unknown as SessionManager,
masterKeyPath,
}));
const start = await request(app).post(`/api/browser-sessions/profiles/${id}/login`);
expect(start.status).toBe(200);
expect(start.body.sessionId).toBe('sess1');
expect(start.body.novncPath).toContain('sess1');
const save = await request(app)
.post(`/api/browser-sessions/profiles/${id}/save`)
.send({ sessionId: 'sess1' });
expect(save.status).toBe(200);
const profile = sessRepo.getProfileById(id, 'u1')!;
expect(profile).not.toBeNull();
expect(profile.status).toBe('active');
expect(profile.encryptedStateBlob).not.toBeNull();
expect(profile.encryptedStateBlob!.length).toBeGreaterThan(16);
// Sanity: the API response itself must NOT leak the encrypted blob.
expect(save.body.profile.encryptedStateBlob).toBeUndefined();
expect(save.body.profile.encrypted_state_blob).toBeUndefined();
expect(save.body.profile.status).toBe('active');
});
});
+483
View File
@@ -0,0 +1,483 @@
import { Router, type Request, type Response } from 'express';
import { readFileSync } from 'fs';
import { createRequire } from 'module';
import type { BrowserSessionRepo, BrowserSessionProfile } from '../db/browser-session-repo.js';
import type { SessionManager } from '../engine/browser-session.js';
import {
initMasterKey,
generateUserDek,
encryptUserDek,
decryptUserDek,
encryptStateBlob,
decryptStateBlob,
} from '../crypto/sessions.js';
import { buildNovncPath } from './novnc-proxy.js';
import { logger } from '../logger.js';
const requireFromHere = createRequire(import.meta.url);
let cachedPlaywrightVersion: string | null = null;
function getPlaywrightVersion(): string {
if (cachedPlaywrightVersion) return cachedPlaywrightVersion;
try {
const pkgPath = requireFromHere.resolve('playwright/package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string };
cachedPlaywrightVersion = pkg.version;
} catch {
cachedPlaywrightVersion = 'unknown';
}
return cachedPlaywrightVersion;
}
interface Deps {
sessRepo: BrowserSessionRepo;
sessionManager: SessionManager | null;
masterKeyPath: string;
authActive?: boolean;
}
interface AuthedUser {
id: string;
role: string;
}
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
/** JSON-friendly subset of a profile. NEVER includes the encrypted blob. */
function serializeProfile(p: BrowserSessionProfile): {
id: number;
label: string;
startUrl: string;
matchPatterns: string[];
storageOrigins: string[];
loggedInSelector: string | null;
loginUrlPatterns: string[];
status: BrowserSessionProfile['status'];
stateVersion: number;
lastSavedAt: string | null;
lastUsedAt: string | null;
lastValidatedAt: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
} {
return {
id: p.id,
label: p.label,
startUrl: p.startUrl,
matchPatterns: p.matchPatterns,
storageOrigins: p.storageOrigins,
loggedInSelector: p.loggedInSelector,
loginUrlPatterns: p.loginUrlPatterns,
status: p.status,
stateVersion: p.stateVersion,
lastSavedAt: p.lastSavedAt,
lastUsedAt: p.lastUsedAt,
lastValidatedAt: p.lastValidatedAt,
lastError: p.lastError,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
};
}
/**
* Ensure the user has a DEK, creating + persisting one if needed.
* Returns the decrypted DEK (32 bytes) for use with state-blob encryption.
*/
export function ensureUserDek(deps: Deps, userId: string): Buffer {
const master = initMasterKey(deps.masterKeyPath);
let enc = deps.sessRepo.getUserDek(userId);
if (!enc) {
const dek = generateUserDek();
enc = encryptUserDek(master, dek);
deps.sessRepo.setUserDek(userId, enc);
return dek;
}
return decryptUserDek(master, enc);
}
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(s => typeof s === 'string');
}
export function createBrowserSessionApi(deps: Deps): Router {
const r = Router();
const authActive = deps.authActive ?? true;
// Auth gate — every request must have req.user.
// In no-auth mode (authActive=false), fall back to a synthetic 'local' user
// so the Browser Sessions panel works in local dev without OAuth.
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// GET /profiles — list owned profiles
r.get('/profiles', (req: Request, res: Response) => {
const u = getUser(req)!;
const profiles = deps.sessRepo.listProfilesByOwner(u.id).map(serializeProfile);
res.json({ profiles });
});
// POST /profiles — create a new profile (status=pending, no blob yet)
r.post('/profiles', (req: Request, res: Response) => {
const u = getUser(req)!;
const b = (req.body ?? {}) as Record<string, unknown>;
if (typeof b['label'] !== 'string' || (b['label'] as string).length === 0) {
res.status(400).json({ error: 'label is required and must be a non-empty string' });
return;
}
if (typeof b['startUrl'] !== 'string' || (b['startUrl'] as string).length === 0) {
res.status(400).json({ error: 'startUrl is required and must be a non-empty string' });
return;
}
if (b['matchPatterns'] !== undefined && !isStringArray(b['matchPatterns'])) {
res.status(400).json({ error: 'matchPatterns must be a string[]' });
return;
}
if (b['storageOrigins'] !== undefined && !isStringArray(b['storageOrigins'])) {
res.status(400).json({ error: 'storageOrigins must be a string[]' });
return;
}
if (b['loginUrlPatterns'] !== undefined && !isStringArray(b['loginUrlPatterns'])) {
res.status(400).json({ error: 'loginUrlPatterns must be a string[]' });
return;
}
if (b['loggedInSelector'] !== undefined && b['loggedInSelector'] !== null && typeof b['loggedInSelector'] !== 'string') {
res.status(400).json({ error: 'loggedInSelector must be a string or null' });
return;
}
const id = deps.sessRepo.createProfile({
ownerId: u.id,
label: b['label'] as string,
startUrl: b['startUrl'] as string,
matchPatterns: isStringArray(b['matchPatterns']) ? b['matchPatterns'] : [],
storageOrigins: isStringArray(b['storageOrigins']) ? b['storageOrigins'] : [],
loggedInSelector: typeof b['loggedInSelector'] === 'string' ? b['loggedInSelector'] : null,
loginUrlPatterns: isStringArray(b['loginUrlPatterns']) ? b['loginUrlPatterns'] : [],
});
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'create',
result: 'success',
});
const profile = deps.sessRepo.getProfileById(id, u.id);
res.status(201).json({ profile: serializeProfile(profile!) });
});
// DELETE /profiles/:id — owner-only delete
r.delete('/profiles/:id', (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const ok = deps.sessRepo.deleteProfile(id, u.id);
if (!ok) {
// Either not found or not owned — same response either way.
// Do NOT audit on failure (per checklist).
res.status(404).json({ error: 'not found' });
return;
}
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'delete',
result: 'success',
});
res.json({ ok: true });
});
// POST /profiles/:id/login — spawn an interactive noVNC session for the owner
r.post('/profiles/:id/login', async (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const profile = deps.sessRepo.getProfileById(id, u.id);
if (!profile) {
res.status(404).json({ error: 'not found' });
return;
}
if (!deps.sessionManager) {
res.status(503).json({ error: 'browser sessions unavailable (missing Xvfb/x11vnc/websockify)' });
return;
}
try {
const session = await deps.sessionManager.createLoginSession({ ownerId: u.id, profileId: id });
// Navigate to start_url for the user (best-effort; do not fail the endpoint on goto error).
try {
const ctx = session.context;
if (ctx) {
const pages = ctx.pages();
const page = pages.length > 0 ? pages[0]! : await ctx.newPage();
await page.goto(profile.startUrl, { waitUntil: 'load', timeout: 60_000 });
}
} catch (gotoErr) {
logger.warn(`[browser-session-api] login goto failed: ${(gotoErr as Error).message}`);
}
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'login_start',
result: 'success',
});
res.json({ sessionId: session.id, novncPath: buildNovncPath(session.id) });
} catch (e) {
const msg = (e as Error).message;
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'login_start',
result: 'error',
reason: msg,
});
res.status(500).json({ error: msg });
}
});
// POST /profiles/:id/save — capture context.storageState(), encrypt, persist, destroy session
r.post('/profiles/:id/save', async (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const sessionId = (req.body as { sessionId?: string } | undefined)?.sessionId;
if (!sessionId) {
res.status(400).json({ error: 'sessionId required' });
return;
}
const profile = deps.sessRepo.getProfileById(id, u.id);
if (!profile) {
res.status(404).json({ error: 'not found' });
return;
}
if (!deps.sessionManager) {
res.status(503).json({ error: 'browser sessions unavailable' });
return;
}
const session = deps.sessionManager.getSession(sessionId);
if (!session || session.kind !== 'login' || session.profileId !== id) {
res.status(400).json({ error: 'login session does not match profile' });
return;
}
try {
if (!session.context) {
throw new Error('login session has no browser context');
}
const state = await session.context.storageState();
const dek = ensureUserDek(deps, u.id);
const blob = encryptStateBlob(dek, JSON.stringify(state));
deps.sessRepo.saveProfileBlob(id, blob, getPlaywrightVersion());
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'save',
result: 'success',
});
await deps.sessionManager.destroySession(sessionId);
const fresh = deps.sessRepo.getProfileById(id, u.id)!;
res.json({ profile: serializeProfile(fresh) });
} catch (e) {
const msg = (e as Error).message;
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'save',
result: 'error',
reason: msg,
});
res.status(500).json({ error: msg });
}
});
// POST /profiles/:id/cancel — abort an in-progress login session
r.post('/profiles/:id/cancel', async (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const sessionId = (req.body as { sessionId?: string } | undefined)?.sessionId;
if (!sessionId) {
res.status(400).json({ error: 'sessionId required' });
return;
}
const profile = deps.sessRepo.getProfileById(id, u.id);
if (!profile || !deps.sessionManager) {
res.status(404).json({ error: 'not found' });
return;
}
await deps.sessionManager.destroySession(sessionId);
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'login_cancel',
result: 'success',
});
res.json({ ok: true });
});
// POST /profiles/:id/test — decrypt the saved state, drive a real headless
// chromium against profile.startUrl, and update the profile's status based
// on the auth-expiry heuristics in browser-session-expiry.
r.post('/profiles/:id/test', async (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const profile = deps.sessRepo.getProfileById(id, u.id);
if (!profile) {
res.status(404).json({ error: 'not found' });
return;
}
if (profile.encryptedStateBlob == null) {
res.status(409).json({ error: 'profile has no saved state' });
return;
}
// Lazy-load Playwright so the bridge layer doesn't require it at import time.
let chromium: typeof import('playwright').chromium;
try {
const pw = await import('playwright');
chromium = pw.chromium;
} catch (e) {
const msg = (e as Error).message;
logger.warn(`[browser-session-api] playwright import failed: ${msg}`);
res.status(503).json({ error: 'playwright not available' });
return;
}
let stateJson: string;
try {
const dek = ensureUserDek(deps, u.id);
stateJson = decryptStateBlob(dek, profile.encryptedStateBlob);
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'decrypt',
result: 'success',
reason: 'test',
});
} catch (e) {
const msg = (e as Error).message;
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'decrypt',
result: 'error',
reason: msg,
});
res.status(500).json({ error: msg });
return;
}
// detectAuthExpiry is loaded via dynamic import to avoid any circular-dep
// surprises between the bridge and engine layers.
const { detectAuthExpiry } = await import('../engine/browser-session-expiry.js');
let browser: import('playwright').Browser | null = null;
try {
const { buildLaunchOptions, applyStealthInitScript } = await import('../engine/browser-launch.js');
const { loadConfig } = await import('../config.js');
browser = await chromium.launch(buildLaunchOptions(loadConfig().browser, true));
const ctx = await browser.newContext({ storageState: JSON.parse(stateJson) });
await applyStealthInitScript(ctx);
const page = await ctx.newPage();
const response = await page.goto(profile.startUrl, { waitUntil: 'load', timeout: 60_000 });
const finalUrl = page.url();
const statusCode = response?.status() ?? 0;
const present = profile.loggedInSelector
? !!(await page.$(profile.loggedInSelector))
: true;
const verdict = detectAuthExpiry({
profile: {
loggedInSelector: profile.loggedInSelector,
loginUrlPatterns: profile.loginUrlPatterns,
},
finalUrl,
statusCode,
loggedInSelectorPresent: present,
});
if (verdict.expired) {
deps.sessRepo.markProfileStatus(id, 'expired', verdict.reason);
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'expire',
result: 'success',
reason: verdict.reason,
});
} else {
deps.sessRepo.markProfileStatus(id, 'active', null);
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'test',
result: 'success',
});
}
res.json({ verdict, finalUrl, statusCode });
} catch (e) {
const msg = (e as Error).message;
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'test',
result: 'error',
reason: msg,
});
res.status(500).json({ error: msg });
} finally {
if (browser) {
try {
await browser.close();
} catch (closeErr) {
logger.warn(`[browser-session-api] browser.close failed: ${(closeErr as Error).message}`);
}
}
}
});
return r;
}
+509
View File
@@ -0,0 +1,509 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { ConfigManager } from '../config-manager.js';
import { mountConfigApi, __clearWorkerBackendsCache } from './config-api.js';
describe('Config API', () => {
let app: express.Application;
let cm: ConfigManager;
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'config-api-'));
writeFileSync(join(tempDir, 'config.yaml'), [
'config_version: 2',
'llm:',
' workers:',
' - id: w1',
' connection_type: direct',
' endpoint: http://localhost:11434/v1',
' model: test-model',
' roles: [auto, fast, quality]',
' max_concurrency: 1',
' enabled: true',
].join('\n'));
cm = new ConfigManager(join(tempDir, 'config.yaml'));
app = express();
app.use(express.json());
mountConfigApi(app, cm);
});
it('GET /api/config returns v2 shape with etag', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.configVersion).toBe(2);
expect(res.body.config.llm.workers[0].model).toBe('test-model');
expect(res.headers.etag).toBeDefined();
});
it('GET /api/config omits legacy provider block and flat storage keys', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.provider).toBeUndefined();
expect(res.body.config.worktreeDir).toBeUndefined();
expect(res.body.config.customPiecesDir).toBeUndefined();
expect(res.body.config.userFolderRoot).toBeUndefined();
});
it('GET /api/config exposes storage.* block when set', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'config_version: 2',
'llm:',
' workers:',
' - id: w1',
' connection_type: direct',
' endpoint: http://x/v1',
' model: m',
'storage:',
' worktree_dir: /tmp/wt',
' custom_pieces_dir: /tmp/pieces',
' task_upload_max_size_mb: 25',
' trash_retention_days: 14',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.storage).toEqual({
worktreeDir: '/tmp/wt',
customPiecesDir: '/tmp/pieces',
taskUploadMaxSizeMb: 25,
trashRetentionDays: 14,
});
// Legacy tools.taskUploadMaxSizeMb / tools.trashRetentionDays must not
// appear under tools.* in v2 output.
expect(res.body.config.tools?.taskUploadMaxSizeMb).toBeUndefined();
expect(res.body.config.tools?.trashRetentionDays).toBeUndefined();
});
it('PUT /api/config updates llm.workers and round-trips through YAML', async () => {
const getRes = await request(app).get('/api/config');
const etag = getRes.headers.etag;
const res = await request(app)
.put('/api/config')
.set('If-Match', etag)
.send({
llm: {
workers: [{
id: 'w1',
connectionType: 'direct',
endpoint: 'http://localhost:11434/v1',
model: 'updated-model',
roles: ['auto', 'fast', 'quality'],
maxConcurrency: 1,
enabled: true,
}],
},
});
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(cm.getConfig().llm?.workers[0]?.model).toBe('updated-model');
// YAML on disk: snake_case + config_version: 2 stamped + no legacy provider block
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('updated-model');
expect(yaml).toContain('config_version: 2');
expect(yaml).toContain('llm:');
expect(yaml).not.toMatch(/^provider:/m);
expect(yaml).not.toMatch(/connectionType/);
});
it('PUT /api/config force-stamps config_version=2 when omitted', async () => {
const getRes = await request(app).get('/api/config');
const etag = getRes.headers.etag;
const res = await request(app)
.put('/api/config')
.set('If-Match', etag)
.send({ concurrency: 4 });
expect(res.status).toBe(200);
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('config_version: 2');
});
it('PUT /api/config rejects body with legacy provider block (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ provider: { model: 'x' } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('provider');
expect(res.body.error).toMatch(/llm\.\*/);
});
it('PUT /api/config rejects body with flat worktreeDir key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ worktreeDir: '/tmp/wt' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('worktreeDir');
expect(res.body.error).toMatch(/storage\.worktreeDir/);
});
it('PUT /api/config rejects body with flat customPiecesDir key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ customPiecesDir: '/tmp/p' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('customPiecesDir');
});
it('PUT /api/config rejects body with flat userFolderRoot key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ userFolderRoot: '/tmp/users' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('userFolderRoot');
});
it('PUT /api/config rejects body with tools.taskUploadMaxSizeMb (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ tools: { taskUploadMaxSizeMb: 50 } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('tools.taskUploadMaxSizeMb');
expect(res.body.error).toMatch(/storage\.taskUploadMaxSizeMb/);
});
it('PUT /api/config rejects body with tools.trashRetentionDays (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ tools: { trashRetentionDays: 7 } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('tools.trashRetentionDays');
});
it('PUT /api/config writes storage.* round-trip cleanly', async () => {
const res = await request(app)
.put('/api/config')
.send({
storage: {
worktreeDir: '/var/lib/aao',
customPiecesDir: '/etc/aao/pieces',
},
});
expect(res.status).toBe(200);
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('storage:');
expect(yaml).toContain('worktree_dir: /var/lib/aao');
expect(yaml).toContain('custom_pieces_dir: /etc/aao/pieces');
// No flat legacy storage keys should appear at top level
expect(yaml).not.toMatch(/^worktree_dir:/m);
expect(yaml).not.toMatch(/^custom_pieces_dir:/m);
});
it('PUT /api/config returns 409 on stale etag', async () => {
const res = await request(app)
.put('/api/config')
.set('If-Match', 'stale-etag')
.send({ concurrency: 2 });
expect(res.status).toBe(409);
});
it('POST /api/config/reload reloads from file', async () => {
const res = await request(app).post('/api/config/reload');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
describe('GET /api/workers', () => {
it('returns the synthesized default worker when no workers are configured', async () => {
// Override the beforeEach v2 fixture with a v1-style empty provider
// to exercise loadConfig's "no workers" auto-gen path.
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: test-model',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers).toHaveLength(1);
expect(res.body.workers[0].id).toBe('default');
});
it('returns workers from config with allowlisted fields only', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: shared-model',
' workers:',
' - id: gpu1',
' endpoint: http://10.0.0.10:11434/v1',
' model: qwen3:8b',
' roles: [auto, fast]',
' enabled: true',
' api_key: super-secret-do-not-leak',
' - id: gpu2',
' endpoint: http://10.0.0.10:11434/v1',
' enabled: false',
' retry:',
' max_attempts: 1',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers).toHaveLength(2);
const [w1, w2] = res.body.workers;
expect(w1).toEqual({
id: 'gpu1',
endpoint: 'http://10.0.0.10:11434/v1',
model: 'qwen3:8b',
roles: ['auto', 'fast'],
enabled: true,
proxy: false,
});
expect(w2.id).toBe('gpu2');
expect(w2.enabled).toBe(false);
// sensitive fields must not leak
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain('api_key');
expect(serialized).not.toContain('super-secret');
});
it('exposes proxy + proxyType on proxy workers (no api_key leak)', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: shared-model',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
' api_key: team-tok-do-not-leak',
' retry:',
' max_attempts: 1',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers[0]).toEqual({
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
model: null,
roles: ['auto', 'fast', 'quality'],
enabled: true,
proxy: true,
proxyType: 'litellm',
});
expect(JSON.stringify(res.body)).not.toContain('team-tok-do-not-leak');
});
});
describe('GET /api/workers/:workerId/backends', () => {
beforeEach(() => {
__clearWorkerBackendsCache();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
__clearWorkerBackendsCache();
});
it('returns 404 for an unknown worker', async () => {
const res = await request(app).get('/api/workers/nope/backends');
expect(res.status).toBe(404);
});
it('returns source=direct with empty backends for non-proxy workers', async () => {
// beforeEach now seeds a v2 worker named w1, so probe its id.
const res = await request(app).get('/api/workers/w1/backends');
expect(res.status).toBe(200);
expect(res.body).toEqual({ source: 'direct', backends: [] });
});
it('fetches /v1/models from a proxy worker and returns the deployment list', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
' api_key: tok-xyz',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'gpu-rtx-a', object: 'model' },
{ id: 'gpu-h100-b', object: 'model' },
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(200);
expect(res.body).toEqual({
source: 'proxy',
proxyType: 'litellm',
backends: [
{ id: 'gpu-rtx-a', model: 'gpu-rtx-a', online: true },
{ id: 'gpu-h100-b', model: 'gpu-h100-b', online: true },
],
});
// Should have called the upstream with the worker's api_key
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0]!;
expect(call[0]).toBe('http://litellm:4000/v1/models');
expect((call[1] as RequestInit).headers).toMatchObject({
Authorization: 'Bearer tok-xyz',
});
});
it('caches successful proxy results across calls (60s TTL)', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'gpu-x' }] }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const r1 = await request(app).get('/api/workers/team-pool/backends');
const r2 = await request(app).get('/api/workers/team-pool/backends');
expect(r1.body).toEqual(r2.body);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('rejects file:// endpoint scheme without leaking apiKey to fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: file:///etc/passwd',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/unsupported endpoint scheme/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects data: endpoint scheme without leaking apiKey to fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: "data:text/plain,hi"',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/unsupported endpoint scheme/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects malformed endpoint URLs without calling fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: "not-a-url"',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/invalid endpoint URL/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns 502 with error payload when the upstream proxy fails', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.source).toBe('proxy');
expect(res.body.backends).toEqual([]);
expect(res.body.error).toContain('ECONNREFUSED');
});
});
describe('/api/workers auth guard', () => {
// server.ts wires `app.use('/api/workers', requireAuth)` when auth is
// active. config-api.ts itself doesn't mount the middleware (separation
// of concerns), so this suite asserts the same middleware shape works
// when callers mount it the way server.ts does.
let guardedApp: express.Application;
let isAuthed: boolean;
const fakeRequireAuth: express.RequestHandler = (_req, res, next) => {
if (isAuthed) {
next();
} else {
res.status(401).json({ error: 'unauthenticated' });
}
};
beforeEach(() => {
isAuthed = true;
guardedApp = express();
guardedApp.use(express.json());
guardedApp.use('/api/workers', fakeRequireAuth);
mountConfigApi(guardedApp, cm);
});
it('returns 401 for unauthenticated GET /api/workers', async () => {
isAuthed = false;
const res = await request(guardedApp).get('/api/workers');
expect(res.status).toBe(401);
});
it('returns 401 for unauthenticated GET /api/workers/:id/backends', async () => {
isAuthed = false;
const res = await request(guardedApp).get('/api/workers/default/backends');
expect(res.status).toBe(401);
});
it('returns 200 for authenticated GET /api/workers', async () => {
isAuthed = true;
const res = await request(guardedApp).get('/api/workers');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.workers)).toBe(true);
});
});
});
+260
View File
@@ -0,0 +1,260 @@
import { type Application, type Request, type Response } from 'express';
import { ConfigManager } from '../config-manager.js';
import { logger } from '../logger.js';
/**
* Backend list response for GET /api/workers/:workerId/backends.
*
* Direct workers always return { source: 'direct', backends: [] } because
* the worker itself IS the node — there is no proxy layer to fan out to.
* Proxy workers (proxy: true) return the deployment list reported by the
* upstream proxy's /v1/models endpoint.
*
* See docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md.
*/
export interface WorkerBackendsResponse {
source: 'direct' | 'proxy';
proxyType?: 'litellm';
backends: Array<{
id: string;
model: string | null;
online: boolean;
}>;
/** Set only when the upstream probe failed; UI uses this to render a degraded badge. */
error?: string;
}
interface BackendsCacheEntry {
expiresAt: number;
payload: WorkerBackendsResponse;
}
/**
* In-memory cache for proxy /v1/models lookups. Prevents the UI (which may
* mount PetsPanel multiple times per session) from hammering the upstream
* proxy. 60s matches the design doc and keeps backend list "fresh enough"
* without coupling to a hard refresh button.
*/
const BACKENDS_CACHE_TTL_MS = 60_000;
const backendsCache = new Map<string, BackendsCacheEntry>();
/** Test hook: clear cache between tests so each case starts fresh. */
export function __clearWorkerBackendsCache(): void {
backendsCache.clear();
}
/**
* Top-level keys that v2 `PUT /api/config` rejects with 400. The migration
* to the v2 shape (design doc 2026-05-21) treats these as authored-in-the-
* wrong-shape errors: the UI is expected to send `llm.*` and `storage.*`
* instead. Returning 400 here (rather than silently dropping them in
* config-manager) gives the UI an actionable error message instead of a
* confusing "save succeeded but nothing changed" outcome.
*/
const V2_REJECTED_TOP_LEVEL_KEYS: Record<string, string> = {
provider: "use 'llm.*' instead (provider block removed in config v2; run scripts/migrate-config.sh to convert)",
worktreeDir: "use 'storage.worktreeDir' instead (flat storage keys removed in config v2)",
customPiecesDir: "use 'storage.customPiecesDir' instead (flat storage keys removed in config v2)",
userFolderRoot: "use 'storage.userFolderRoot' instead (flat storage keys removed in config v2)",
};
function rejectLegacyV2Body(body: Record<string, unknown>): { key: string; message: string } | null {
for (const [key, message] of Object.entries(V2_REJECTED_TOP_LEVEL_KEYS)) {
if (key in body) return { key, message };
}
// tools.taskUploadMaxSizeMb / tools.trashRetentionDays migrated into
// storage.* — reject them too so the UI doesn't silently keep writing
// the old key.
const tools = (body as any).tools;
if (tools && typeof tools === 'object') {
if ('taskUploadMaxSizeMb' in tools) {
return {
key: 'tools.taskUploadMaxSizeMb',
message: "use 'storage.taskUploadMaxSizeMb' instead (moved out of tools.* in config v2)",
};
}
if ('trashRetentionDays' in tools) {
return {
key: 'tools.trashRetentionDays',
message: "use 'storage.trashRetentionDays' instead (moved out of tools.* in config v2)",
};
}
}
return null;
}
export function mountConfigApi(app: Application, configManager: ConfigManager): void {
app.get('/api/config', (_req: Request, res: Response) => {
const { config, etag, overriddenByEnv } = configManager.getConfigForApi();
res.set('ETag', etag);
res.json({ config, overriddenByEnv });
});
app.put('/api/config', (req: Request, res: Response) => {
if (typeof req.body !== 'object' || req.body === null || Array.isArray(req.body)) {
res.status(400).json({ ok: false, error: 'Request body must be a JSON object' }); return;
}
const rejected = rejectLegacyV2Body(req.body as Record<string, unknown>);
if (rejected) {
logger.warn(`[config-api] PUT /api/config rejected v1-shaped key '${rejected.key}'`);
res.status(400).json({
ok: false,
error: `legacy config key '${rejected.key}' is no longer accepted; ${rejected.message}`,
rejectedKey: rejected.key,
});
return;
}
const etag = req.headers['if-match'] as string | undefined;
const result = configManager.updateConfig(req.body, etag);
if (!result.ok) {
const status = (result as any).conflict ? 409 : 400;
res.status(status).json(result);
return;
}
res.json({ ok: true });
});
app.post('/api/config/reload', (_req: Request, res: Response) => {
try {
configManager.reloadFromFile();
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, message: String(e) });
}
});
app.get('/api/workers', (_req: Request, res: Response) => {
const cfg = configManager.getConfig();
const workers = (cfg.provider.workers ?? [])
.filter(w => typeof w.id === 'string' && w.id.length > 0)
.map(w => ({
id: w.id,
endpoint: w.endpoint ?? null,
model: w.model ?? null,
roles: Array.isArray(w.roles) ? w.roles : [],
enabled: w.enabled !== false,
proxy: w.proxy === true,
proxyType: w.proxy === true ? (w.proxyType ?? 'litellm') : undefined,
}));
res.json({ workers });
});
// GET /api/workers/:workerId/backends
//
// Returns the physical backends behind a worker. For direct workers this
// is a trivial empty list (the worker IS the node). For proxy workers
// (proxy: true), we proxy to <endpoint>/v1/models and translate the
// result into a uniform shape. Cached for 60s per worker so the panel
// can be re-rendered freely without hammering the upstream.
app.get('/api/workers/:workerId/backends', async (req: Request, res: Response) => {
const workerId = String(req.params['workerId'] ?? '');
const cfg = configManager.getConfig();
const worker = (cfg.provider.workers ?? []).find(w => w.id === workerId);
if (!worker) {
res.status(404).json({ error: 'worker not found' });
return;
}
if (worker.proxy !== true) {
res.json({ source: 'direct', backends: [] } satisfies WorkerBackendsResponse);
return;
}
const cacheKey = `${workerId}|${worker.endpoint}`;
const cached = backendsCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.payload);
return;
}
try {
const payload = await fetchProxyBackends(worker.endpoint, worker.apiKey);
backendsCache.set(cacheKey, { expiresAt: Date.now() + BACKENDS_CACHE_TTL_MS, payload });
res.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`[config-api] /api/workers/${workerId}/backends failed: ${message}`);
const payload: WorkerBackendsResponse = {
source: 'proxy',
proxyType: 'litellm',
backends: [],
error: message,
};
// Cache the failure briefly so a flapping upstream doesn't cause a
// request storm. Shorter than success TTL on purpose.
backendsCache.set(cacheKey, { expiresAt: Date.now() + 10_000, payload });
res.status(502).json(payload);
}
});
}
interface LiteLLMModelEntry {
id?: unknown;
// LiteLLM's /v1/models response includes a few non-standard fields
// alongside the OpenAI-shape entries; we only consume `id`.
[key: string]: unknown;
}
/**
* Fetch the physical backend list from a LiteLLM-style proxy.
*
* Calls `<endpoint>/v1/models` and converts each entry into a NodeStatus-
* compatible backend record. v1 treats every returned id as `online: true`;
* Phase B's BackendStatusRegistry will replace this with /health-derived
* status when it lands.
*
* Exported for unit tests to drive end-to-end without a live proxy.
*/
export async function fetchProxyBackends(endpoint: string, apiKey?: string): Promise<WorkerBackendsResponse> {
// Defense-in-depth: validate the endpoint URL before we touch it. Without
// this guard, an admin (or a config write bug) could set the endpoint to
// `file://...`, `data:...`, or `javascript:...` and we'd happily ship the
// worker's apiKey as a Bearer token to whatever fetch() interprets it as.
// /api/workers is admin-adjacent but the apiKey here may be a shared GPU
// pool credential — leaking it to an arbitrary scheme is a credential
// exfil primitive, not just a logic bug.
//
// Allowlist http: and https:. v1 doesn't enforce a host allowlist (admin
// is trusted to point at the right proxy); scheme validation alone closes
// the credential-leak class.
let parsed: URL;
try {
parsed = new URL(endpoint);
} catch {
throw new Error(`invalid endpoint URL: ${endpoint}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`unsupported endpoint scheme: ${parsed.protocol} (only http: and https: are allowed)`);
}
// /v1/models is the canonical OpenAI-compatible discovery endpoint.
// LiteLLM honors it and returns the union of all configured deployments
// visible under the caller's virtual key.
const trimmed = endpoint.replace(/\/+$/, '');
const url = `${trimmed}/models`;
const headers: Record<string, string> = { Accept: 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const res = await fetch(url, { method: 'GET', headers });
if (!res.ok) {
throw new Error(`proxy /v1/models returned HTTP ${res.status}`);
}
const body = await res.json() as { data?: LiteLLMModelEntry[] } | LiteLLMModelEntry[];
const data: LiteLLMModelEntry[] = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
const seen = new Set<string>();
const backends: WorkerBackendsResponse['backends'] = [];
for (const entry of data) {
const id = typeof entry?.['id'] === 'string' ? (entry['id'] as string).trim() : '';
if (!id || seen.has(id)) continue;
seen.add(id);
// LiteLLM populates `id` with the model alias users request; deployment
// detail (litellm_params.model) is only returned via the admin
// `/model/info` endpoint, which requires admin auth. v1 surfaces just
// the alias; richer detail can be added in Phase B without changing
// the response shape.
backends.push({ id, model: id, online: true });
}
return { source: 'proxy', proxyType: 'litellm', backends };
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createConsoleAdminRouter } from './console-admin-api.js';
function mkApp(registry: any) {
const app = express();
app.use(express.json());
app.use('/api/admin', createConsoleAdminRouter({
registry,
requireAdmin: (_req: any, _res: any, next: any) => next(),
}));
return app;
}
describe('console admin API', () => {
it('lists active sessions', async () => {
const registry = {
listAll: () => [
{ localTaskId: 't1', connectionId: 'c1', ownerId: 'u1', startedAt: 1000,
lastActivityAt: 2000, totalInputBytes: 10, totalOutputBytes: 20, isClosed: false },
],
};
const res = await request(mkApp(registry)).get('/api/admin/ssh/console-sessions');
expect(res.status).toBe(200);
expect(res.body.sessions).toHaveLength(1);
expect(res.body.sessions[0].task_id).toBe('t1');
});
it('kills session by task id', async () => {
const registry = {
listAll: () => [],
closeForTask: vi.fn(async () => {}),
};
const res = await request(mkApp(registry))
.post('/api/admin/ssh/console-sessions/t1/kill')
.send({ reason: 'investigating' });
expect(res.status).toBe(200);
expect(res.body.closed).toBe(true);
expect(registry.closeForTask).toHaveBeenCalledWith('t1', 'admin_kill');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { Router, type Request, type Response } from 'express';
import type { SessionRegistry } from '../ssh/console-registry.js';
export function createConsoleAdminRouter(deps: {
registry: SessionRegistry;
requireAdmin: any;
}): Router {
const r = Router();
r.get('/ssh/console-sessions', deps.requireAdmin, (_req: Request, res: Response) => {
const sessions = deps.registry.listAll().map((s) => ({
task_id: s.localTaskId,
owner_id: s.ownerId,
connection_id: s.connectionId,
started_at: new Date(s.startedAt).toISOString(),
last_activity_at: new Date(s.lastActivityAt).toISOString(),
total_input_bytes: s.totalInputBytes,
total_output_bytes: s.totalOutputBytes,
}));
res.json({ sessions });
});
r.post('/ssh/console-sessions/:taskId/kill', deps.requireAdmin, async (req: Request, res: Response) => {
const taskId = req.params.taskId!;
await deps.registry.closeForTask(taskId, 'admin_kill');
res.json({ closed: true });
});
return r;
}
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import express from 'express';
import request from 'supertest';
import { decideAccess, handleConsoleSocket, createConsoleStatusRouter } from './console-ws-api.js';
import { runMigrations } from '../db/migrate.js';
import { createAccessResolver } from '../ssh/access.js';
import { createGrantsRepo } from '../ssh/grants-repo.js';
import type { SshConnection } from '../ssh/connection-repo.js';
describe('decideAccess', () => {
const baseTask = { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console' };
it('rejects unauthenticated', () => {
const r = decideAccess({ user: null, task: baseTask, session: null, accessAllowed: false });
expect(r.allowed).toBe(false);
expect((r as any).reason).toBe('unauthenticated');
});
it('rejects when task not visible', () => {
const r = decideAccess({ user: { id: 'u2', role: 'user' } as any, task: null, session: null, accessAllowed: false });
expect(r.allowed).toBe(false);
expect((r as any).reason).toBe('task_not_visible');
});
it('rejects when no active session', () => {
const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session: null, accessAllowed: true });
expect(r.allowed).toBe(false);
expect((r as any).reason).toBe('no_session');
});
it('rejects when SSH access denied', () => {
const session = { connectionId: 'c1' } as any;
const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session, accessAllowed: false });
expect(r.allowed).toBe(false);
expect((r as any).reason).toBe('no_grant');
});
it('owner gets canWrite=true', () => {
const session = { connectionId: 'c1' } as any;
const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session, accessAllowed: true });
expect(r.allowed).toBe(true);
if (r.allowed) expect(r.canWrite).toBe(true);
});
it('non-owner with task visibility gets canWrite=false', () => {
const session = { connectionId: 'c1' } as any;
const r = decideAccess({ user: { id: 'other', role: 'user' } as any, task: { ...baseTask, visibility: 'org' } as any, session, accessAllowed: true });
expect(r.allowed).toBe(true);
if (r.allowed) expect(r.canWrite).toBe(false);
});
it('admin always canWrite=true', () => {
const session = { connectionId: 'c1' } as any;
const r = decideAccess({ user: { id: 'admin', role: 'admin' } as any, task: baseTask, session, accessAllowed: true });
expect(r.allowed).toBe(true);
if (r.allowed) expect(r.canWrite).toBe(true);
});
});
class FakeWS extends EventEmitter {
readyState = 1; // OPEN
OPEN = 1;
sent: Array<{ kind: 'text' | 'binary'; data: any }> = [];
send(data: any, opts?: { binary?: boolean }) {
if (opts?.binary) this.sent.push({ kind: 'binary', data });
else this.sent.push({ kind: 'text', data: JSON.parse(data) });
}
}
function fakeSessionForWs() {
return {
cols: 80, rows: 24,
connectionId: 'c1',
scrollbackBytes: () => Buffer.alloc(0),
onOutput: (_cb: any) => () => {},
write: vi.fn(),
resize: vi.fn(),
addViewer: vi.fn(() => () => {}),
listViewers: vi.fn(() => []),
} as any;
}
describe('handleConsoleSocket', () => {
it('drops human input that fails deny-list and emits notice', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
ws.emit('message', Buffer.from('rm -rf /\n'), true);
expect(session.write).not.toHaveBeenCalled();
const notice = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'notice');
expect(notice).toBeDefined();
expect((notice as any).data.severity).toBe('error');
});
it('forwards safe human input to session.write', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
ws.emit('message', Buffer.from('uptime\n'), true);
expect(session.write).toHaveBeenCalled();
const arg = session.write.mock.calls[0][0] as Buffer;
expect(arg.toString()).toBe('uptime\n');
});
it('rejects input when canWrite=false', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u2', role: 'user' }, false, { deny: [], allow: [] });
ws.emit('message', Buffer.from('uptime\n'), true);
expect(session.write).not.toHaveBeenCalled();
const notice = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'notice');
expect(notice).toBeDefined();
expect((notice as any).data.severity).toBe('warn');
});
it('handles resize text frame when canWrite=true', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 100, rows: 40 })), false);
expect(session.resize).toHaveBeenCalledWith(100, 40);
});
it('ignores resize text frame when canWrite=false', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u2', role: 'user' }, false, { deny: [], allow: [] });
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 100, rows: 40 })), false);
expect(session.resize).not.toHaveBeenCalled();
});
it('registers a ViewerHandle with the session on attach', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
expect(session.addViewer).toHaveBeenCalledTimes(1);
const handle = (session.addViewer as any).mock.calls[0][0];
expect(handle.userId).toBe('u1');
expect(typeof handle.close).toBe('function');
});
it('viewer.close() sends a close message and ws.close(1008, reason)', () => {
const ws = new FakeWS();
(ws as any).close = vi.fn();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
const handle = (session.addViewer as any).mock.calls[0][0];
handle.close('access_revoked');
const closeMsg = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'close');
expect(closeMsg).toBeDefined();
expect((closeMsg as any).data.reason).toBe('access_revoked');
expect((ws as any).close).toHaveBeenCalledWith(1008, 'access_revoked');
});
it('viewer unsubscribes from session on ws close', () => {
const ws = new FakeWS();
const unsubViewer = vi.fn();
const session = fakeSessionForWs();
(session.addViewer as any).mockReturnValue(unsubViewer);
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
ws.emit('close');
expect(unsubViewer).toHaveBeenCalled();
});
});
// Regression: PR fixing "wss error for non-admin owner of task with piece-specific grant".
// Documents the contract that the WS upgrade access check MUST pass the
// task's pieceName to accessResolver so piece-specific grants match.
// Before fix: server.ts resolveSshAccess hardcoded pieceName: '' → all
// piece-specific grants silently failed (no_grant) even when one existed.
describe('regression: piece-specific grant matching via accessResolver', () => {
let tmpRoot: string;
let db: Database.Database;
const CONN_ID = 'conn-global-1';
const USER_ID = 'user-non-admin-1';
const PIECE = 'ssh-console';
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'console-ws-regression-'));
db = new Database(join(tmpRoot, 'test.db'));
runMigrations(db);
// Insert a global connection (ownerId=NULL) so the owner branch in
// access.ts doesn't short-circuit — forces grant lookup.
db.prepare(`
INSERT INTO ssh_connections (id, owner_id, label, host, port, username, private_key_enc, remote_path_prefix, enabled, created_at, updated_at)
VALUES (?, NULL, 'global', 'host.example', 22, 'u', X'00', '/srv', 1, datetime('now'), datetime('now'))
`).run(CONN_ID);
// Insert a piece-specific grant for the user.
const grantsRepo = createGrantsRepo(db);
grantsRepo.create({
connectionId: CONN_ID,
subjectType: 'user',
subjectId: USER_ID,
pieceName: PIECE,
appliesToAllPieces: false,
grantedByUserId: 'admin-1',
reason: 'regression test',
});
});
afterEach(() => {
db.close();
rmSync(tmpRoot, { recursive: true, force: true });
});
function connection(): SshConnection {
return {
id: CONN_ID,
ownerId: null,
label: 'global',
host: 'host.example',
port: 22,
username: 'u',
privateKeyEnc: Buffer.alloc(0),
passphraseEnc: null,
keyVersion: 1,
keyFingerprint: null,
hostKeyType: null,
hostKeyB64: null,
hostKeyFingerprint: null,
hostKeyRecordedAt: null,
hostKeyVerifiedAt: null,
hostKeyPending: false,
hostKeyPendingB64: null,
hostKeyPendingFingerprint: null,
hostKeyPendingToken: null,
hostKeyPendingSource: null,
commandDenyPatterns: null,
commandAllowPatterns: null,
remotePathPrefix: '/srv',
enabled: true,
allowRemoteUnrestricted: false,
allowPrivateAddresses: false,
createdAt: '',
updatedAt: '',
} as unknown as SshConnection;
}
it('access GRANTED when pieceName matches the grant (the fix)', () => {
const grants = createGrantsRepo(db);
const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
const decision = resolver.resolveAccess({
connection: connection(),
userId: USER_ID,
isAdmin: false,
pieceName: PIECE, // <-- the fix passes the task's actual pieceName
orgIds: [],
});
expect(decision.allowed).toBe(true);
expect((decision as any).via).toBe('grant');
});
it('access DENIED when pieceName is empty (the original bug)', () => {
const grants = createGrantsRepo(db);
const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
const decision = resolver.resolveAccess({
connection: connection(),
userId: USER_ID,
isAdmin: false,
pieceName: '', // <-- pre-fix server.ts hardcoded this; grant never matches
orgIds: [],
});
expect(decision.allowed).toBe(false);
expect((decision as any).reason).toBe('no_grant');
});
it('access DENIED when pieceName differs from the grant', () => {
const grants = createGrantsRepo(db);
const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
const decision = resolver.resolveAccess({
connection: connection(),
userId: USER_ID,
isAdmin: false,
pieceName: 'unrelated-piece',
orgIds: [],
});
expect(decision.allowed).toBe(false);
});
});
describe('createConsoleStatusRouter', () => {
// Issue #347 regression: the App.tsx-side poller fires
// GET /api/local/tasks/:taskId/console/status every 5 seconds for
// the currently-selected local task. Returning 404 when the task is
// missing / not-visible logged an unsuppressible network error in
// the browser DevTools console on every tick. The router now returns
// 200 active=false instead, matching the no-session shape.
function buildApp(opts: {
resolveTask?: (id: string, user: any) => Promise<any>;
registry?: { get: (id: string) => any };
user?: any;
}) {
const app = express();
if (opts.user) {
app.use((req, _res, next) => { (req as any).user = opts.user; next(); });
}
app.use('/api', createConsoleStatusRouter({
registry: (opts.registry ?? { get: () => null }) as any,
requireAuth: (_req: any, _res: any, next: any) => next(),
resolveTask: opts.resolveTask ?? (async () => null),
}));
return app;
}
it('returns 200 active=false when task is not visible to the user (was 404)', async () => {
const app = buildApp({
user: { id: 'alice', role: 'user' },
resolveTask: async () => null, // not visible
});
const res = await request(app).get('/api/local/tasks/182/console/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ active: false });
});
it('returns 200 active=false when task exists but no SSH session is open', async () => {
const app = buildApp({
user: { id: 'alice', role: 'user' },
resolveTask: async () => ({ id: 't1' }),
registry: { get: () => null },
});
const res = await request(app).get('/api/local/tasks/t1/console/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ active: false });
});
it('returns 200 active=true with session metadata when session is live', async () => {
const now = Date.now();
const app = buildApp({
user: { id: 'alice', role: 'user' },
resolveTask: async () => ({ id: 't1' }),
registry: { get: () => ({
connectionId: 'conn-1',
startedAt: now - 60_000,
lastActivityAt: now,
cols: 120,
rows: 30,
}) },
});
const res = await request(app).get('/api/local/tasks/t1/console/status');
expect(res.status).toBe(200);
expect(res.body.active).toBe(true);
expect(res.body.connection_id).toBe('conn-1');
expect(res.body.cols).toBe(120);
});
it('still returns 401 when there is no authenticated user', async () => {
const app = buildApp({}); // no user middleware
const res = await request(app).get('/api/local/tasks/t1/console/status');
expect(res.status).toBe(401);
});
});
+291
View File
@@ -0,0 +1,291 @@
import type { IncomingMessage, Server as HttpServer } from 'node:http';
import type { Socket } from 'node:net';
import { WebSocketServer, type WebSocket } from 'ws';
import { Router, type Request, type Response } from 'express';
import { logger } from '../logger.js';
import type { SessionRegistry } from '../ssh/console-registry.js';
import type { ConsoleSession } from '../ssh/console-session.js';
import type { AttachMessage, ServerTextMessage } from '../ssh/console-protocol.js';
import { checkConsoleInput } from '../ssh/console-deny-check.js';
export interface SimpleUser { id: string; role: 'admin' | 'user' | string }
export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: string }
export type AccessDecision =
| { allowed: true; canWrite: boolean }
| { allowed: false; reason: 'unauthenticated' | 'task_not_visible' | 'no_session' | 'no_grant' };
/**
* Pure access decision for an SSH Console WS attach attempt.
*
* - Unauthenticated → reject
* - Task not visible → reject ("not found"-like)
* - No active session for the task → reject
* - SSH access denied (no grant) → reject
* - Otherwise → allow; canWrite gated on owner OR admin.
*
* Non-owners with task-visibility (e.g. org members on an org-visible task)
* attach as read-only viewers — they see scrollback + live output but cannot
* type or resize.
*/
export function decideAccess(args: {
user: SimpleUser | null;
task: SimpleTask | null;
session: ConsoleSession | null;
accessAllowed: boolean;
}): AccessDecision {
if (!args.user) return { allowed: false, reason: 'unauthenticated' };
if (!args.task) return { allowed: false, reason: 'task_not_visible' };
if (!args.session) return { allowed: false, reason: 'no_session' };
if (!args.accessAllowed) return { allowed: false, reason: 'no_grant' };
const canWrite = args.user.id === args.task.ownerId || args.user.role === 'admin';
return { allowed: true, canWrite };
}
export interface DenyPatternProvider {
/** Returns the {deny, allow} regex patterns for a given connection_id. */
getPatterns(connectionId: string): Promise<{ deny: string[]; allow: string[] }>;
}
export interface ConsoleWsDeps {
registry: SessionRegistry;
resolveUserFromUpgrade: (req: IncomingMessage) => Promise<SimpleUser | null>;
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
resolveSshAccess: (user: SimpleUser, session: ConsoleSession, task: SimpleTask) => Promise<boolean>;
denyPatterns: DenyPatternProvider;
}
const PATH_RE = /^\/+api\/local\/tasks\/([^/]+)\/console\/ws$/;
/**
* Attach the SSH Console WebSocket upgrade handler to the given http.Server.
*
* Matches paths of the form /api/local/tasks/:taskId/console/ws and runs the
* full auth + access pipeline. Rejected upgrades are silently destroyed
* (the client gets a 1006 abnormal close) so we don't leak failure
* reasons over the upgrade channel. The reason is always logged.
*/
export function attachConsoleWs(server: HttpServer, deps: ConsoleWsDeps): void {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', async (req, socket, head) => {
const url = req.url ?? '';
const m = url.match(PATH_RE);
if (!m) return;
const taskId = decodeURIComponent(m[1]!);
try {
const user = await deps.resolveUserFromUpgrade(req);
const task = user ? await deps.resolveTask(taskId, user) : null;
const session = deps.registry.get(taskId);
const accessAllowed = !!(user && task && session)
? await deps.resolveSshAccess(user, session, task)
: false;
const decision = decideAccess({ user: user ?? null, task, session, accessAllowed });
if (!decision.allowed) {
logger.info(`[console-ws] reject taskId=${taskId} reason=${decision.reason}`);
socket.destroy();
return;
}
const patterns = await deps.denyPatterns.getPatterns(session!.connectionId);
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
handleConsoleSocket(ws, session!, user!, decision.canWrite, patterns);
});
} catch (e) {
logger.warn(`[console-ws] upgrade error: ${(e as Error).message}`);
socket.destroy();
}
});
}
/**
* Handle a single accepted Console WebSocket. The caller has already done
* auth + access + scrollback fetch.
*
* Wire protocol (see console-protocol.ts):
* - server → client: attach (JSON), ESC c (binary reset), replay bytes,
* replay_begin (JSON), replay_end (JSON), live binary output, notices
* - client → server: binary input frames (forwarded to PTY) and JSON
* control frames (`resize`).
*
* Input policy:
* - canWrite=false ⇒ all input is rejected with a 'warn' notice.
* - canWrite=true ⇒ input is forwarded as-is to the PTY, BUT any chunk
* containing a line terminator is first checked against the connection's
* deny/allow patterns. A rejected line drops the WHOLE chunk and emits
* an 'error' notice. The check fires only on chunks containing a CR/LF
* since pre-Enter keystrokes are partial input the operator hasn't
* committed yet — the live shell echo will show them on screen but
* they only matter for safety once the line is submitted.
*/
export function handleConsoleSocket(
ws: WebSocket,
session: ConsoleSession,
user: SimpleUser,
canWrite: boolean,
patterns: { deny: string[]; allow: string[] },
): void {
const sendText = (msg: ServerTextMessage) => {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
};
const sendBinary = (buf: Buffer) => {
if (ws.readyState === ws.OPEN) ws.send(buf, { binary: true });
};
const attachMsg: AttachMessage = {
type: 'attach',
acting_user_id: user.id,
can_write: canWrite,
connection_id: session.connectionId,
cols: session.cols,
rows: session.rows,
};
sendText(attachMsg);
// Replay scrollback: ESC c reset, raw bytes, then markers around the bytes.
// The ESC c ensures the client terminal starts fresh even if it was already
// attached to another session before this one.
const scroll = session.scrollbackBytes();
sendBinary(Buffer.from([0x1b, 0x63])); // ESC c — full reset
sendText({ type: 'replay_begin', bytes: scroll.length });
if (scroll.length > 0) sendBinary(scroll);
sendText({ type: 'replay_end' });
const unsub = session.onOutput((b) => sendBinary(b));
// Register this WS as a viewer so the registry can selectively kick it
// (e.g. when its access grant is revoked) without tearing down the whole
// SSH session. unsubViewer must fire on `close` along with unsub.
const unsubViewer = session.addViewer({
userId: user.id,
close: (reason) => {
try {
sendText({ type: 'close', reason });
} catch { /* socket gone */ }
try {
// 1008 = Policy Violation — appropriate for authorization revocation.
ws.close(1008, reason);
} catch { /* already closed */ }
},
});
// Heartbeat — without this, a half-dead WS (TCP alive but the peer
// can't respond, e.g. laptop suspended or NAT/proxy dropped state)
// never fires `close` and the UI just silently swallows user input.
// Send ping every 30s; if no pong within the next ping cycle, treat
// as dead and terminate so the client switches to `disconnected`
// and the user knows to refresh / reconnect.
let alive = true;
const heartbeatTimer = setInterval(() => {
if (!alive) {
logger.warn(`[console-ws] heartbeat timeout for task=${session.localTaskId} — terminating`);
try { ws.terminate(); } catch { /* already dead */ }
return;
}
alive = false;
try {
ws.ping();
} catch (e) {
logger.warn(`[console-ws] ping failed: ${(e as Error).message}`);
}
}, 30_000);
ws.on('pong', () => { alive = true; });
ws.on('message', (data, isBinary) => {
if (isBinary) {
if (!canWrite) {
sendText({ type: 'notice', severity: 'warn', msg: 'read-only viewer; input ignored.' });
return;
}
const buf = data as Buffer;
const text = buf.toString('utf8');
if (/[\r\n]/.test(text)) {
const denyResult = checkConsoleInput(
text,
patterns.deny.length ? patterns.deny : null,
patterns.allow.length ? patterns.allow : null,
);
if (!denyResult.ok) {
sendText({
type: 'notice',
severity: 'error',
msg: `command rejected: ${denyResult.reason} (${denyResult.matched ?? 'n/a'})`,
});
return;
}
}
session.write(buf, 'human');
return;
}
try {
const msg = JSON.parse(String(data));
if (msg && msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
if (canWrite) session.resize(msg.cols, msg.rows);
}
} catch (e) {
logger.warn(`[console-ws] bad text frame: ${(e as Error).message}`);
}
});
ws.on('close', () => {
clearInterval(heartbeatTimer);
unsub();
unsubViewer();
});
}
/**
* REST router exposing GET /local/tasks/:taskId/console/status.
*
* Used by the UI to know whether a Console tab should render an attach
* button (active=true) or a "no live session" empty state.
*/
export function createConsoleStatusRouter(deps: {
registry: SessionRegistry;
requireAuth: any;
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
}): Router {
const r = Router();
r.get(
'/local/tasks/:taskId/console/status',
deps.requireAuth,
async (req: Request, res: Response) => {
const taskId = req.params.taskId!;
const user = (req.user as SimpleUser | undefined) ?? null;
if (!user) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const task = await deps.resolveTask(taskId, user);
if (!task) {
// Task is missing or not visible to this user. Return 200
// active=false rather than 404: the UI's App.tsx polls this
// endpoint every 5 seconds for the currently-selected local
// task, and a 404 logs an unsuppressible network error in
// the browser DevTools console on every tick (reported as
// issue #347 during dogfooding). The poll only needs to
// know whether a SSH attach button should be rendered, and
// "no, you can't attach" is the same answer whether the
// task doesn't exist or just doesn't expose a session —
// collapsing both into 200 active=false matches the rest of
// the route's fallback shape without leaking task existence
// either way.
res.json({ active: false });
return;
}
const s = deps.registry.get(taskId);
if (!s) {
res.json({ active: false });
return;
}
res.json({
active: true,
connection_id: s.connectionId,
started_at: new Date(s.startedAt).toISOString(),
last_activity_at: new Date(s.lastActivityAt).toISOString(),
cols: s.cols,
rows: s.rows,
});
},
);
return r;
}
+335
View File
@@ -0,0 +1,335 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { createDashboardApi } from './dashboard-api.js';
import type { BackendStatusRegistry, NodeStatus } from '../engine/backend-status-registry.js';
function makeApp(userId: string, repo: Repository, opts?: {
registry?: BackendStatusRegistry | null;
}): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user' };
next();
});
app.use(
'/api/local/dashboard',
createDashboardApi({
repo,
getWorkers: () => [
{ id: 'w1', endpoint: 'x', roles: ['task'] },
],
authActive: true,
backendStatusRegistry: opts?.registry ?? null,
}),
);
return app;
}
function stubRegistry(nodes: NodeStatus[]): BackendStatusRegistry {
return {
start: () => {},
stop: async () => {},
getAll: () => nodes.slice(),
getByNodeId: (id) => nodes.find(n => n.nodeId === id) ?? null,
subscribe: () => () => {},
refresh: async () => {},
};
}
describe('Dashboard API', () => {
let tmpDir: string;
let repo: Repository;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'dashboard-api-test-'));
repo = new Repository(join(tmpDir, 'test.db'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('GET /widgets returns empty when none', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/widgets');
expect(res.status).toBe(200);
expect(res.body.widgets).toEqual([]);
});
it('POST /widgets creates a widget', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo', content: 'hi' });
expect(res.status).toBe(201);
expect(res.body.widget.slug).toBe('memo');
expect(res.body.widget.markdownContent).toBe('hi');
});
it('POST /widgets rejects invalid slug', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'Bad Slug!', title: 't', content: '' });
expect(res.status).toBe(400);
});
it('POST /widgets rejects duplicate slug', async () => {
const app = makeApp('u1', repo);
await request(app).post('/api/local/dashboard/widgets').send({ slug: 'memo', title: 'a', content: '' });
const dup = await request(app).post('/api/local/dashboard/widgets').send({ slug: 'memo', title: 'b', content: '' });
expect(dup.status).toBe(409);
});
it('PATCH /widgets/:id updates content', async () => {
const app = makeApp('u1', repo);
const created = await request(app).post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: 'old' });
const id = created.body.widget.id;
const res = await request(app).patch(`/api/local/dashboard/widgets/${id}`).send({ content: 'new' });
expect(res.status).toBe(200);
expect(res.body.widget.markdownContent).toBe('new');
});
it('PATCH /widgets/:id returns 404 for other user', async () => {
const created = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: '' });
const res = await request(makeApp('u2', repo))
.patch(`/api/local/dashboard/widgets/${created.body.widget.id}`).send({ content: 'hack' });
expect(res.status).toBe(404);
});
it('DELETE /widgets/:id removes for owner only', async () => {
const created = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: '' });
const other = await request(makeApp('u2', repo))
.delete(`/api/local/dashboard/widgets/${created.body.widget.id}`);
expect(other.status).toBe(404);
const owner = await request(makeApp('u1', repo))
.delete(`/api/local/dashboard/widgets/${created.body.widget.id}`);
expect(owner.status).toBe(204);
});
it('PUT /widgets/reorder reorders within user scope', async () => {
const app = makeApp('u1', repo);
const a = (await request(app).post('/api/local/dashboard/widgets').send({ slug: 'a', title: 'A', content: '' })).body.widget.id;
const b = (await request(app).post('/api/local/dashboard/widgets').send({ slug: 'b', title: 'B', content: '' })).body.widget.id;
const res = await request(app).put('/api/local/dashboard/widgets/reorder').send({ ids: [b, a] });
expect(res.status).toBe(200);
const list = await request(app).get('/api/local/dashboard/widgets');
expect(list.body.widgets.map((w: any) => w.slug)).toEqual(['b', 'a']);
});
it('GET /workers returns idle/running per worker', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/workers');
expect(res.status).toBe(200);
expect(res.body.workers).toHaveLength(1);
expect(res.body.workers[0].id).toBe('w1');
expect(res.body.workers[0].state).toBe('idle');
});
it('GET /workers does not include job id/title/owner', async () => {
const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed' });
await repo.updateJob(j.id, { status: 'running', workerId: 'w1' });
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/workers');
const keys = Object.keys(res.body.workers[0]).sort();
// `proxy` was added when Worker widget gained tree-expand for proxy
// workers (PR #350). `backends` / `busySlots` / `totalSlots` / `online`
// are conditional (proxy with registry, direct with registry) so they
// can be absent. The privacy contract here is the negative — job id,
// title, and owner must never appear — so assert that explicitly
// alongside the allowed-key whitelist.
expect(keys.includes('proxy')).toBe(true);
const allowed = new Set(['id', 'name', 'roles', 'state', 'proxy', 'backends', 'busySlots', 'totalSlots', 'online']);
for (const k of keys) {
expect(allowed.has(k)).toBe(true);
}
// Defensive: leaks would show up as one of these substrings.
const serialized = JSON.stringify(res.body.workers[0]);
expect(serialized).not.toMatch(/local\/task-1/);
expect(serialized).not.toMatch(/instruction|"u1"|"seed"/);
});
it('returns 401 when no req.user and authActive=true', async () => {
const app = express();
app.use(express.json());
app.use('/api/local/dashboard', createDashboardApi({
repo,
getWorkers: () => [],
authActive: true,
}));
const res = await request(app).get('/api/local/dashboard/widgets');
expect(res.status).toBe(401);
});
it('POST /widgets accepts kind=node-status', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
expect(res.status).toBe(201);
expect(res.body.widget.kind).toBe('node-status');
});
it('POST /widgets defaults kind to markdown when omitted', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo' });
expect(res.status).toBe(201);
expect(res.body.widget.kind).toBe('markdown');
});
it('POST /widgets rejects unknown kind', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'x', title: 'X', kind: 'mystery' });
expect(res.status).toBe(400);
});
it('PATCH /widgets/:id rejects content edits on node-status widgets (400)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
expect(created.status).toBe(201);
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ content: 'manual override' });
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain('node-status');
});
it('PATCH /widgets/:id allows title-only edits on node-status widgets (200)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ title: 'GPU Pool' });
expect(res.status).toBe(200);
expect(res.body.widget.title).toBe('GPU Pool');
expect(res.body.widget.kind).toBe('node-status');
});
it('PATCH /widgets/:id still allows content edits on markdown widgets (regression)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo', content: 'old' });
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ content: 'new content' });
expect(res.status).toBe(200);
expect(res.body.widget.markdownContent).toBe('new content');
});
it('GET /node-status returns 503 when registry is not configured', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/node-status');
expect(res.status).toBe(503);
});
it('GET /node-status returns registry snapshot', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
.get('/api/local/dashboard/node-status');
expect(res.status).toBe(200);
expect(res.body.nodes).toEqual(nodes);
});
it('GET /node-status sets Cache-Control: no-store and a weak ETag', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
.get('/api/local/dashboard/node-status');
expect(res.status).toBe(200);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.headers['etag']).toMatch(/^W\/"[0-9a-f]{16}"$/);
});
it('GET /node-status returns 304 on If-None-Match match', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
const first = await request(app).get('/api/local/dashboard/node-status');
const etag = first.headers['etag'];
const second = await request(app)
.get('/api/local/dashboard/node-status')
.set('If-None-Match', etag);
expect(second.status).toBe(304);
// 304 must not carry a body.
expect(second.text).toBe('');
});
it('GET /node-status returns 304 on multi-value If-None-Match (RFC 9110 §13.1.2)', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
const first = await request(app).get('/api/local/dashboard/node-status');
const etag = first.headers['etag'] as string;
// Browsers' BFCache restore and HTTP intermediaries can produce
// comma-separated multi-tag If-None-Match headers. The server must
// match any of them per RFC 9110 §13.1.2.
const multi = `W/"deadbeefdeadbeef", ${etag}, W/"cafef00dcafef00d"`;
const second = await request(app)
.get('/api/local/dashboard/node-status')
.set('If-None-Match', multi);
expect(second.status).toBe(304);
expect(second.text).toBe('');
});
it('GET /node-status returns 200 when no tag in multi-value If-None-Match matches', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
const second = await request(app)
.get('/api/local/dashboard/node-status')
.set('If-None-Match', 'W/"deadbeef", W/"cafef00d"');
expect(second.status).toBe(200);
expect(second.body.nodes).toEqual(nodes);
});
it('GET /node-status returns 200 when If-None-Match header is absent', async () => {
const nodes: NodeStatus[] = [];
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
.get('/api/local/dashboard/node-status');
expect(res.status).toBe(200);
expect(res.body.nodes).toEqual([]);
});
it('GET /node-status calls noteSubscriberActivity when available', async () => {
const nodes: NodeStatus[] = [];
const stub = stubRegistry(nodes) as BackendStatusRegistry & { calls: number };
stub.calls = 0;
(stub as any).noteSubscriberActivity = () => { stub.calls++; };
const res = await request(makeApp('u1', repo, { registry: stub }))
.get('/api/local/dashboard/node-status');
expect(res.status).toBe(200);
expect(stub.calls).toBe(1);
});
});
+277
View File
@@ -0,0 +1,277 @@
/**
* dashboard-api.ts — REST router for Side Info Panel.
* Mounted at /api/local/dashboard.
*
* Routes:
* GET /widgets — list current user's widgets
* POST /widgets — create
* PATCH /widgets/:id — update title/content
* DELETE /widgets/:id — delete
* PUT /widgets/reorder — reorder by id list
* GET /workers — worker idle/running (no job details)
*
* Auth: all routes require req.user (or fall back to 'local' when authActive=false).
* Owner: every operation scopes to req.user.id; cross-user access returns 404.
*/
import { Router, type Request, type Response } from 'express';
import { createHash } from 'crypto';
import { isDashboardWidgetKind, type DashboardWidgetKind, type Repository } from '../db/repository.js';
import type { WorkerDef } from '../config.js';
import { collectWorkerStatuses } from './dashboard-workers.js';
import type { BackendStatusRegistry } from '../engine/backend-status-registry.js';
import { logger } from '../logger.js';
const SLUG_PATTERN = /^[a-z0-9-]+$/;
const MAX_SLUG_LEN = 32;
const MAX_TITLE_LEN = 64;
const MAX_CONTENT_BYTES = 64 * 1024;
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
export interface DashboardApiDeps {
repo: Repository;
getWorkers: () => WorkerDef[];
authActive?: boolean;
/**
* Optional BackendStatusRegistry. When supplied, the API exposes
* GET /node-status; when omitted (e.g. in unit tests that don't care
* about node status), the route 503s.
*/
backendStatusRegistry?: BackendStatusRegistry | null;
}
export function createDashboardApi(deps: DashboardApiDeps): Router {
const { repo, getWorkers } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
r.get('/widgets', async (req, res) => {
const u = getUser(req)!;
try {
const widgets = await repo.listDashboardWidgets(u.id);
res.json({ widgets });
} catch (err) {
logger.error(`[dashboard-api] GET /widgets failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list widgets' });
}
});
r.post('/widgets', async (req, res) => {
const u = getUser(req)!;
const { slug, title, content, kind } = (req.body ?? {}) as {
slug?: string;
title?: string;
content?: string;
kind?: string;
};
if (!slug || !SLUG_PATTERN.test(slug) || slug.length > MAX_SLUG_LEN) {
res.status(400).json({ error: 'invalid slug (lowercase a-z, 0-9, hyphen; max 32 chars)' });
return;
}
if (!title || title.length > MAX_TITLE_LEN) {
res.status(400).json({ error: `title required and <= ${MAX_TITLE_LEN} chars` });
return;
}
if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` });
return;
}
// kind is optional; defaults to 'markdown' for backward compat.
let resolvedKind: DashboardWidgetKind = 'markdown';
if (kind !== undefined) {
if (!isDashboardWidgetKind(kind)) {
res.status(400).json({ error: 'invalid kind (allowed: markdown, node-status)' });
return;
}
resolvedKind = kind;
}
try {
const widget = await repo.createDashboardWidget({
userId: u.id,
slug,
title,
content: content ?? '',
kind: resolvedKind,
});
res.status(201).json({ widget });
} catch (err: any) {
if (String(err?.message ?? err).includes('UNIQUE')) {
res.status(409).json({ error: 'slug already exists' });
return;
}
logger.error(`[dashboard-api] POST /widgets failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to create widget' });
}
});
r.patch('/widgets/:id', async (req, res) => {
const u = getUser(req)!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const existing = await repo.getDashboardWidget(id, u.id);
if (!existing) {
res.status(404).json({ error: 'not found' });
return;
}
const { title, content } = (req.body ?? {}) as { title?: string; content?: string };
// Non-markdown widget kinds (currently just 'node-status') render
// data live from a backing source instead of stored markdown — any
// content the caller sends would be dead state at best and a
// confusing surprise on the next render at worst. Title remains
// editable so the user can rename the panel.
if (existing.kind !== 'markdown' && content !== undefined) {
res.status(400).json({
error: `cannot edit content of ${existing.kind} widget (title-only updates allowed)`,
});
return;
}
if (title !== undefined && (title.length === 0 || title.length > MAX_TITLE_LEN)) {
res.status(400).json({ error: `title must be 1..${MAX_TITLE_LEN} chars` });
return;
}
if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` });
return;
}
try {
const widget = await repo.updateDashboardWidget(id, u.id, { title, content });
res.json({ widget });
} catch (err) {
logger.error(`[dashboard-api] PATCH /widgets/${id} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to update widget' });
}
});
r.delete('/widgets/:id', async (req, res) => {
const u = getUser(req)!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const existing = await repo.getDashboardWidget(id, u.id);
if (!existing) {
res.status(404).json({ error: 'not found' });
return;
}
try {
await repo.deleteDashboardWidget(id, u.id);
res.status(204).end();
} catch (err) {
logger.error(`[dashboard-api] DELETE /widgets/${id} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to delete widget' });
}
});
r.put('/widgets/reorder', async (req, res) => {
const u = getUser(req)!;
const { ids } = (req.body ?? {}) as { ids?: number[] };
if (!Array.isArray(ids) || !ids.every(n => Number.isFinite(n))) {
res.status(400).json({ error: 'ids must be array of numbers' });
return;
}
try {
await repo.reorderDashboardWidgets(u.id, ids);
res.json({ ok: true });
} catch (err) {
logger.error(`[dashboard-api] reorder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to reorder' });
}
});
r.get('/workers', async (_req, res) => {
try {
const workers = await collectWorkerStatuses(repo, getWorkers(), deps.backendStatusRegistry ?? null);
res.json({ workers });
} catch (err) {
logger.error(`[dashboard-api] GET /workers failed err=${err}`);
res.status(500).json({ error: 'Failed to list worker status' });
}
});
// GET /node-status
//
// Returns the latest BackendStatusRegistry snapshot. The registry is
// already polling in the background at a fixed cadence, so this
// handler is a cheap cache read.
//
// Caching headers (Phase C):
// - `Cache-Control: no-store` — multiple AAO instances might sit
// behind a shared proxy/CDN with body-level caching defaults; the
// snapshot is per-process state and must never be cached
// intermediately.
// - Weak ETag of the JSON payload + 304 short-circuit — when 5s polls
// land on an unchanged registry (idle pool, no probes flipped) the
// response avoids re-serialising the body and the browser refetch
// skips the JSON parse, halving the per-tick CPU under N-tab loads.
//
// The registry tick also notifies the registry that a subscriber is
// active so the polling cadence can fall back to the idle interval
// when no UI is open (see BackendStatusRegistry.noteSubscriberActivity).
r.get('/node-status', async (req, res) => {
const reg = deps.backendStatusRegistry ?? null;
if (!reg) {
// The registry is started by server.ts; when running under tests
// that don't bother to construct one, we'd rather signal "feature
// disabled" than crash.
res.status(503).json({ nodes: [], error: 'node-status registry not configured' });
return;
}
try {
const nodes = reg.getAll();
// Signal to the registry that a UI is actively watching so the
// polling cadence stays in the active (5s) band; without any
// recent GET the registry falls back to the idle (30s) cadence.
if (typeof reg.noteSubscriberActivity === 'function') {
try { reg.noteSubscriberActivity(); } catch { /* never fail the GET on metrics */ }
}
const body = JSON.stringify({ nodes });
// Weak ETag: payload identity is the only thing that matters for
// 304 short-circuiting; we don't care about byte-for-byte
// equivalence (no Content-Encoding negotiation here).
const etag = `W/"${createHash('sha1').update(body).digest('hex').slice(0, 16)}"`;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('ETag', etag);
const inm = req.headers['if-none-match'];
// RFC 9110 §13.1.2: If-None-Match may carry a comma-separated
// list of entity tags (browsers' BFCache restore and HTTP
// intermediaries can both produce multi-tag headers). Strict
// equality on the whole header would silently miss matches and
// re-send the body unnecessarily — splitting + per-tag compare
// is the spec-compliant behaviour.
if (typeof inm === 'string') {
const tags = inm.split(',').map(s => s.trim());
if (tags.includes(etag)) {
res.status(304).end();
return;
}
}
res.type('application/json').send(body);
} catch (err) {
logger.error(`[dashboard-api] GET /node-status failed err=${err}`);
res.status(500).json({ error: 'Failed to read node status' });
}
});
return r;
}
+173
View File
@@ -0,0 +1,173 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { collectWorkerStatuses } from './dashboard-workers.js';
import type { WorkerDef } from '../config.js';
describe('collectWorkerStatuses', () => {
let tmpDir: string;
let repo: Repository;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'dashboard-workers-test-'));
repo = new Repository(join(tmpDir, 'test.db'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('returns idle for all workers when no running jobs exist', async () => {
const workers: WorkerDef[] = [
{ id: 'w1', endpoint: 'x', roles: ['task'] },
{ id: 'w2', endpoint: 'y', roles: ['title'] },
];
const result = await collectWorkerStatuses(repo, workers);
expect(result).toEqual([
{ id: 'w1', name: 'w1', roles: ['task'], state: 'idle', proxy: false },
{ id: 'w2', name: 'w2', roles: ['title'], state: 'idle', proxy: false },
]);
});
it('returns running for workers with active jobs', async () => {
// Seed a running job for w1 via Repository's public API
const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed' });
await repo.updateJob(j.id, { status: 'running', workerId: 'w1' });
const workers: WorkerDef[] = [
{ id: 'w1', endpoint: 'x', roles: ['task'] },
{ id: 'w2', endpoint: 'y', roles: ['task'] },
];
const result = await collectWorkerStatuses(repo, workers);
expect(result.find(w => w.id === 'w1')!.state).toBe('running');
expect(result.find(w => w.id === 'w2')!.state).toBe('idle');
});
it('does not leak job id/title/owner in the response shape', async () => {
const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed' });
await repo.updateJob(j.id, { status: 'running', workerId: 'w1' });
const result = await collectWorkerStatuses(repo, [{ id: 'w1', endpoint: 'x' }]);
const keys = Object.keys(result[0]!).sort();
// proxy is a new keyed field; backends is optional and absent for
// direct workers — that's part of the public shape contract.
expect(keys).toEqual(['id', 'name', 'proxy', 'roles', 'state']);
});
it('fans out proxy workers into backends[] when a registry is supplied', async () => {
const fakeRegistry = {
getAll: () => [
// Self-row that the proxy probe surfaces with nodeId === workerId —
// must be filtered out so the proxy doesn't appear as its own child.
{
nodeId: 'gw', workerId: 'gw', source: 'proxy' as const,
online: true, busy: false, busySlots: 0, totalSlots: 0,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
{
nodeId: 'backend-a', workerId: 'gw', source: 'proxy' as const,
online: true, busy: true, busySlots: 2, totalSlots: 4,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
{
nodeId: 'backend-b', workerId: 'gw', source: 'proxy' as const,
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
// Belongs to a different worker — must not leak into gw.backends
{
nodeId: 'other-backend', workerId: 'other', source: 'proxy' as const,
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
],
};
const workers: WorkerDef[] = [
{ id: 'gw', endpoint: 'http://gw/v1', proxy: true },
{ id: 'direct', endpoint: 'http://direct/v1' },
];
const result = await collectWorkerStatuses(repo, workers, fakeRegistry);
const gw = result.find(w => w.id === 'gw')!;
expect(gw.proxy).toBe(true);
expect(gw.backends?.map(b => b.id).sort()).toEqual(['backend-a', 'backend-b']);
expect(gw.backends?.find(b => b.id === 'backend-a')).toMatchObject({
state: 'running', busySlots: 2, totalSlots: 4, online: true,
});
expect(gw.backends?.find(b => b.id === 'backend-b')).toMatchObject({
state: 'idle', busySlots: 0,
});
const direct = result.find(w => w.id === 'direct')!;
expect(direct.proxy).toBe(false);
// direct workers MUST omit `backends` (undefined, not empty) so the
// UI can distinguish "direct" from "proxy with zero backends".
expect(direct.backends).toBeUndefined();
});
it('omits backends[] for proxy workers when no registry is supplied (back-compat)', async () => {
const result = await collectWorkerStatuses(repo, [
{ id: 'gw', endpoint: 'http://gw/v1', proxy: true },
], null);
expect(result[0]!.proxy).toBe(true);
expect(result[0]!.backends).toBeUndefined();
});
it('surfaces busy/total slots on the row for direct workers via the registry self-row', async () => {
// Direct workers don't have a backends[] expansion, so the slot
// pressure has to live at the row level — otherwise the Worker
// widget can't show "(1/3)" for them the way proxy backends do.
const fakeRegistry = {
getAll: () => [
{
nodeId: 'gpu-1', workerId: 'gpu-1', source: 'direct' as const,
online: true, busy: true, busySlots: 1, totalSlots: 3,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
],
};
const workers: WorkerDef[] = [
{ id: 'gpu-1', endpoint: 'http://gpu-1:8080/v1' },
];
const result = await collectWorkerStatuses(repo, workers, fakeRegistry);
const row = result[0]!;
expect(row.proxy).toBe(false);
expect(row.backends).toBeUndefined();
expect(row.busySlots).toBe(1);
expect(row.totalSlots).toBe(3);
expect(row.online).toBe(true);
// Probe-derived state wins over the local jobs-table check when
// the probe sees in-flight requests this AAO didn't dispatch.
expect(row.state).toBe('running');
});
it('keeps direct worker slot fields undefined when registry has no matching row', async () => {
const fakeRegistry = {
// Registry knows other workers, but not the one we're asking about.
getAll: () => [{
nodeId: 'other', workerId: 'other', source: 'direct' as const,
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
}],
};
const workers: WorkerDef[] = [
{ id: 'gpu-1', endpoint: 'http://gpu-1:8080/v1' },
];
const result = await collectWorkerStatuses(repo, workers, fakeRegistry);
const row = result[0]!;
expect(row.busySlots).toBeUndefined();
expect(row.totalSlots).toBeUndefined();
expect(row.online).toBeUndefined();
});
it('marks online=false for direct workers when probe failed', async () => {
const fakeRegistry = {
getAll: () => [{
nodeId: 'gpu-1', workerId: 'gpu-1', source: 'direct' as const,
online: false, busy: false, busySlots: 0, totalSlots: 0,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
lastProbeError: 'connection refused',
}],
};
const result = await collectWorkerStatuses(repo, [{ id: 'gpu-1', endpoint: 'x' }], fakeRegistry);
expect(result[0]!.online).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
import type { Repository } from '../db/repository.js';
import type { WorkerDef } from '../config.js';
import type { BackendStatusRegistry, NodeStatus } from '../engine/backend-status-registry.js';
export interface WorkerStatusBackendRow {
/** Stable identifier from the upstream /health response (deployment id). */
id: string;
/** idle = registry says zero busy slots, running = at least one in-flight. */
state: 'idle' | 'running';
/** Number of in-flight slots reported by the registry. */
busySlots: number;
/** Total slot capacity. 0 when the registry hasn't probed yet. */
totalSlots: number;
/** false when the most recent /health probe failed. null when unprobed. */
online: boolean | null;
}
export interface WorkerStatusRow {
id: string;
name: string;
roles: string[];
state: 'idle' | 'running';
/** True when this row represents a `proxy: true` worker (LiteLLM / AAO Gateway). */
proxy: boolean;
/**
* Slot pressure from the BackendStatusRegistry, populated for direct
* workers when the registry has seen at least one probe cycle and a
* matching `nodeId === worker.id` row exists. Proxy workers leave
* these undefined — the meaningful breakdown lives in `backends[]`
* (per-backend, since the proxy itself doesn't have its own
* /slots-style busy figure).
*/
busySlots?: number;
totalSlots?: number;
/** Probe liveness. Same gating as busySlots/totalSlots. */
online?: boolean;
/**
* Per-backend rows for proxy workers — populated when a
* BackendStatusRegistry is wired and the registry has seen at least
* one probe cycle for this worker. Omitted (undefined, not empty)
* for direct workers so the UI can distinguish "no backends because
* this is a direct worker" from "proxy worker with zero backends
* reported".
*/
backends?: WorkerStatusBackendRow[];
}
/**
* Build the per-worker status list for the Side Info Panel.
*
* Proxy workers fan out into a `backends[]` list when a
* `BackendStatusRegistry` is supplied — the Worker widget renders the
* tree at the same granularity as the Node Status widget, so an
* operator can see *which* backend behind a LiteLLM / AAO Gateway
* front is currently in use rather than just "the proxy is busy".
*
* Privacy: returns idle/running booleans + slot counts only. Never job
* ids, titles, or owners, since the panel is shown to all users in a
* multi-tenant deployment.
*/
export async function collectWorkerStatuses(
repo: Repository,
workers: WorkerDef[],
registry: Pick<BackendStatusRegistry, 'getAll'> | null = null,
): Promise<WorkerStatusRow[]> {
// Build a workerId → NodeStatus[] map once per call so we don't
// O(N*M) the registry snapshot per worker. registry.getAll() copies
// its internal cache, so calling it once is cheap.
const byWorker = new Map<string, NodeStatus[]>();
if (registry) {
for (const ns of registry.getAll()) {
const list = byWorker.get(ns.workerId);
if (list) list.push(ns);
else byWorker.set(ns.workerId, [ns]);
}
}
return workers.map((w) => {
const isProxy = w.proxy === true;
const row: WorkerStatusRow = {
id: w.id,
name: w.id,
roles: w.roles ?? [],
state: repo.isWorkerBusy(w.id) ? 'running' : 'idle',
proxy: isProxy,
};
if (isProxy && registry) {
// Filter to backend-source rows only — the registry also stores a
// self-row for the proxy worker itself (source='proxy', nodeId =
// workerId) which would otherwise show up duplicated as a child
// of itself.
const rows = (byWorker.get(w.id) ?? []).filter((ns) => ns.nodeId !== w.id);
row.backends = rows.map((ns) => ({
id: ns.nodeId,
state: ns.busySlots > 0 ? 'running' : 'idle',
busySlots: ns.busySlots,
totalSlots: ns.totalSlots,
online: ns.online,
}));
} else if (!isProxy && registry) {
// Direct workers: the registry stores one row keyed by the
// worker id (source='direct', populated from llama-server
// /slots). Surface its slot pressure at the row level so the
// UI can render `(busy/total)` next to the state badge — same
// signal proxy backends get, just one level higher in the
// tree because direct workers have no expansion.
const selfRow = (byWorker.get(w.id) ?? []).find((ns) => ns.nodeId === w.id);
if (selfRow) {
row.busySlots = selfRow.busySlots;
row.totalSlots = selfRow.totalSlots;
row.online = selfRow.online;
// Re-derive state from the probe too — it sees in-flight
// requests that didn't go through the local jobs table
// (e.g. anything dispatched outside AAO). `repo.isWorkerBusy`
// alone misses those.
if (selfRow.busySlots > 0) row.state = 'running';
}
}
return row;
});
}
+515
View File
@@ -0,0 +1,515 @@
/**
* Phase 3c — same-process gateway mount unit tests.
*
* Drives the mount handle directly with a fake ConfigManager + fake
* BackendStatusRegistry so we don't need a Repository or live HTTP
* upstream. Verifies:
*
* - Gate returns 404 for /v1/* while disabled (and the worker bridge's
* own routes still work in the same Express app)
* - Flipping enabled: true brings the gateway up; /v1/models authed
* against the boot virtual_keys returns a backend list
* - Flipping back to false drains and re-404s
* - A backend list change while running triggers a bounce (stop + start)
* - misconfigured config (no backends) parks in `misconfigured` state
* with errors exposed
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express, { type Express } from 'express';
import request from 'supertest';
import { EventEmitter } from 'events';
import {
isGatewayPath,
classifyGatewayPath,
mountGateway,
type GatewayMountHandle,
} from './gateway-mount.js';
import type { ConfigManager } from '../config-manager.js';
import type { AppConfig } from '../config.js';
import type {
BackendStatusRegistry,
NodeStatus,
NodeStatusListener,
Unsubscribe,
} from '../engine/backend-status-registry.js';
function fakeRegistry(): BackendStatusRegistry & { emit(s: NodeStatus[]): void } {
const listeners = new Set<NodeStatusListener>();
const reg: BackendStatusRegistry = {
start: () => {},
stop: async () => {},
getAll: () => [],
subscribe: (l: NodeStatusListener): Unsubscribe => {
listeners.add(l);
return () => listeners.delete(l);
},
noteSubscriberActivity: () => {},
};
return Object.assign(reg, {
emit: (snapshot: NodeStatus[]) => {
for (const l of listeners) l(snapshot);
},
});
}
function fakeConfigManager(initial: Partial<AppConfig>): ConfigManager & {
setConfig(next: Partial<AppConfig>): void;
} {
let cfg = initial as AppConfig;
const emitter = new EventEmitter();
const cm = {
getConfig: () => cfg,
onConfigChanged: (cb: (c: AppConfig) => void) => {
emitter.on('config-changed', cb);
},
setConfig: (next: Partial<AppConfig>) => {
cfg = next as AppConfig;
emitter.emit('config-changed', cfg);
},
} as unknown as ConfigManager & { setConfig(next: Partial<AppConfig>): void };
return cm;
}
function gatewayConfigBlock(opts: {
enabled: boolean;
backends?: Array<{ id: string; endpoint: string; model: string; maxSlots: number }>;
virtualKeys?: Array<{ key: string; team: string }>;
}): Partial<AppConfig> {
// readGatewayConfig reads app['gateway'] directly and feeds it to
// normalizeGatewayConfig, which expects camelCase (snake → camel
// happens earlier inside loadConfig via transformKeys). Tests bypass
// loadConfig so we pre-camelCase the block here.
return {
gateway: {
enabled: opts.enabled,
listenPort: 4000,
backends: opts.backends ?? [
{ id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
],
virtualKeys: opts.virtualKeys ?? [{ key: 'sk-test', team: 'alpha' }],
},
} as unknown as Partial<AppConfig>;
}
describe('isGatewayPath', () => {
it('matches /v1/* paths', () => {
expect(isGatewayPath('/v1/chat/completions')).toBe(true);
expect(isGatewayPath('/v1/models')).toBe(true);
expect(isGatewayPath('/v1/')).toBe(true);
});
it('A1: matches bare /v1 (no slash) as well as /v1/', () => {
// Regression: previously isGatewayPath('/v1') was false because
// the prefix match used '/v1/'. Bare /v1 silently bypassed the
// gate. classifyGatewayPath now treats both consistently.
expect(isGatewayPath('/v1')).toBe(true);
expect(isGatewayPath('/v1/')).toBe(true);
});
it('matches /health/liveness but NOT bare /health (worker owns it)', () => {
expect(isGatewayPath('/health/liveness')).toBe(true);
// `isGatewayPath` keeps the back-compat semantics — only
// gateway-only paths return true. Bare /health is
// 'gateway-when-enabled' which is NOT gateway-only.
expect(isGatewayPath('/health')).toBe(false);
});
it('does not match worker bridge routes', () => {
expect(isGatewayPath('/api/local/tasks')).toBe(false);
expect(isGatewayPath('/api/admin/gateway/keys')).toBe(false);
expect(isGatewayPath('/auth/google')).toBe(false);
expect(isGatewayPath('/ui/index.html')).toBe(false);
expect(isGatewayPath('/metrics')).toBe(false);
});
});
describe('classifyGatewayPath (CRITICAL-3 tri-state)', () => {
it('classifies /v1/* as gateway-only', () => {
expect(classifyGatewayPath('/v1/chat/completions')).toBe('gateway-only');
expect(classifyGatewayPath('/v1/models')).toBe('gateway-only');
expect(classifyGatewayPath('/v1')).toBe('gateway-only');
expect(classifyGatewayPath('/v1/')).toBe('gateway-only');
});
it('classifies /health/liveness as gateway-only', () => {
expect(classifyGatewayPath('/health/liveness')).toBe('gateway-only');
});
it('classifies /health as gateway-when-enabled', () => {
expect(classifyGatewayPath('/health')).toBe('gateway-when-enabled');
});
it('returns false for everything else', () => {
expect(classifyGatewayPath('/api/local/tasks')).toBe(false);
expect(classifyGatewayPath('/healthz')).toBe(false);
expect(classifyGatewayPath('/metrics')).toBe(false);
expect(classifyGatewayPath('/')).toBe(false);
});
});
describe('mountGateway lifecycle', () => {
let app: Express;
let configManager: ReturnType<typeof fakeConfigManager>;
let registry: ReturnType<typeof fakeRegistry>;
let mount: GatewayMountHandle;
beforeEach(() => {
app = express();
configManager = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
registry = fakeRegistry();
// A representative worker route that should NEVER be 404'd by the
// gateway gate.
app.get('/api/version', (_req, res) => res.json({ version: 'test' }));
mount = mountGateway({
app,
configManager,
repo: null,
// CRITICAL-2: gateway owns its own registry built per config.
// Tests inject via buildRegistry so the gateway sees the same
// fake the test drives (snapshot emits / listener count).
buildRegistry: () => registry,
promRegistry: null,
});
});
it('starts in disabled state', () => {
expect(mount.getState()).toBe('disabled');
expect(mount.getErrors()).toEqual([]);
});
it('returns 404 for /v1/* while disabled', async () => {
await mount.applyConfig({
enabled: false,
listenPort: 4000,
requestTimeoutSec: 600,
upstreamTimeoutSec: 30,
shutdownGracefulSec: 30,
backends: [],
virtualKeys: [],
});
const res = await request(app).get('/v1/models');
expect(res.status).toBe(404);
});
it('preserves worker bridge routes regardless of gateway state', async () => {
const r1 = await request(app).get('/api/version');
expect(r1.status).toBe(200);
expect(r1.body.version).toBe('test');
// Enable gateway: worker route still works.
configManager.setConfig(gatewayConfigBlock({ enabled: true }));
// Wait for async applyConfig to drain.
await new Promise(r => setImmediate(r));
await new Promise(r => setImmediate(r));
const r2 = await request(app).get('/api/version');
expect(r2.status).toBe(200);
});
it('flipping enabled true brings the gateway up; /v1/models authed succeeds', async () => {
configManager.setConfig(gatewayConfigBlock({ enabled: true }));
// applyConfig is fire-and-forget from onConfigChanged; wait for the
// mutex to drain so the running state stabilises before we hit it.
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
const res = await request(app)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.status).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.data.length).toBe(1);
expect(res.body.data[0].id).toBe('gpu-a');
});
it('flipping enabled false drains and re-404s', async () => {
configManager.setConfig(gatewayConfigBlock({ enabled: true }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
configManager.setConfig(gatewayConfigBlock({ enabled: false }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('disabled');
const res = await request(app)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.status).toBe(404);
});
it('backend list change bounces (stop + start) without leaving state stale', async () => {
configManager.setConfig(gatewayConfigBlock({ enabled: true }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
configManager.setConfig(
gatewayConfigBlock({
enabled: true,
backends: [
{ id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
{ id: 'gpu-b', endpoint: 'http://localhost:9/v1', model: 'qwen3:14b', maxSlots: 4 },
],
}),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
const res = await request(app)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.body.data.map((m: { id: string }) => m.id).sort()).toEqual(['gpu-a', 'gpu-b']);
});
it('misconfigured config (no backends) parks in misconfigured state with errors', async () => {
configManager.setConfig(
gatewayConfigBlock({
enabled: true,
backends: [], // empty → validation fails
}),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('misconfigured');
expect(mount.getErrors().length).toBeGreaterThan(0);
// Gate stays closed.
const res = await request(app).get('/v1/models');
expect(res.status).toBe(404);
});
it('mount.stop() is idempotent and safe when never started', async () => {
await expect(mount.stop()).resolves.toBeUndefined();
await expect(mount.stop()).resolves.toBeUndefined();
expect(mount.getState()).toBe('disabled');
});
it('CRITICAL-2: gateway owns its registry (built per config.backends, not worker list)', async () => {
// Adversarial-review regression: previously the gateway router
// borrowed the worker bridge's BackendStatusRegistry, which probes
// provider.workers[].id (e.g. "w-a"). When gateway.backends[].id is
// "gw-a" the router would get null status for every backend (id
// mismatch), making /health empty + least-busy routing blind.
//
// The fix: gateway builds its own registry via buildRegistry(config)
// on every start. This test sets up a fresh mount with a buildRegistry
// spy and verifies (a) it gets called with the new config containing
// gw-* ids, (b) the same fake registry is then used to serve
// /v1/models (proving the router queries the gateway's registry).
const localApp = express();
const localCm = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
const gwRegistry = fakeRegistry();
const buildSpy = vi.fn(() => gwRegistry);
const localMount = mountGateway({
app: localApp,
configManager: localCm,
repo: null,
buildRegistry: buildSpy,
promRegistry: null,
});
localCm.setConfig(
gatewayConfigBlock({
enabled: true,
backends: [
{ id: 'gw-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
],
}),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(localMount.getState()).toBe('running');
// buildRegistry was called once with the gateway config.
expect(buildSpy).toHaveBeenCalledTimes(1);
const cfg = buildSpy.mock.calls[0]![0];
expect(cfg.backends[0]!.id).toBe('gw-a');
// /v1/models comes from config (sanity check the mount wired the
// gateway sub-app), and the gateway's router has the gw-a id.
const res = await request(localApp)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.status).toBe(200);
expect(res.body.data.map((m: { id: string }) => m.id)).toContain('gw-a');
await localMount.stop();
});
it('CRITICAL-3: /health LiteLLM-compat — gateway answers when running, bridge fallback when off', async () => {
// Adversarial-review regression: server.ts used to register
// app.get('/health', ...) BEFORE mountGateway, so the bridge
// {status:'ok'} handler always won. Phase 1 promised LiteLLM-shape
// `/health` JSON same-process — that was silently broken.
//
// The fix has two parts:
// 1. classifyGatewayPath('/health') === 'gateway-when-enabled'
// 2. server.ts registers the bridge `/health` handler AFTER
// mountGateway, so the gate's sub-app dispatch wins when the
// gateway is running.
//
// This test simulates that registration order locally.
const localApp = express();
const localCm = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
const localRegistry = fakeRegistry();
const localMount = mountGateway({
app: localApp,
configManager: localCm,
repo: null,
buildRegistry: () => localRegistry,
promRegistry: null,
});
// The bridge `/health` fallback (registered AFTER mountGateway).
localApp.get('/health', (_req, res) => res.json({ status: 'ok' }));
// Off → bridge fallback answers.
let res = await request(localApp).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
// On → gateway answers with LiteLLM shape.
localCm.setConfig(gatewayConfigBlock({ enabled: true }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(localMount.getState()).toBe('running');
res = await request(localApp).get('/health');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('healthy_endpoints');
expect(res.body).toHaveProperty('unhealthy_endpoints');
expect(res.body).toHaveProperty('healthy_count');
expect(res.body).toHaveProperty('unhealthy_count');
expect(res.body).not.toHaveProperty('status'); // not the bridge shape
// /health/liveness is gateway-only — 200 from gateway, 404 when off.
res = await request(localApp).get('/health/liveness');
expect(res.status).toBe(200);
// Flip off — bridge fallback again.
localCm.setConfig(gatewayConfigBlock({ enabled: false }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
res = await request(localApp).get('/health');
expect(res.body).toEqual({ status: 'ok' });
// /health/liveness now 404 (gateway-only + gateway off).
res = await request(localApp).get('/health/liveness');
expect(res.status).toBe(404);
await localMount.stop();
});
it('CRITICAL-3: /v1/models stays 404 when gateway disabled (gateway-only path)', async () => {
// Sanity check the gateway-only branch is unchanged: when off, /v1/*
// returns 404 with the gateway-not-found shape — not bridge fall-through.
const res = await request(app).get('/v1/models');
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found:/);
});
it('configsEquivalent is key-order insensitive (no spurious bounce on YAML round-trip)', async () => {
// Bring the gateway up with a baseline config.
configManager.setConfig(
gatewayConfigBlock({
enabled: true,
backends: [
{ id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
],
}),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
// Spy on registry.subscribe — a bounce re-subscribes, so the
// subscribe-count growing tells us a bounce happened. We use the
// fake-registry's listener tracking indirectly via the running
// log line; instead, observe by checking that /v1/models stays
// available across "the same config in a different key order".
//
// Build a config with the SAME semantic shape but each backend
// object's keys reordered.
const reorderedBackend = {
maxSlots: 4,
model: 'qwen3:8b',
endpoint: 'http://localhost:9/v1',
id: 'gpu-a',
};
configManager.setConfig({
gateway: {
enabled: true,
listenPort: 4000,
backends: [reorderedBackend],
virtualKeys: [{ team: 'alpha', key: 'sk-test' }], // also reordered
},
} as unknown as Parameters<typeof configManager.setConfig>[0]);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
// Should still be running (no false bounce). Hit /v1/models to
// confirm the gateway sub-app is still up.
expect(mount.getState()).toBe('running');
const res = await request(app)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.status).toBe(200);
});
it('F1: rapid double-toggle does NOT drop the 2nd config (pending replay)', async () => {
// Fire 4 transitions back-to-back. The mutex serialises them, but
// F1 says we must not drop intent that arrived during a
// starting/stopping window.
const cfgEnabled = gatewayConfigBlock({ enabled: true });
const cfgDisabled = gatewayConfigBlock({ enabled: false });
configManager.setConfig(cfgEnabled);
configManager.setConfig(cfgDisabled);
configManager.setConfig(cfgEnabled);
configManager.setConfig(cfgDisabled);
// Drain the mutex chain.
for (let i = 0; i < 20; i++) await new Promise(r => setImmediate(r));
// The final intent was 'disabled' — the handle must converge there
// even though multiple transitions arrived during in-flight ones.
expect(mount.getState()).toBe('disabled');
});
it('F2: stop() on a misconfigured handle clears the state to disabled', async () => {
// Park the mount in 'misconfigured' (enabled:true + empty backends).
configManager.setConfig(
gatewayConfigBlock({ enabled: true, backends: [] }),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('misconfigured');
expect(mount.getErrors().length).toBeGreaterThan(0);
// Call stop(). Without the F2 fix, state stays 'misconfigured'
// because stopGateway() returns early when shared is null.
await mount.stop();
expect(mount.getState()).toBe('disabled');
expect(mount.getErrors()).toEqual([]);
});
it('F2: applyConfig({enabled:false}) on misconfigured clears the state', async () => {
// Same scenario via the config-change path (the more common one in
// production — admin opens the form, sees the misconfigured badge,
// unchecks Enable, hits Save).
configManager.setConfig(
gatewayConfigBlock({ enabled: true, backends: [] }),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('misconfigured');
configManager.setConfig(gatewayConfigBlock({ enabled: false }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('disabled');
expect(mount.getErrors()).toEqual([]);
});
it('rapid enable -> disable transitions serialize via mutex (no interleaving)', async () => {
// Fire enable + disable back-to-back without waiting.
const p1 = mount.applyConfig({
enabled: true,
listenPort: 4000,
requestTimeoutSec: 600,
upstreamTimeoutSec: 30,
shutdownGracefulSec: 30,
backends: [
{ id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
],
virtualKeys: [{ key: 'sk-test', team: 'alpha' }],
});
const p2 = mount.applyConfig({
enabled: false,
listenPort: 4000,
requestTimeoutSec: 600,
upstreamTimeoutSec: 30,
shutdownGracefulSec: 30,
backends: [],
virtualKeys: [],
});
await Promise.all([p1, p2]);
expect(mount.getState()).toBe('disabled');
});
});
+488
View File
@@ -0,0 +1,488 @@
/**
* Phase 3c — same-process gateway mount.
*
* Mounts the AAO Gateway's Express sub-app on the worker bridge so a
* single AAO process can serve both the worker UI (`/api/local/*` etc.)
* and the gateway endpoints (`/v1/*`, `/health`) on the same port.
*
* Path scoping
* ────────────
* The gateway owns these paths:
* - POST /v1/chat/completions
* - GET /v1/models
* - GET /health, /health/liveness
* No conflict with the worker bridge's `/api/*` / `/ui/*` / `/auth/*`
* roots. `/metrics` deliberately stays with the worker — gateway counters
* are registered into the worker's prom-client registry so one scrape
* endpoint serves both label spaces (no port conflict).
*
* Dynamic enable / disable
* ────────────────────────
* We mount a small "gate" middleware ahead of the gateway sub-app that
* returns 404 for any gateway path while `gateway.enabled !== true`,
* matching the behaviour an operator sees when the gateway block is
* absent. Flipping the flag at runtime (via ConfigManager
* `config-changed`) requires no Express remount — the routes are
* always registered, the gate just stops short-circuiting them.
*
* Lifecycle
* ─────────
* false -> true: createSharedGatewayDependencies + start + flip gate open
* true -> false: flip gate closed + shared.stop() (drains in-flight
* SSE via streamRegistry.signalShutdown)
*
* Hot edits to backends / virtual_keys are picked up by the gateway
* automatically because createSharedGatewayDependencies reads them
* through closures over the latest GatewayConfig snapshot at start
* time. A backend list edit currently requires an enable -> disable ->
* enable cycle to take effect; full hot-reload of backends without a
* disable is Phase 4 scope.
*/
import { type Express, type Request, type Response, type NextFunction } from 'express';
import { logger } from '../logger.js';
import type { ConfigManager } from '../config-manager.js';
import type { Repository } from '../db/repository.js';
import {
createBackendStatusRegistry,
type BackendStatusRegistry,
} from '../engine/backend-status-registry.js';
import { buildDirectProbe } from '../engine/backend-probes.js';
import type { Registry as PromRegistry } from 'prom-client';
import {
readGatewayConfig,
validateGatewayConfig,
type GatewayConfig,
} from '../gateway/config.js';
import { createGatewayApp } from '../gateway/server.js';
import {
createSharedGatewayDependencies,
type SharedGatewayDependencies,
} from '../gateway/shared-dependencies.js';
import { buildWorkerDefsFromBackends } from '../gateway/bootstrap.js';
/**
* Path roots the gateway sub-app exclusively owns in same-process mode.
* These are gated by the 404 middleware while the gateway is not
* running — clients see the same JSON the not-found handler in
* gateway/server.ts emits.
*
* `/health/liveness` is also gateway-only (more specific path stays
* with the gateway's k8s liveness probe).
*/
const GATEWAY_ONLY_PREFIXES = ['/v1/'];
/**
* Classification of a request path's relationship to the gateway:
*
* - `'gateway-only'`: the gateway exclusively owns this path. When
* the gateway isn't running, the gate returns 404 to mimic the
* "block absent" behaviour an operator sees in disabled mode.
* Examples: `/v1/*`, `/health/liveness`.
*
* - `'gateway-when-enabled'`: gateway can answer this path when
* running, but the bridge has its own handler that must respond
* when the gateway is off. The gate FORWARDS to gateway while
* running, FALLS THROUGH otherwise.
* Examples: `/health` (LiteLLM-compat JSON when on, bridge ok JSON
* when off — CRITICAL-3 fix).
*
* - `false`: path is not gateway-related; gate never touches it.
*/
export type GatewayPathKind = 'gateway-only' | 'gateway-when-enabled' | false;
/**
* Classify a path against the gateway URL surface. Pure function —
* exported so the bridge / tests can reuse the same logic that the
* gate middleware uses.
*/
export function classifyGatewayPath(path: string): GatewayPathKind {
// A1: handle both /v1 (no trailing slash) and /v1/* consistently.
// Previously `/v1/` (exact) was missing from the prefix match and
// bare `/v1` silently bypassed the gate.
if (path === '/v1' || path === '/v1/') return 'gateway-only';
for (const prefix of GATEWAY_ONLY_PREFIXES) {
if (path.startsWith(prefix)) return 'gateway-only';
}
if (path === '/health/liveness') return 'gateway-only';
if (path === '/health') return 'gateway-when-enabled';
return false;
}
/**
* Back-compat boolean classifier kept for callers that don't care
* about the gateway-when-enabled tri-state. Returns true ONLY for
* gateway-only paths (NOT gateway-when-enabled) so existing
* test asserts like `isGatewayPath('/health') === false` still hold.
*/
export function isGatewayPath(path: string): boolean {
return classifyGatewayPath(path) === 'gateway-only';
}
export type GatewayMountState =
| 'disabled' // config says enabled !== true
| 'starting' // shared.start() in progress
| 'running' // gate open, deps live
| 'stopping' // shared.stop() in progress (drain)
| 'misconfigured'; // enabled === true but validateGatewayConfig found errors
export interface GatewayMountHandle {
/** Current state. Reflects the in-memory flag, not config on disk. */
getState(): GatewayMountState;
/** Validation errors from the most-recent attempt (or empty). */
getErrors(): string[];
/**
* Apply a new config snapshot. The bridge calls this from a
* `ConfigManager#onConfigChanged` listener; tests can drive it
* directly. Returns true when the call resulted in a state change.
*/
applyConfig(next: GatewayConfig): Promise<boolean>;
/**
* Forcibly stop the gateway (used by graceful shutdown). Safe to call
* when already stopped.
*/
stop(): Promise<void>;
}
export interface MountGatewayOptions {
app: Express;
configManager: ConfigManager;
repo: Repository | null;
/**
* DEPRECATED — kept for source compatibility but no longer used.
* The gateway now owns its own BackendStatusRegistry over
* `gateway.backends[]` (CRITICAL-2 fix). Passing the worker bridge's
* registry here is silently ignored — the worker registry probes
* `provider.workers[]`, whose ids do NOT match gateway.backends[].id,
* which used to make `/health` empty + least-busy routing blind.
* Remove this field in Phase 4 once the bridge call site is updated.
*/
backendStatusRegistry?: BackendStatusRegistry;
/** Shared with worker /metrics. Null disables gateway metrics. */
promRegistry: PromRegistry | null;
/** Prefix for gateway counters inside the shared registry. */
metricsPrefix?: string;
/** Test hook — defaults to globalThis.fetch. */
fetchImpl?: typeof fetch;
/**
* Test hook for the per-gateway BackendStatusRegistry. Defaults to
* the real `createBackendStatusRegistry` over `gateway.backends`. Tests
* can substitute a fake to drive snapshots deterministically without
* spinning up real probes.
*/
buildRegistry?: (config: GatewayConfig) => BackendStatusRegistry;
}
/**
* Mount the gateway sub-app on `app` and return a handle the caller
* uses to wire the ConfigManager event subscription. The gateway is
* NOT started here — call `applyConfig()` with the current config
* snapshot from the bridge to bring it up if `enabled: true`.
*/
export function mountGateway(opts: MountGatewayOptions): GatewayMountHandle {
const { app, configManager, repo, promRegistry } = opts;
const metricsPrefix = opts.metricsPrefix ?? 'aao_gateway';
const buildRegistry =
opts.buildRegistry ??
((config: GatewayConfig): BackendStatusRegistry =>
createBackendStatusRegistry({
// Per CRITICAL-2: the gateway needs a registry keyed by
// gateway.backends[].id, not the worker bridge's
// provider.workers[].id. Same-host double-probe with the
// worker registry is acceptable (probes are cheap) and the
// two registries' lifetimes are now independent.
getWorkers: () => buildWorkerDefsFromBackends(config.backends),
probeDirect: buildDirectProbe(),
// Gateway backends are never proxy=true (LiteLLM is what we're
// replacing) — supply a stub so the registry contract holds.
probeProxy: async () => [],
}));
let state: GatewayMountState = 'disabled';
let lastErrors: string[] = [];
let shared: SharedGatewayDependencies | null = null;
let gatewaySubApp: Express | null = null;
let activeConfig: GatewayConfig | null = null;
// Per-gateway registry over gateway.backends — owned + stopped here,
// independent of any worker registry the bridge separately keeps.
let ownedRegistry: BackendStatusRegistry | null = null;
// F1: pending config replay. applyConfig() during 'starting' /
// 'stopping' (an in-flight transition still draining through the
// mutex) stores the latest intent here so the next mutex turn can
// replay it instead of dropping it.
let pendingConfig: GatewayConfig | null = null;
// Serialize start/stop transitions so a rapid toggle can't interleave.
let mutex: Promise<void> = Promise.resolve();
// --- 404 gate -------------------------------------------------------
// Mounted exactly once. Returns 404 for gateway-only paths while
// state != 'running'. For 'gateway-when-enabled' paths (e.g.
// `/health`) we FALL THROUGH so the bridge's own handler can answer
// when the gateway is off. Non-gateway paths fall through
// unconditionally. We must register this BEFORE the sub-app
// dispatch middleware so the gate can short-circuit before sub-app
// routing.
app.use((req: Request, res: Response, next: NextFunction) => {
const kind = classifyGatewayPath(req.path);
if (!kind) return next();
if (state === 'running' && gatewaySubApp) return next();
if (kind === 'gateway-when-enabled') return next(); // bridge owns when off
// gateway-only and gateway not running → 404 (mimic gateway not-found
// body shape so clients see the same JSON whether the gateway is
// off or the path is genuinely missing).
res.status(404).json({ error: `not found: ${req.method} ${req.path}` });
});
// Sub-app dispatch middleware. Permanently registered; when
// `gatewaySubApp` is null (gateway never enabled this process) it
// forwards. When set + running, it routes through the gateway's
// Express app for any gateway-classified path — including
// 'gateway-when-enabled' so /health returns the LiteLLM-shape JSON
// (CRITICAL-3 fix).
app.use((req, res, next) => {
if (!gatewaySubApp) return next();
if (state !== 'running') return next();
if (classifyGatewayPath(req.path) === false) return next();
gatewaySubApp(req, res, next);
});
const startGateway = async (config: GatewayConfig): Promise<void> => {
const errors = validateGatewayConfig(config);
if (errors.length > 0) {
lastErrors = errors;
state = 'misconfigured';
for (const e of errors) {
logger.warn(`[bridge-gateway] config error blocking start: ${e}`);
}
return;
}
state = 'starting';
lastErrors = [];
// Build + start the per-gateway BackendStatusRegistry first so the
// gateway router sees backend ids from gateway.backends (not the
// worker bridge's provider.workers list, which would 404-on-status
// for the gateway's ids — CRITICAL-2).
ownedRegistry = buildRegistry(config);
ownedRegistry.start();
shared = createSharedGatewayDependencies({
config,
registry: ownedRegistry,
repo,
promRegistry,
prefix: metricsPrefix,
});
shared.start();
const { app: subApp } = createGatewayApp({
config,
registry: ownedRegistry,
fetchImpl: opts.fetchImpl,
dbLookup: shared.dbLookup,
touchLastUsed: shared.touchLastUsed,
postAuthMiddleware: shared.postAuthMiddleware,
usageRecorder: shared.usageRecorder ?? undefined,
streamRegistry: shared.streamRegistry,
inflight: shared.inflight,
metrics: shared.metrics ?? undefined,
// Phase 3c: same-process mode does NOT mount /metrics from the
// gateway side — gateway counters land in the shared worker
// registry that already serves /metrics. Pass undefined here so
// createGatewayApp skips the metrics endpoint mount.
metricsRegistry: undefined,
});
gatewaySubApp = subApp;
activeConfig = config;
state = 'running';
logger.info(
`[bridge-gateway] gateway enabled (same-process) backends=${config.backends.length} virtual_keys=${config.virtualKeys.length}`,
);
};
const stopGateway = async (): Promise<void> => {
if (!shared) return;
state = 'stopping';
try {
await shared.stop();
} catch (e) {
logger.warn(`[bridge-gateway] shared.stop threw: ${e instanceof Error ? e.message : String(e)}`);
}
// Tear down the per-gateway registry's probe loop — independent of
// any worker registry the bridge keeps running.
if (ownedRegistry) {
try {
await ownedRegistry.stop();
} catch (e) {
logger.warn(
`[bridge-gateway] ownedRegistry.stop threw: ${e instanceof Error ? e.message : String(e)}`,
);
}
ownedRegistry = null;
}
shared = null;
gatewaySubApp = null;
activeConfig = null;
state = 'disabled';
logger.info('[bridge-gateway] gateway disabled');
};
const transition = async (next: GatewayConfig): Promise<boolean> => {
const prevState = state;
if (next.enabled !== true) {
if (state === 'running' || state === 'misconfigured') {
await stopGateway();
// F2: after stopGateway() for the misconfigured branch (where
// shared was never assigned) the early return inside
// stopGateway leaves `state` at 'misconfigured'. Force the
// disable + clear errors here so applyConfig({enabled:false})
// unconditionally leaves the handle in a clean state — the
// status endpoint should never see a misconfigured handle
// after the operator turned the gateway off.
state = 'disabled';
lastErrors = [];
return true;
}
// F1: starting / stopping mid-transition — record the latest
// intent so the in-flight transition can replay it via the
// mutex chain instead of dropping it on the floor.
// NOTE: defensive-only. Under the current mutex chain
// (`applyConfig = mutex.then(...)`), transition() runs only after
// the prior start/stopGateway settles, so `state` is never
// observed as 'starting' / 'stopping' here. Kept to survive
// future refactors that allow concurrent transition() calls.
if (state === 'starting' || state === 'stopping') {
pendingConfig = next;
return false;
}
// already disabled — clear any stale validation errors
if (lastErrors.length > 0) {
lastErrors = [];
return true;
}
return false;
}
// next.enabled === true
if (state === 'disabled' || state === 'misconfigured') {
await startGateway(next);
return prevState !== state;
}
// F1: enable while another transition is in flight → queue the
// config and let the mutex finalizer replay it.
// NOTE: defensive-only — see the matching branch above. The mutex
// chain already serialises transition() calls, so this is dead
// under the current model but cheap insurance for future refactors.
if (state === 'starting' || state === 'stopping') {
pendingConfig = next;
return false;
}
// Already running. Backends / keys list changed? Bounce so the
// shared deps pick up the new snapshot. Full in-place hot reload
// is Phase 4 scope.
if (state === 'running' && activeConfig && !configsEquivalent(activeConfig, next)) {
logger.info('[bridge-gateway] gateway config changed; bouncing same-process mount');
await stopGateway();
await startGateway(next);
return true;
}
return false;
};
/**
* Mutex-chained applyConfig that drains the F1 pending queue. After
* the current transition settles, replay any pendingConfig stored
* during 'starting' / 'stopping' so configs that arrived
* mid-transition aren't dropped. Returning the OUTER promise (not
* the inner replay) preserves the public contract: callers see their
* own transition's settlement, the replay's result lands on
* whatever listener cares.
*/
const applyConfigInternal = async (next: GatewayConfig): Promise<boolean> => {
const changed = await transition(next);
if (pendingConfig) {
const replay = pendingConfig;
pendingConfig = null;
const drain = mutex.then(() => applyConfigInternal(replay));
mutex = drain.then(() => undefined, () => undefined);
drain.catch((e) => {
logger.warn(
`[bridge-gateway] pending config replay threw: ${e instanceof Error ? e.message : String(e)}`,
);
});
}
return changed;
};
const handle: GatewayMountHandle = {
getState: () => state,
getErrors: () => lastErrors.slice(),
applyConfig: (next) => {
// Serialize through the mutex to avoid interleaved start/stop.
const run = mutex.then(() => applyConfigInternal(next));
mutex = run.then(() => undefined, () => undefined);
return run;
},
stop: async () => {
const run = mutex.then(async () => {
await stopGateway();
// F2: stop() must always land in a clean 'disabled' state with
// no stale validation errors. stopGateway() early-returns when
// shared is null (e.g. handle parked in 'misconfigured' state
// before any successful start), leaving state untouched. Force
// the cleanup here.
state = 'disabled';
lastErrors = [];
});
mutex = run.then(() => undefined, () => undefined);
await run;
},
};
// Subscribe to config changes so an admin enabling the gateway from
// Settings UI takes effect without a server restart.
configManager.onConfigChanged((cfg) => {
const next = readGatewayConfig(cfg);
handle.applyConfig(next).catch((e) => {
logger.warn(`[bridge-gateway] applyConfig from config-changed threw: ${e instanceof Error ? e.message : String(e)}`);
});
});
return handle;
}
/**
* Stable JSON.stringify that sorts object keys recursively so two
* objects whose YAML round-trip produced different key orders still
* compare equal. Plain JSON.stringify is key-order-sensitive — when
* the bridge re-serialises config.yaml the key order can flip and a
* spurious bounce would fire on an otherwise no-op save.
*
* Arrays preserve their order (intentional — `backends[]` /
* `virtualKeys[]` ordering is semantic from the operator's
* perspective; first match wins in router lookups).
*/
function stableStringify(value: unknown): string {
return JSON.stringify(value, (_key, val) => {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
const sorted: Record<string, unknown> = {};
for (const k of Object.keys(val as Record<string, unknown>).sort()) {
sorted[k] = (val as Record<string, unknown>)[k];
}
return sorted;
}
return val;
});
}
/**
* Shallow equivalence on the subset of fields that affect the gateway's
* runtime behavior. Used to decide whether a config-changed event
* requires a bounce. Uses stableStringify so YAML round-trip key-order
* changes don't cause false-positive bounces.
*/
function configsEquivalent(a: GatewayConfig, b: GatewayConfig): boolean {
if (a.listenPort !== b.listenPort) return false;
if (a.requestTimeoutSec !== b.requestTimeoutSec) return false;
if (a.upstreamTimeoutSec !== b.upstreamTimeoutSec) return false;
if (a.shutdownGracefulSec !== b.shutdownGracefulSec) return false;
if (stableStringify(a.backends) !== stableStringify(b.backends)) return false;
if (stableStringify(a.virtualKeys) !== stableStringify(b.virtualKeys)) return false;
return true;
}
+46
View File
@@ -0,0 +1,46 @@
import { EventEmitter } from 'events';
export interface JobStreamEvent {
type: 'prompt_progress' | 'text' | 'tool_use' | 'tool_use_delta' | 'tool_result' | 'done';
// prompt_progress
processed?: number;
total?: number;
timeMs?: number;
cache?: number;
// text
text?: string;
// tool_use / tool_result
toolName?: string;
toolInput?: string;
toolOutput?: string;
toolIsError?: boolean;
callId?: string;
// tool_use_delta (live tool-call argument streaming)
name?: string;
chunk?: string;
}
class JobEventBus extends EventEmitter {
constructor() {
super();
this.setMaxListeners(200);
}
emitJob(jobId: string, event: JobStreamEvent): void {
this.emit(`job:${jobId}`, event);
}
onJob(jobId: string, handler: (event: JobStreamEvent) => void): void {
this.on(`job:${jobId}`, handler);
}
offJob(jobId: string, handler: (event: JobStreamEvent) => void): void {
this.off(`job:${jobId}`, handler);
}
hasListeners(jobId: string): boolean {
return this.listenerCount(`job:${jobId}`) > 0;
}
}
export const jobEventBus = new JobEventBus();
+61
View File
@@ -0,0 +1,61 @@
import { type Request, type Response } from 'express';
import { join, resolve, sep } from 'path';
export function getLocalWorkspacePath(worktreeDir: string | undefined, taskId: number): string {
const base = worktreeDir ?? '/tmp/maestro/workspaces';
return join(base, 'local', String(taskId));
}
export function ensurePathWithin(baseDir: string, requestedPath: string): string {
const resolvedBase = resolve(baseDir);
const resolvedPath = resolve(baseDir, requestedPath);
if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) {
throw new Error('Path escapes workspace');
}
return resolvedPath;
}
export function serializeLocalFileEntry(relativePath: string, name: string, isDirectory: boolean, size: number, mtime: Date) {
return {
name,
path: relativePath ? `${relativePath}/${name}` : name,
kind: isDirectory ? 'directory' : 'file',
size,
modifiedAt: mtime.toISOString(),
};
}
export function getOwnerFilter(req: Request): { ownerId?: string } {
if (!req.user) return {};
if (req.user.role === 'admin') return {};
return { ownerId: req.user.id };
}
export function checkTaskOwnership(req: Request, res: Response, task: { ownerId?: string | null } | null): boolean {
if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
if (req.user && req.user.role !== 'admin' && task.ownerId !== req.user?.id) {
res.status(404).json({ error: 'Task not found' });
return false;
}
return true;
}
type TaskLike = {
ownerId?: string | null;
visibility?: 'private' | 'org' | 'public' | null;
visibilityScopeOrgId?: string | null;
};
// Read-side permission check honoring the full visibility model.
// Writes should continue to use checkTaskOwnership (owner-or-admin only).
export function canViewTask(req: Request, res: Response, task: TaskLike | null): boolean {
if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
const user = req.user as Express.User | undefined;
if (!user) return true;
if (user.role === 'admin') return true;
if (task.ownerId && task.ownerId === user.id) return true;
if (task.visibility === 'public') return true;
if (task.visibility === 'org' && task.visibilityScopeOrgId && user.orgIds?.includes(task.visibilityScopeOrgId)) return true;
res.status(404).json({ error: 'Task not found' });
return false;
}
+176
View File
@@ -0,0 +1,176 @@
import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { join, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { ensurePathWithin, serializeLocalFileEntry, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
export function mountLocalFilesApi(app: Application, repo: Repository): void {
app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
});
res.json({ basePath: section, path: relativeDir, entries });
} catch (err) {
logger.error(`Local files list API error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
app.get('/api/local/tasks/:taskId/files/content', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
logger.error(`Local file content API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/local/tasks/:taskId/files/raw', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
logger.error(`Local file raw API error: ${err}`);
res.status(500).json({ error: 'Failed to read raw file' });
}
});
app.put('/api/local/tasks/:taskId/files/content', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (latestJob && ['running', 'dispatching'].includes(latestJob.status)) {
res.status(409).json({ error: 'Cannot edit files while job is running' });
return;
}
const section = String(req.body?.section ?? '');
if (section !== 'output') {
res.status(400).json({ error: 'Only output files can be edited' });
return;
}
const relativePath = String(req.body?.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const content = req.body?.content;
if (typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
// PUT (inline edit) is output-only; section is narrowed to 'output' above.
const rootDir = join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
writeFileSync(filePath, content, 'utf-8');
res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'Path escapes workspace') {
res.status(400).json({ error: message });
return;
}
logger.error(`Local file update API error: ${err}`);
res.status(500).json({ error: 'Failed to update file' });
}
});
}
+913
View File
@@ -0,0 +1,913 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
describe('POST /api/local/tasks with visibility', () => {
let tempDir = '';
let repo: Repository;
let app: express.Application;
let aliceUser: Express.User;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-api-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const real = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
aliceUser = {
...real,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = aliceUser;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('creates task with owner_id and visibility=org', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
visibility: 'org',
visibilityScopeOrgId: '10',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('org');
expect(res.body.task.visibilityScopeOrgId).toBe('10');
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('rejects visibility=org with org not in user orgs', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
visibility: 'org',
visibilityScopeOrgId: '99',
});
expect(res.status).toBe(400);
});
it('defaults visibility to private when not provided', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('private');
expect(res.body.task.visibilityScopeOrgId).toBeNull();
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('rejects invalid visibility enum values', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
visibility: 'bogus',
});
expect(res.status).toBe(400);
});
it('clears visibilityScopeOrgId when visibility is public', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
visibility: 'public',
visibilityScopeOrgId: '10',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('public');
expect(res.body.task.visibilityScopeOrgId).toBeNull();
});
});
describe('DELETE /api/local/tasks/:id owner-or-admin', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
it('non-owner non-admin gets 404 on DELETE (even when visibility=public)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({
title: 't',
body: 'b',
ownerId: alice.id,
visibility: 'public',
});
const bobUser: Express.User = {
id: 'bob-id',
email: '[email protected]',
name: 'b',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const app = buildAppForUser(bobUser);
// Bob CANNOT delete task owned by alice
const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
expect(delRes.status).toBe(404);
// Task still exists (Bob's DELETE was rejected)
const after = await repo.getLocalTask(task.id);
expect(after).not.toBeNull();
});
it('admin can DELETE any task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({
title: 't',
body: 'b',
ownerId: alice.id,
visibility: 'private',
});
const adminUser: Express.User = {
id: 'admin-id',
email: '[email protected]',
name: 'admin',
avatarUrl: null,
role: 'admin',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const app = buildAppForUser(adminUser);
const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
expect(delRes.status).toBe(200);
expect(delRes.body.ok).toBe(true);
const after = await repo.getLocalTask(task.id);
expect(after).toBeNull();
});
it('owner can DELETE own task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't',
body: 'b',
ownerId: alice.id,
visibility: 'private',
});
const app = buildAppForUser(aliceUser);
const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
expect(delRes.status).toBe(200);
expect(delRes.body.ok).toBe(true);
});
});
describe('PATCH /api/local/tasks/:id visibility', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
it('owner can change visibility from private to org with valid orgId', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'org', visibilityScopeOrgId: '10' });
expect(res.status).toBe(200);
expect(res.body.task.visibility).toBe('org');
expect(res.body.task.visibilityScopeOrgId).toBe('10');
});
it('rejects invalid visibility enum with 400', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'bogus' });
expect(res.status).toBe(400);
});
it('rejects visibility=org without a scope org the user belongs to', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'org', visibilityScopeOrgId: '99' });
expect(res.status).toBe(400);
});
it('non-owner non-admin gets 404 on PATCH', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'public',
});
const bobUser: Express.User = {
id: 'bob-id',
email: '[email protected]',
name: 'b',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const app = buildAppForUser(bobUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'private' });
expect(res.status).toBe(404);
const after = await repo.getLocalTask(task.id);
expect(after!.visibility).toBe('public');
});
it('cascades visibility change to the spawn job and its subtask descendants', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: ['10'], defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
});
const spawnJob = await repo.createJob({
repo: `local/task-${task.id}`, issueNumber: task.id, instruction: 'run',
ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
});
const subJob = await repo.createJob({
repo: `subtask/${spawnJob.id}`, issueNumber: 1, instruction: 'sub',
parentJobId: spawnJob.id, subtaskDepth: 1,
ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
});
const grandSubJob = await repo.createJob({
repo: `subtask/${subJob.id}`, issueNumber: 1, instruction: 'sub-sub',
parentJobId: subJob.id, subtaskDepth: 2,
ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'org', visibilityScopeOrgId: '10' });
expect(res.status).toBe(200);
for (const id of [spawnJob.id, subJob.id, grandSubJob.id]) {
const after = await repo.getJob(id);
expect(after!.visibility).toBe('org');
expect(after!.visibilityScopeOrgId).toBe('10');
}
});
it('nulls the scope on descendants when visibility moves back to public', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: ['10'], defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'org', visibilityScopeOrgId: '10',
});
const spawnJob = await repo.createJob({
repo: `local/task-${task.id}`, issueNumber: task.id, instruction: 'run',
ownerId: alice.id, visibility: 'org', visibilityScopeOrgId: '10',
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'public' });
expect(res.status).toBe(200);
const after = await repo.getJob(spawnJob.id);
expect(after!.visibility).toBe('public');
expect(after!.visibilityScopeOrgId).toBeNull();
});
});
describe('GET /api/local/tasks visibility filter', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
async function seedThreeTasks(aliceId: string) {
const priv = await repo.createLocalTask({ title: 'priv', body: 'b', ownerId: aliceId, visibility: 'private' });
const org = await repo.createLocalTask({ title: 'org', body: 'b', ownerId: aliceId, visibility: 'org', visibilityScopeOrgId: '10' });
const pub = await repo.createLocalTask({ title: 'pub', body: 'b', ownerId: aliceId, visibility: 'public' });
return { priv, org, pub };
}
it('owner sees all three visibilities', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
await seedThreeTasks(alice.id);
const res = await request(buildAppForUser(aliceUser)).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title).sort()).toEqual(['org', 'priv', 'pub']);
});
it('admin sees all three', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const admin: Express.User = {
id: 'admin-id', email: '[email protected]', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
await seedThreeTasks(alice.id);
const res = await request(buildAppForUser(admin)).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(res.body.tasks).toHaveLength(3);
});
it('same-org bystander sees org + public (not private)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const bob = repo.createUser({ email: '[email protected]', name: 'b', role: 'user', status: 'active' });
const bobUser: Express.User = {
...bob,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
await seedThreeTasks(alice.id);
const res = await request(buildAppForUser(bobUser)).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title).sort()).toEqual(['org', 'pub']);
});
it('different-org bystander sees only public', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const carol = repo.createUser({ email: '[email protected]', name: 'c', role: 'user', status: 'active' });
const carolUser: Express.User = {
...carol,
orgIds: ['20'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
await seedThreeTasks(alice.id);
const res = await request(buildAppForUser(carolUser)).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).toEqual(['pub']);
});
});
describe('PUT /api/local/tasks/:taskId/feedback ownership', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
it('non-owner non-admin gets 404 on feedback (even when visibility=public)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'public' });
const bobUser: Express.User = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(bobUser))
.put(`/api/local/tasks/${task.id}/feedback`)
.send({ rating: 'good', tags: [] });
expect(res.status).toBe(404);
});
it('owner can submit feedback on own task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });
const res = await request(buildAppForUser(aliceUser))
.put(`/api/local/tasks/${task.id}/feedback`)
.send({ rating: 'good', tags: [] });
expect(res.status).toBe(200);
});
it('admin can submit feedback on any task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });
const adminUser: Express.User = {
id: 'admin-id', email: '[email protected]', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(adminUser))
.put(`/api/local/tasks/${task.id}/feedback`)
.send({ rating: 'good', tags: [] });
expect(res.status).toBe(200);
});
});
describe('POST /api/local/tasks/:taskId/comments and /cancel ownership', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
function makeBob(): Express.User {
return {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
it('non-owner non-admin gets 404 on POST /comments (private task)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });
const res = await request(buildAppForUser(makeBob()))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'hi from bob', author: 'user' });
expect(res.status).toBe(404);
// Comment was NOT recorded
const comments = await repo.listLocalTaskComments(task.id);
expect(comments).toHaveLength(0);
});
it('non-owner non-admin gets 404 on POST /comments even when task is public', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'public' });
const res = await request(buildAppForUser(makeBob()))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'hi from bob', author: 'user' });
expect(res.status).toBe(404);
});
it('non-owner non-admin gets 404 on POST /cancel', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cancel-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });
const res = await request(buildAppForUser(makeBob()))
.post(`/api/local/tasks/${task.id}/cancel`);
expect(res.status).toBe(404);
});
it('owner can post comments to own task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: join(tempDir, 'ws-alice') });
const res = await request(buildAppForUser(aliceUser))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'hi from alice', author: 'user' });
expect(res.status).toBe(201);
});
});
describe('POST /api/local/tasks browserSessionProfileId owner check', () => {
let tempDir = '';
let repo: Repository;
let sessRepo: BrowserSessionRepo;
let alice: { id: string };
let bob: { id: string };
let aliceProfileId: number;
let bobProfileId: number;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-bsp-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
sessRepo = new BrowserSessionRepo(repo.getDb());
alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
bob = repo.createUser({ email: '[email protected]', name: 'b', role: 'user', status: 'active' });
aliceProfileId = sessRepo.createProfile({
ownerId: alice.id,
label: 'alice-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
bobProfileId = sessRepo.createProfile({
ownerId: bob.id,
label: 'bob-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
sessRepo,
});
return app;
}
function asUser(u: { id: string }, email: string): Express.User {
return {
id: u.id, email, name: 'x', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
it('accepts a valid profile owned by the requesting user (201)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto', browserSessionProfileId: aliceProfileId });
expect(res.status).toBe(201);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('rejects a profile owned by a different user (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto', browserSessionProfileId: bobProfileId });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not owned by you|not found/i);
});
it('rejects a positive integer that does not match any profile (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto', browserSessionProfileId: 999999 });
expect(res.status).toBe(400);
});
it('rejects a non-positive-integer profile id (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto', browserSessionProfileId: -3 });
expect(res.status).toBe(400);
});
it('without the field, behavior is unchanged (201, profile null)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto' });
expect(res.status).toBe(201);
expect(res.body.task.browserSessionProfileId ?? null).toBeNull();
});
});
describe('POST /api/local/tasks/:id/continue', () => {
let tempDir = '';
let repo: Repository;
let app: express.Application;
let aliceUser: Express.User;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cont-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const real = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
aliceUser = {
...real,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = aliceUser;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
pieceExists: (name: string) => name === 'manual-writer' || name === 'ssh-ops',
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
async function setupTaskWithTerminalJob(opts: { status?: string } = {}) {
const task = await repo.createLocalTask({
title: 't',
body: 'b',
pieceName: 'manual-writer',
ownerId: aliceUser.id,
});
const prev = await repo.createJob({
repo: localTaskRepoName(task.id),
issueNumber: task.id,
instruction: 'go',
pieceName: 'manual-writer',
ownerId: aliceUser.id,
});
await repo.updateJob(prev.id, { status: opts.status ?? 'succeeded' });
await repo.addLocalTaskComment(task.id, 'agent', '✅ 完了\n\nmanual at output/manual.md', 'result');
return { task, prev };
}
it('happy path: terminal succeeded job + valid piece + instruction → 201', async () => {
const { task, prev } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'use output/manual.md to set up foo' });
expect(res.status).toBe(201);
expect(res.body.jobId).toBeTruthy();
const newJob = await repo.getJob(res.body.jobId);
expect(newJob?.pieceName).toBe('ssh-ops');
expect(newJob?.continuedFromJobId).toBe(prev.id);
const updatedTask = await repo.getLocalTask(task.id);
expect(updatedTask?.pieceName).toBe('ssh-ops');
});
it('posts a handoff-kind comment naming both pieces', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'go' });
expect(res.status).toBe(201);
const comments = await repo.listLocalTaskComments(task.id);
const handoff = comments.find((c) => c.kind === 'handoff');
expect(handoff).toBeTruthy();
expect(handoff?.body).toContain('manual-writer');
expect(handoff?.body).toContain('ssh-ops');
});
it('returns 409 job_in_progress when prev job is running', async () => {
const { task } = await setupTaskWithTerminalJob({ status: 'running' });
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'go' });
expect(res.status).toBe(409);
expect(res.body.error).toBe('job_in_progress');
expect(res.body.currentStatus).toBe('running');
});
it('returns 409 no_previous_job when no jobs exist for the task', async () => {
const task = await repo.createLocalTask({
title: 't',
body: 'b',
pieceName: 'manual-writer',
ownerId: aliceUser.id,
});
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'go' });
expect(res.status).toBe(409);
expect(res.body.error).toBe('no_previous_job');
});
it('returns 400 piece_not_found for unknown piece', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'no-such-piece', instruction: 'go' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('piece_not_found');
});
it('returns 400 instruction_required for whitespace-only instruction', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: ' ' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('instruction_required');
});
it('returns 400 piece_required when piece field is missing', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ instruction: 'go' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('piece_required');
});
it('all DB-valid terminal states allow continuation', async () => {
// jobs.status CHECK constraint permits these four terminal states.
// 'aborted' is intentionally absent — the worker maps abort outcomes to
// 'failed' (see worker.ts handlePieceResult), so the endpoint's TERMINAL
// list also excludes it (see commit 974ef89).
for (const status of ['succeeded', 'failed', 'waiting_human', 'cancelled']) {
const { task } = await setupTaskWithTerminalJob({ status });
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: `from ${status}` });
expect(res.status, `status=${status}`).toBe(201);
}
});
});
+670
View File
@@ -0,0 +1,670 @@
import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { logger } from '../logger.js';
import { resolveJobScheduling } from '../scheduling.js';
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
import { jobEventBus, type JobStreamEvent } from './job-events.js';
export interface LocalTasksApiOptions {
repo: Repository;
worktreeDir?: string;
generateTitle?: (body: string) => Promise<string>;
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
/**
* Server-side validator for piece names accepted by the
* /continue endpoint. Returns true if the piece is loadable.
* When unset, /continue rejects all requests with 500 (misconfiguration).
*/
pieceExists?: (name: string) => boolean;
/**
* Optional. When set, accepting browserSessionProfileId on task create
* verifies the profile belongs to the requesting user. Without it, the
* field is silently dropped (legacy / no-auth deployments).
*/
sessRepo?: BrowserSessionRepo;
/**
* Optional. Returns the current upload size limit (MB) for task creation
* and comment posting. Called per request so config changes take effect
* without a server restart. Clamped to [1, 1000] MB. Default: 50.
*/
getMaxUploadMb?: () => number;
}
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
const { repo, worktreeDir, sessRepo } = opts;
const resolveUploadLimit = (): string => {
const raw = opts.getMaxUploadMb?.() ?? 50;
const mb = Number.isFinite(raw) ? Math.max(1, Math.min(1000, Math.floor(raw))) : 50;
return `${mb}mb`;
};
const dynamicJson = () => (req: Request, res: Response, next: express.NextFunction) =>
express.json({ limit: resolveUploadLimit() })(req, res, next);
app.get('/api/local/tasks', async (req: Request, res: Response) => {
try {
const viewer = req.user as Express.User | undefined;
const tasks = await repo.listLocalTasks(viewer ? { viewer } : {});
res.json({ tasks });
} catch (err) {
logger.error(`Local tasks list API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local tasks' });
}
});
app.post('/api/local/tasks', dynamicJson(), async (req: Request, res: Response) => {
try {
const validation = validateCreateTaskBody(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const body = validation.data;
// Visibility extraction + validation
const rawVisibility = req.body?.visibility ?? 'private';
if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
const visibility = rawVisibility as 'private' | 'org' | 'public';
const rawScopeOrgId = req.body?.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
}
// Optional browser session profile binding. Owner-scoped check
// (sessRepo.getProfileById enforces owner_id = req.user.id) prevents
// user A from binding user B's profile to their task.
let browserSessionProfileId: number | null = null;
const rawProfileId = req.body?.browserSessionProfileId;
if (rawProfileId !== undefined && rawProfileId !== null && rawProfileId !== '') {
const n = Number(rawProfileId);
if (!Number.isInteger(n) || n <= 0) {
res.status(400).json({ error: 'browserSessionProfileId must be a positive integer' });
return;
}
if (sessRepo) {
const userId = (req.user as Express.User | undefined)?.id;
if (!userId) {
res.status(400).json({ error: 'browserSessionProfileId requires an authenticated user' });
return;
}
const owned = sessRepo.getProfileById(n, userId);
if (!owned) {
res.status(400).json({ error: 'browser session profile not found or not owned by you' });
return;
}
}
browserSessionProfileId = n;
}
let taskTitle = (body.title ?? '').trim();
const rawPiece = (body.piece ?? 'auto').trim();
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
// タイトル生成と piece 分類を並列実行
const [generatedTitle, autoSelectedPiece] = await Promise.all([
// タイトル生成
(!taskTitle && opts.generateTitle)
? Promise.race([
opts.generateTitle(body.body.trim()),
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
]).catch((e: unknown) => { logger.warn(`Title generation failed: ${e}`); return ''; })
: Promise.resolve(''),
// piece 分類('auto' の場合のみ); userId を渡し per-user カタログを使用
(rawPiece === 'auto' && opts.selectPiece)
? opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id).catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
: Promise.resolve(rawPiece),
]);
if (!taskTitle) {
taskTitle = generatedTitle || body.body.trim().slice(0, 40).replace(/\n/g, ' ');
}
const piece = autoSelectedPiece;
const profile = body.profile ?? 'auto';
const outputFormat = body.outputFormat ?? 'markdown';
const askPolicy = body.askPolicy ?? 'low';
const priority = body.priority ?? 'medium';
const scheduling = resolveJobScheduling({
role: profile,
pieceName: piece,
instruction: body.body.trim(),
});
// Per-task options (e.g. { mcpDisabled, skillsDisabled })
const rawOptions = req.body?.options;
const taskOptions: Record<string, unknown> =
rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)
? rawOptions as Record<string, unknown>
: {};
const task = await repo.createLocalTask({
title: taskTitle,
body: body.body.trim(),
pieceName: piece,
profile,
outputFormat,
askPolicy,
priority,
ownerId: req.user?.id,
visibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
browserSessionProfileId,
options: taskOptions,
});
const workspacePath = getLocalWorkspacePath(worktreeDir, task.id);
mkdirSync(join(workspacePath, 'input'), { recursive: true });
mkdirSync(join(workspacePath, 'output'), { recursive: true });
mkdirSync(join(workspacePath, 'logs'), { recursive: true });
await repo.updateLocalTask(task.id, { workspacePath });
for (const att of body.attachments ?? []) {
if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(workspacePath, 'input', safeName), Buffer.from(att.contentBase64, 'base64'));
}
await repo.addLocalTaskComment(task.id, 'user', body.body.trim(), 'request');
const metadataBlock = [
'---',
`ui_profile: ${scheduling.role}`,
`ui_output_format: ${outputFormat}`,
`ui_ask_policy: ${askPolicy}`,
`ui_priority: ${priority}`,
'---',
].join('\n');
const instruction = `${taskTitle}\n\n${body.body.trim()}\n\n${metadataBlock}`.trim();
// Merge task options into job payload so the worker can read them at runtime.
const hasOptions = Object.keys(taskOptions).length > 0;
const job = await repo.createJob({
repo: localTaskRepoName(task.id),
issueNumber: task.id,
instruction,
pieceName: piece,
role: scheduling.role,
ownerId: task.ownerId,
visibility: task.visibility,
visibilityScopeOrgId: task.visibilityScopeOrgId,
browserSessionProfileId: task.browserSessionProfileId ?? null,
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
});
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
if (rawPiece === 'auto') {
await repo.addAuditLog(job.id, 'piece_auto_selected', 'piece-classifier', {
selectedPiece: piece,
});
}
const created = await repo.getLocalTask(task.id);
res.status(201).json({ task: created, jobId: job.id });
} catch (err) {
logger.error(`Create local task API error: ${err}`);
res.status(500).json({ error: 'Failed to create local task' });
}
});
app.get('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
res.json({ task });
} catch (err) {
logger.error(`Local task detail API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local task' });
}
});
app.put('/api/local/tasks/:taskId/feedback', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const validation = validateFeedbackBody(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
await repo.updateFeedback(taskId, validation.data);
const updated = await repo.getLocalTask(taskId);
res.json({ task: updated });
} catch (err) {
logger.error(`Local task feedback API error: ${err}`);
res.status(500).json({ error: 'Failed to update feedback' });
}
});
app.put('/api/local/tasks/:taskId/mission', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Partial-replace: only string fields are written. Anything else
// (null, undefined, non-string) is treated as "leave unchanged".
// To clear a field, send an empty string.
const body = (req.body ?? {}) as Record<string, unknown>;
const patch: Record<string, string> = {};
for (const key of ['goal', 'done', 'open', 'clarifications'] as const) {
const v = body[key];
if (typeof v === 'string') patch[key] = v;
}
if (Object.keys(patch).length === 0) {
res.status(400).json({ error: 'No mission fields provided. Send goal, done, open, or clarifications as strings.' });
return;
}
const merged = await repo.updateMissionBrief(taskId, patch);
res.json({ missionBrief: merged });
} catch (err) {
logger.error(`Local task mission API error: ${err}`);
res.status(500).json({ error: 'Failed to update mission brief' });
}
});
app.get('/api/local/tasks/:taskId/comments', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
const comments = await repo.listLocalTaskComments(taskId);
res.json({ comments });
} catch (err) {
logger.error(`Local task comments API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local task comments' });
}
});
app.post('/api/local/tasks/:taskId/comments', dynamicJson(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const commentValidation = validateCommentBody(req.body);
if (!commentValidation.valid) {
res.status(400).json({ error: commentValidation.error });
return;
}
const { body, author, attachments } = commentValidation;
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Save attachments to input/
if (attachments && attachments.length > 0 && task?.workspacePath) {
const inputDir = join(task.workspacePath, 'input');
mkdirSync(inputDir, { recursive: true });
for (const att of attachments) {
if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(inputDir, safeName), Buffer.from(att.contentBase64, 'base64'));
}
}
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
// running / dispatching / waiting_subtasks 中: コメント保存のみ(agent-loop が注入する)
const isActive = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
const commentKind = isActive ? 'interjection' : 'comment';
const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind);
if (isActive) {
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
res.status(201).json({ comment, jobId: prevJob!.id, interjection: true });
return;
}
const askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
// Build instruction with attachment info
const savedFileNames = (attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
const instruction = savedFileNames.length > 0
? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}`
: body;
const job = await repo.createJob({
repo: localTaskRepoName(taskId),
issueNumber: taskId,
instruction,
pieceName: task!.pieceName,
askCount,
resumeMovement,
role: prevJob?.requiredRole,
ownerId: task!.ownerId,
visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null,
});
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
res.status(201).json({ comment, jobId: job.id });
} catch (err) {
logger.error(`Local task comment create API error: ${err}`);
res.status(500).json({ error: 'Failed to post local task comment' });
}
});
app.patch('/api/local/tasks/:taskId', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
const updates: { visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
if (req.body.visibility !== undefined) {
const v = req.body.visibility;
if (!['private', 'org', 'public'].includes(v)) {
res.status(400).json({ error: 'invalid visibility' }); return;
}
updates.visibility = v;
}
if (req.body.visibilityScopeOrgId !== undefined) {
updates.visibilityScopeOrgId = req.body.visibilityScopeOrgId ?? null;
}
if (updates.visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
const scopeId = updates.visibilityScopeOrgId ?? task!.visibilityScopeOrgId ?? null;
if (!scopeId || !orgIds.includes(scopeId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); return;
}
updates.visibilityScopeOrgId = scopeId;
}
if (updates.visibility && updates.visibility !== 'org') {
updates.visibilityScopeOrgId = null;
}
await repo.updateLocalTask(taskId, updates);
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
await repo.updateJobsVisibilityForTask(taskId, {
visibility: refreshed.visibility ?? 'private',
visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
});
}
res.json({ task: refreshed });
} catch (err) {
logger.error(`Patch local task API error: ${err}`);
res.status(500).json({ error: 'Failed to update task' });
}
});
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
await repo.deleteLocalTask(taskId);
res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('has an active job')) {
res.status(409).json({ error: 'Cannot delete task with running jobs' });
return;
}
logger.error(`Delete local task API error: ${err}`);
res.status(500).json({ error: 'Failed to delete local task' });
}
});
app.post('/api/local/tasks/:taskId/cancel', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!latestJob || !['running', 'dispatching'].includes(latestJob.status)) {
res.status(404).json({ error: 'No running job found' });
return;
}
const cancelled = repo.requestJobCancel(latestJob.id);
if (!cancelled) {
res.status(409).json({ error: 'Job is no longer running' });
return;
}
await repo.addAuditLog(latestJob.id, 'job_cancel_requested', 'local-ui', { taskId });
logger.info(`Cancel requested for job ${latestJob.id} (task ${taskId})`);
res.json({ ok: true, jobId: latestJob.id });
} catch (err) {
logger.error(`Cancel local task API error: ${err}`);
res.status(500).json({ error: 'Failed to cancel task' });
}
});
app.post('/api/local/tasks/:taskId/continue', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const piece = typeof req.body?.piece === 'string' ? req.body.piece.trim() : '';
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
if (!piece) {
res.status(400).json({ error: 'piece_required' });
return;
}
if (!instruction.trim()) {
res.status(400).json({ error: 'instruction_required' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Piece existence check (server-side; UI dropdown is best-effort).
if (!opts.pieceExists) {
logger.error('[local-tasks-api] /continue invoked but pieceExists option not configured');
res.status(500).json({ error: 'piece_validation_unavailable' });
return;
}
if (!opts.pieceExists(piece)) {
res.status(400).json({ error: 'piece_not_found', piece });
return;
}
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!prevJob) {
res.status(409).json({ error: 'no_previous_job' });
return;
}
// jobs.status CHECK には 'aborted' が無い (worker が abort 結果を 'failed' に集約するため)。
// 'waiting_subtasks' は子 job 待機の中間状態で、そこから別 piece に切り替えると孤立するので除外。
const TERMINAL: ReadonlyArray<string> = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
if (!TERMINAL.includes(prevJob.status)) {
res.status(409).json({ error: 'job_in_progress', currentStatus: prevJob.status });
return;
}
const job = await repo.createJob({
repo: localTaskRepoName(taskId),
issueNumber: taskId,
instruction: instruction.trim(),
pieceName: piece,
continuedFromJobId: prevJob.id,
ownerId: task!.ownerId,
role: prevJob.requiredRole,
visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null,
});
await repo.updateLocalTask(taskId, { pieceName: piece });
// Surface the handoff in the timeline so the user (and the LLM, when
// it later inspects task comments) can see when piece switches happened.
await repo.addLocalTaskComment(
taskId,
'system',
`🔄 Continued: piece="${prevJob.pieceName}" → piece="${piece}"`,
'handoff',
);
await repo.addAuditLog(job.id, 'job_queued_local_continue', 'local-ui', {
taskId,
fromPiece: prevJob.pieceName,
toPiece: piece,
prevJobId: prevJob.id,
});
res.status(201).json({ jobId: job.id });
} catch (err) {
logger.error(`Local task continue API error: ${err}`);
res.status(500).json({ error: 'Failed to continue task' });
}
});
// ── SSE stream: real-time job events ──────────────────────────────────────
app.get('/api/local/tasks/:taskId/stream', async (req: Request, res: Response) => {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'invalid taskId' }); return; }
try {
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : {});
if (!task) { res.status(404).json({ error: 'task not found' }); return; }
const runningJob = task.latestJob;
if (!runningJob || (runningJob.status !== 'running' && runningJob.status !== 'dispatching')) {
res.status(204).end();
return;
}
const jobId = runningJob.id;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// Text delta batching (50ms flush)
let textBuf = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const TEXT_FLUSH_MS = 50;
// Tool-call argument delta batching, keyed by callId (50ms flush).
const toolBuf = new Map<string, { name: string; chunk: string }>();
let toolFlushTimer: ReturnType<typeof setTimeout> | null = null;
const flushText = () => {
if (textBuf) {
const data = JSON.stringify({ type: 'text_delta', text: textBuf });
res.write(`data: ${data}\n\n`);
textBuf = '';
}
flushTimer = null;
};
const flushToolDeltas = () => {
for (const [callId, { name, chunk }] of toolBuf) {
if (res.writableEnded) break;
res.write(`data: ${JSON.stringify({ type: 'tool_use_delta', callId, name, chunk })}\n\n`);
}
toolBuf.clear();
toolFlushTimer = null;
};
const handler = (event: JobStreamEvent) => {
if (res.writableEnded) return;
if (event.type === 'text') {
textBuf += event.text ?? '';
if (!flushTimer) flushTimer = setTimeout(flushText, TEXT_FLUSH_MS);
return;
}
if (event.type === 'tool_use_delta') {
const callId = event.callId ?? '';
// chunk is a full snapshot of args-so-far; keep the LATEST per
// callId (replace, not append) so each flush sends the newest
// complete prefix. Coalesces many snapshots into one per 50ms.
toolBuf.set(callId, {
name: event.name ?? toolBuf.get(callId)?.name ?? '',
chunk: event.chunk ?? '',
});
if (!toolFlushTimer) toolFlushTimer = setTimeout(flushToolDeltas, TEXT_FLUSH_MS);
return;
}
// Flush pending text + tool deltas before non-streaming events
if (textBuf) flushText();
if (toolBuf.size) flushToolDeltas();
if (event.type === 'prompt_progress') {
const effective = (event.processed ?? 0) - (event.cache ?? 0);
const effectiveTotal = (event.total ?? 0) - (event.cache ?? 0);
const percent = effectiveTotal > 0 ? Math.round(effective / effectiveTotal * 100) : 0;
res.write(`data: ${JSON.stringify({ type: 'prompt_progress', percent, processed: event.processed, total: event.total, cache: event.cache, timeMs: event.timeMs })}\n\n`);
} else if (event.type === 'done') {
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
cleanup();
res.end();
} else {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
};
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(': heartbeat\n\n');
}, 15_000);
const cleanup = () => {
jobEventBus.offJob(jobId, handler);
clearInterval(heartbeat);
if (flushTimer) { clearTimeout(flushTimer); flushText(); }
if (toolFlushTimer) { clearTimeout(toolFlushTimer); flushToolDeltas(); }
};
jobEventBus.onJob(jobId, handler);
req.on('close', cleanup);
} catch (err) {
logger.error(`Local task stream API error: ${err}`);
if (!res.headersSent) res.status(500).json({ error: 'stream failed' });
}
});
}
+327
View File
@@ -0,0 +1,327 @@
import { describe, it, expect, afterEach } from 'vitest';
import express from 'express';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createRegistry } from '../mcp/registry.js';
import { createTokenManager } from '../mcp/token-manager.js';
import { createToolCache } from '../mcp/tool-cache.js';
import { createAdminRouter, createUserRouter, createUserServersRouter } from './mcp-api.js';
import request from 'supertest';
const openDbs: Database.Database[] = [];
function makeApp(opts: { currentRole: 'admin' | 'user' | 'anon'; userId?: string }) {
const validKey = 'a'.repeat(64);
process.env.MCP_ENCRYPTION_KEY = validKey;
const db = new Database(':memory:');
openDbs.push(db);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`); // runMigrations needs this
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?)').run('u1');
db.prepare('INSERT INTO users(id) VALUES(?)').run('u2');
const reg = createRegistry(db);
const tm = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
const cache = createToolCache(db, 600);
const userId = opts.userId ?? 'u1';
const requireAdmin: express.RequestHandler = (_req, res, next) => {
if (opts.currentRole === 'admin') next();
else res.status(403).json({ error: 'admin required' });
};
const requireAuth: express.RequestHandler = (_req, res, next) => {
if (opts.currentRole !== 'anon') next();
else res.status(401).json({ error: 'unauth' });
};
const app = express();
app.use(express.json());
app.use(
'/api/mcp/servers',
createAdminRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
app.use(
'/api/mcp/connections',
createUserRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
app.use(
'/api/mcp/user-servers',
createUserServersRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
return { app, db, reg, tm };
}
describe('mcp-api', () => {
afterEach(() => {
while (openDbs.length) {
const db = openDbs.pop();
try {
db?.close();
} catch {
/* ignore */
}
}
delete process.env.MCP_ENCRYPTION_KEY;
});
it('non-admin cannot POST /api/mcp/servers', async () => {
const { app } = makeApp({ currentRole: 'user' });
const res = await request(app).post('/api/mcp/servers').send({ id: 'canva' });
expect(res.status).toBe(403);
});
it('admin can upsert (oauth) + list + delete', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'canva',
name: 'Canva',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
oauthClientId: 'cid',
oauthClientSecret: 'secret',
});
expect(post.status).toBe(200);
const list = await request(app).get('/api/mcp/servers');
expect(list.body.servers).toHaveLength(1);
// authKind and ownerId included in response
expect(list.body.servers[0].authKind).toBe('oauth');
expect(list.body.servers[0].ownerId).toBeNull();
// Secret must not leak
expect(JSON.stringify(list.body.servers)).not.toContain('secret');
const del = await request(app).delete('/api/mcp/servers/canva');
expect(del.status).toBe(200);
});
it('admin can upsert api_key server', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'myapi',
name: 'My API',
url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key',
staticToken: 'sk-test-admin',
});
expect(post.status).toBe(200);
const list = await request(app).get('/api/mcp/servers');
expect(list.body.servers[0].authKind).toBe('api_key');
// Static token must not leak
expect(JSON.stringify(list.body.servers)).not.toContain('sk-test-admin');
});
it('admin POST api_key fails without staticToken', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'myapi',
name: 'My API',
url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key',
});
expect(post.status).toBe(400);
});
it('user sees connection state with authKind + ownerId', async () => {
const { app, reg, tm } = makeApp({ currentRole: 'user' });
reg.upsert({
id: 'canva',
name: 'Canva',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'i',
oauthClientSecret: 's',
oauthScopes: null,
});
const accessTokenLiteral = 'access-token-do-not-leak-xyz';
const refreshTokenLiteral = 'refresh-token-do-not-leak-xyz';
tm.saveTokens({
userId: 'u1',
serverId: 'canva',
accessToken: accessTokenLiteral,
refreshToken: refreshTokenLiteral,
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
scope: null,
});
const res = await request(app).get('/api/mcp/connections');
expect(res.body.connections).toHaveLength(1);
expect(res.body.connections[0]).toMatchObject({
serverId: 'canva',
serverName: 'Canva',
connected: true,
authKind: 'oauth',
ownerId: null,
});
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain(accessTokenLiteral);
expect(serialized).not.toContain(refreshTokenLiteral);
});
it('connections GET uses listEnabledForUser (includes user-owned servers)', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
// Global server
reg.upsert({
id: 'global-server',
name: 'Global',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'i',
oauthClientSecret: 's',
oauthScopes: null,
});
// User-owned server
reg.upsert({
id: 'u1-server',
name: 'U1 Server',
url: 'http://127.0.0.1:2/mcp',
authKind: 'api_key',
ownerId: 'u1',
staticToken: 'sk-u1',
});
const res = await request(app).get('/api/mcp/connections');
expect(res.status).toBe(200);
const ids = res.body.connections.map((c: { serverId: string }) => c.serverId);
expect(ids).toContain('global-server');
expect(ids).toContain('u1-server');
});
it('user can POST /api/mcp/user-servers with api_key', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
const post = await request(app).post('/api/mcp/user-servers').send({
id: 'my-tool',
name: 'My Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
staticToken: 'sk-test',
});
expect(post.status).toBe(200);
// Server should appear in listEnabledForUser
const servers = reg.listEnabledForUser('u1');
expect(servers.find((s) => s.id === 'my-tool')).toBeTruthy();
expect(servers.find((s) => s.id === 'my-tool')?.ownerId).toBe('u1');
});
it('user cannot DELETE another user\'s server (403)', async () => {
// u2 creates a server, u1 tries to delete it
const { db, reg } = makeApp({ currentRole: 'user', userId: 'u2' });
reg.upsert({
id: 'u2-tool',
name: 'U2 Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
ownerId: 'u2',
staticToken: 'sk-u2',
});
// Now make an app as u1
const validKey = 'a'.repeat(64);
process.env.MCP_ENCRYPTION_KEY = validKey;
const reg2 = createRegistry(db);
const tm2 = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
const cache2 = createToolCache(db, 600);
const app2 = express();
app2.use(express.json());
app2.use('/api/mcp/user-servers', createUserServersRouter({
db,
registry: reg2,
tokenManager: tm2,
toolCache: cache2,
requireAdmin: (_req, _res, next) => next(),
requireAuth: (_req, _res, next) => next(),
getUserId: () => 'u1',
insecureLocalTestMode: true,
}));
const del = await request(app2).delete('/api/mcp/user-servers/u2-tool');
expect(del.status).toBe(403);
});
it('user cannot DELETE a global server via user-servers route', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
reg.upsert({
id: 'global-tool',
name: 'Global Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'csec',
oauthScopes: null,
});
const del = await request(app).delete('/api/mcp/user-servers/global-tool');
expect(del.status).toBe(403);
});
it('id collision: POST user-servers fails 409 if id already exists', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
// Pre-create a global server with same id
reg.upsert({
id: 'existing',
name: 'Existing',
url: 'http://127.0.0.1:9/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'csec',
oauthScopes: null,
});
const post = await request(app).post('/api/mcp/user-servers').send({
id: 'existing',
name: 'My Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
staticToken: 'sk-test',
});
expect(post.status).toBe(409);
});
it('DELETE /api/mcp/connections returns 400 for api_key global server', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
reg.upsert({
id: 'apikey-global',
name: 'API Key Global',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
ownerId: null,
staticToken: 'sk-global',
});
const del = await request(app).delete('/api/mcp/connections/apikey-global');
expect(del.status).toBe(400);
});
});
+418
View File
@@ -0,0 +1,418 @@
import { Router, type Request } from 'express';
import type Database from 'better-sqlite3';
import type { McpRegistry } from '../mcp/registry.js';
import type { McpTokenManager } from '../mcp/token-manager.js';
import type { McpToolCache } from '../mcp/tool-cache.js';
import { fetchDiscovery } from '../mcp/discovery.js';
import { createMcpClient } from '../mcp/client-factory.js';
import { logger } from '../logger.js';
export interface McpApiDeps {
db: Database.Database;
registry: McpRegistry;
tokenManager: McpTokenManager;
toolCache: McpToolCache;
requireAdmin: import('express').RequestHandler;
requireAuth: import('express').RequestHandler;
getUserId: (req: Request) => string | null;
insecureLocalTestMode?: boolean;
allowPrivateAddresses?: boolean;
}
const ID_REGEX = /^[a-z0-9_-]{1,64}$/;
export function createAdminRouter(deps: McpApiDeps): Router {
const router = Router();
router.get('/', deps.requireAdmin, (_req, res) => {
const servers = deps.registry.listPublic();
const enriched = servers.map((s) => ({
...s,
toolCount: deps.toolCache.getForServer(s.id).length,
}));
res.json({ servers: enriched });
});
router.post('/', deps.requireAdmin, async (req, res) => {
const body = req.body as Partial<{
id: string;
name: string;
url: string;
authKind: string;
oauthClientId: string;
oauthClientSecret: string;
oauthScopes: string;
staticToken: string;
enabled: boolean;
}>;
if (!body.id || !ID_REGEX.test(body.id)) {
res.status(400).json({ error: 'id must match [a-z0-9_-]{1,64}' });
return;
}
if (!body.name || !body.url) {
res.status(400).json({ error: 'missing required fields' });
return;
}
const authKind = (body.authKind ?? 'oauth') as 'oauth' | 'api_key';
if (authKind === 'oauth') {
if (!body.oauthClientId || !body.oauthClientSecret) {
res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' });
return;
}
} else if (authKind === 'api_key') {
if (!body.staticToken) {
res.status(400).json({ error: 'authKind api_key requires staticToken' });
return;
}
} else {
res.status(400).json({ error: `unknown authKind: ${authKind}` });
return;
}
const adminId = deps.getUserId(req);
deps.registry.upsert({
id: body.id,
name: body.name,
url: body.url,
authKind,
ownerId: null,
oauthClientId: body.oauthClientId,
oauthClientSecret: body.oauthClientSecret,
oauthScopes: body.oauthScopes ?? null,
staticToken: body.staticToken,
enabled: body.enabled !== false,
createdBy: adminId,
});
// Attempt discovery for OAuth only (best effort — defer on failure).
if (authKind === 'oauth') {
try {
const meta = await fetchDiscovery(body.url, {
insecureLocalTestMode: deps.insecureLocalTestMode,
});
deps.registry.setDiscovery(body.id, meta);
} catch (err) {
logger.warn(
`[mcp:api] discovery deferred for server=${body.id}: ${(err as Error).message}`,
);
}
}
// Auto list_tools for api_key servers (token available immediately).
// For OAuth servers, defer until callback handler.
if (authKind === 'api_key' && body.staticToken) {
try {
const server = deps.registry.getDecrypted(body.id);
if (server && server.staticToken) {
const { client, close } = await createMcpClient(
server,
server.staticToken,
{
insecureLocalTestMode: deps.insecureLocalTestMode,
allowPrivateAddresses: deps.allowPrivateAddresses,
callTimeoutMs: 30_000,
},
);
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
deps.toolCache.replaceForServer(body.id, list.tools);
logger.info(`[mcp:api] auto list_tools server=${body.id} count=${list.tools.length}`);
} finally {
await close();
}
}
} catch (err) {
logger.warn(
`[mcp:api] auto list_tools failed (deferred to manual refresh) server=${body.id}: ${(err as Error).message}`,
);
}
}
res.json({ ok: true });
});
router.delete('/:id', deps.requireAdmin, (req, res) => {
deps.registry.delete(req.params.id);
res.json({ ok: true });
});
router.post('/:id/tools/refresh', deps.requireAdmin, async (req, res) => {
const server = deps.registry.getDecrypted(req.params.id);
if (!server) {
res.status(404).json({ error: 'unknown server' });
return;
}
// For api_key servers, use the static token; for oauth servers use 'anonymous'
// (the OAuth token is per-user and not available at admin-refresh time).
const accessToken = server.authKind === 'api_key' && server.staticToken
? server.staticToken
: 'anonymous';
try {
const { client, close } = await createMcpClient(
server,
accessToken,
{
insecureLocalTestMode: deps.insecureLocalTestMode,
callTimeoutMs: 30_000,
allowPrivateAddresses: deps.allowPrivateAddresses,
},
);
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
deps.toolCache.replaceForServer(server.id, list.tools);
res.json({ ok: true, count: list.tools.length });
} finally {
await close();
}
} catch (err) {
logger.warn(
`[mcp:api] list_tools failed server=${server.id}: ${(err as Error).message}`,
);
res.status(502).json({ error: 'list_tools failed', detail: (err as Error).message });
}
});
return router;
}
export function createUserRouter(deps: McpApiDeps): Router {
const router = Router();
router.get('/', deps.requireAuth, (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const servers = deps.registry.listEnabledForUser(userId);
const out = servers.map((s) => ({
serverId: s.id,
serverName: s.name,
connected: deps.tokenManager.hasToken(userId, s.id),
authKind: s.authKind,
ownerId: s.ownerId,
}));
res.json({ connections: out });
});
router.delete('/:id', deps.requireAuth, (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const server = deps.registry.getDecrypted(req.params.id);
if (!server) {
res.status(404).json({ error: 'unknown server' });
return;
}
// For api_key global servers, DELETE has no meaningful effect
if (server.authKind === 'api_key' && server.ownerId === null) {
res.status(400).json({ error: 'api_key servers do not use per-user tokens; disconnect is not applicable' });
return;
}
// User-owned servers should be managed via /api/mcp/user-servers, not this route
if (server.ownerId !== null) {
res.status(400).json({ error: 'use /api/mcp/user-servers/:id to delete a user-owned server' });
return;
}
deps.tokenManager.deleteToken(userId, req.params.id);
res.json({ ok: true });
});
return router;
}
export function createUserServersRouter(deps: McpApiDeps): Router {
const router = Router();
router.get('/', deps.requireAuth, (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const servers = deps.registry.listEnabledForOwner(userId);
const enriched = servers.map((s) => ({
...s,
toolCount: deps.toolCache.getForServer(s.id).length,
}));
res.json({ servers: enriched });
});
router.post('/', deps.requireAuth, async (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const body = req.body as Partial<{
id: string;
name: string;
url: string;
authKind: string;
oauthClientId: string;
oauthClientSecret: string;
oauthScopes: string;
staticToken: string;
enabled: boolean;
}>;
if (!body.id || !ID_REGEX.test(body.id)) {
res.status(400).json({ error: 'id must match [a-z0-9_-]{1,64}' });
return;
}
if (!body.name || !body.url) {
res.status(400).json({ error: 'missing required fields: name, url' });
return;
}
const authKind = (body.authKind ?? 'oauth') as 'oauth' | 'api_key';
if (authKind === 'api_key') {
if (!body.staticToken) {
res.status(400).json({ error: 'authKind api_key requires staticToken' });
return;
}
} else if (authKind === 'oauth') {
if (!body.oauthClientId || !body.oauthClientSecret) {
res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' });
return;
}
} else {
res.status(400).json({ error: `unknown authKind: ${authKind}` });
return;
}
// Check for id collision with any existing server (global or other-user-owned)
const existing = deps.registry.getDecrypted(body.id);
if (existing) {
res.status(409).json({ error: `server id '${body.id}' already exists` });
return;
}
deps.registry.upsert({
id: body.id,
name: body.name,
url: body.url,
authKind,
ownerId: userId,
oauthClientId: body.oauthClientId,
oauthClientSecret: body.oauthClientSecret,
oauthScopes: body.oauthScopes ?? null,
staticToken: body.staticToken,
enabled: body.enabled !== false,
createdBy: userId,
});
// Auto list_tools for api_key servers (token available immediately).
// For OAuth servers, defer until callback handler.
if (authKind === 'api_key' && body.staticToken) {
try {
const server = deps.registry.getDecrypted(body.id);
if (server && server.staticToken) {
const { client, close } = await createMcpClient(
server,
server.staticToken,
{
insecureLocalTestMode: deps.insecureLocalTestMode,
allowPrivateAddresses: deps.allowPrivateAddresses,
callTimeoutMs: 30_000,
},
);
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
deps.toolCache.replaceForServer(body.id, list.tools);
logger.info(
`[mcp:api] auto list_tools server=${body.id} count=${list.tools.length}`,
);
} finally {
await close();
}
}
} catch (err) {
logger.warn(
`[mcp:api] auto list_tools failed (deferred to manual refresh) server=${body.id}: ${(err as Error).message}`,
);
}
}
res.json({ ok: true });
});
router.delete('/:id', deps.requireAuth, (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const server = deps.registry.getDecrypted(req.params.id);
if (!server) {
res.status(404).json({ error: 'unknown server' });
return;
}
if (server.ownerId !== userId) {
res.status(403).json({ error: 'forbidden: you do not own this server' });
return;
}
deps.registry.delete(req.params.id);
res.json({ ok: true });
});
router.post('/:id/tools/refresh', deps.requireAuth, async (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const server = deps.registry.getDecrypted(req.params.id);
if (!server) {
res.status(404).json({ error: 'unknown server' });
return;
}
if (server.ownerId !== userId) {
res.status(403).json({ error: 'forbidden: you do not own this server' });
return;
}
const accessToken = server.authKind === 'api_key' && server.staticToken
? server.staticToken
: 'anonymous';
try {
const { client, close } = await createMcpClient(
server,
accessToken,
{
insecureLocalTestMode: deps.insecureLocalTestMode,
callTimeoutMs: 30_000,
allowPrivateAddresses: deps.allowPrivateAddresses,
},
);
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
deps.toolCache.replaceForServer(server.id, list.tools);
res.json({ ok: true, count: list.tools.length });
} finally {
await close();
}
} catch (err) {
logger.warn(
`[mcp:api] user list_tools failed server=${server.id}: ${(err as Error).message}`,
);
res.status(502).json({ error: 'list_tools failed', detail: (err as Error).message });
}
});
return router;
}
+245
View File
@@ -0,0 +1,245 @@
/**
* memory-api.test.ts — unit tests for /api/local/memory router
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createMemoryApi } from './memory-api.js';
import { upsertMemoryEntry } from '../user-folder/memory.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const USER_A = 'user-a';
/**
* App with req.user injected (authenticated).
*/
function makeApp(userId: string, dataDir: string): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user' };
next();
});
app.use('/api/local/memory', createMemoryApi({ dataDir }));
return app;
}
/**
* App with no req.user — simulates missing auth.
*/
function makeUnauthApp(dataDir: string): express.Application {
const app = express();
app.use(express.json());
// No req.user set; authActive defaults to true inside the router
app.use('/api/local/memory', createMemoryApi({ dataDir }));
return app;
}
// ── Setup / Teardown ───────────────────────────────────────────────────────────
describe('Memory API', () => {
let tmpDir: string;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'memory-api-test-'));
app = makeApp(USER_A, tmpDir);
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
// ── GET /entries ─────────────────────────────────────────────────────────────
describe('GET /entries', () => {
it('returns empty entries and null index when no memory exists', async () => {
const res = await request(app).get('/api/local/memory/entries');
expect(res.status).toBe(200);
expect(res.body.entries).toEqual([]);
expect(res.body.index).toBeNull();
});
it('returns parsed entries and index when entries exist', async () => {
// Seed directly via the memory helper
upsertMemoryEntry(tmpDir, USER_A, {
name: 'my-fact',
type: 'user',
description: 'A test fact',
body: 'some body content',
});
const res = await request(app).get('/api/local/memory/entries');
expect(res.status).toBe(200);
const entries = res.body.entries as Array<{ name: string; description: string; type: string; body: string }>;
expect(entries).toHaveLength(1);
expect(entries[0]!.name).toBe('my-fact');
expect(entries[0]!.description).toBe('A test fact');
expect(entries[0]!.type).toBe('user');
expect(entries[0]!.body.trim()).toBe('some body content');
// Index should contain the entry line
expect(typeof res.body.index).toBe('string');
expect(res.body.index).toContain('my-fact');
});
it('returns 401 when the request is unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp).get('/api/local/memory/entries');
expect(res.status).toBe(401);
});
});
// ── PUT /entries/:name ────────────────────────────────────────────────────────
describe('PUT /entries/:name', () => {
it('creates an entry and writes it to disk', async () => {
const res = await request(app)
.put('/api/local/memory/entries/my-note')
.send({ description: 'A useful note', type: 'reference', body: 'Details here.' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.name).toBe('my-note');
// Verify file is on disk
const factPath = join(tmpDir, USER_A, 'memory', 'my-note.md');
expect(existsSync(factPath)).toBe(true);
// Verify GET returns it
const getRes = await request(app).get('/api/local/memory/entries');
expect(getRes.status).toBe(200);
const names = (getRes.body.entries as Array<{ name: string }>).map(e => e.name);
expect(names).toContain('my-note');
});
it('updates an existing entry (upsert)', async () => {
// Create first
await request(app)
.put('/api/local/memory/entries/upsert-me')
.send({ description: 'Old description', type: 'user', body: 'old body' });
// Update
const res = await request(app)
.put('/api/local/memory/entries/upsert-me')
.send({ description: 'New description', type: 'feedback', body: 'new body' });
expect(res.status).toBe(200);
const getRes = await request(app).get('/api/local/memory/entries');
const entry = (getRes.body.entries as Array<{ name: string; description: string; type: string }>)
.find(e => e.name === 'upsert-me');
expect(entry).toBeDefined();
expect(entry!.description).toBe('New description');
expect(entry!.type).toBe('feedback');
});
it('returns 400 + rejected_bad_name for a name that is too long', async () => {
// Build a 65-char name using only URL-safe chars to avoid Express routing weirdness.
// isValidMemoryName rejects names longer than 64 chars.
const longName = 'a'.repeat(64) + 'b'; // 65 chars, all alphanumeric
const res = await request(app)
.put(`/api/local/memory/entries/${longName}`)
.send({ description: 'desc', type: 'user', body: 'body' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_bad_name');
});
it('returns 400 + rejected_unknown_type for invalid type', async () => {
const res = await request(app)
.put('/api/local/memory/entries/valid-name')
.send({ description: 'A description', type: 'bogus-type', body: 'body content' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_unknown_type');
});
it('returns 400 + rejected_body_too_large when body exceeds maxEntryBodyBytes', async () => {
// Default maxEntryBodyBytes is 8192; send 9000 bytes
const bigBody = 'x'.repeat(9000);
const res = await request(app)
.put('/api/local/memory/entries/big-entry')
.send({ description: 'too big', type: 'user', body: bigBody });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_body_too_large');
});
it('returns 400 + rejected_bad_description for multi-line description', async () => {
const res = await request(app)
.put('/api/local/memory/entries/multi-line-desc')
.send({ description: 'line one\nline two', type: 'user', body: 'body' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_bad_description');
});
it('accepts all valid types', async () => {
for (const type of ['user', 'feedback', 'project', 'reference'] as const) {
const res = await request(app)
.put(`/api/local/memory/entries/type-test-${type}`)
.send({ description: `type is ${type}`, type, body: 'body' });
expect(res.status).toBe(200);
}
});
it('returns 401 when unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp)
.put('/api/local/memory/entries/my-note')
.send({ description: 'desc', type: 'user', body: 'body' });
expect(res.status).toBe(401);
});
});
// ── DELETE /entries/:name ─────────────────────────────────────────────────────
describe('DELETE /entries/:name', () => {
it('removes an entry and updates the index', async () => {
// Seed entry
upsertMemoryEntry(tmpDir, USER_A, {
name: 'delete-me',
type: 'project',
description: 'Temporary fact',
body: 'will be deleted',
});
const res = await request(app).delete('/api/local/memory/entries/delete-me');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.name).toBe('delete-me');
// Fact file should be gone from memory dir (moved to trash)
const factPath = join(tmpDir, USER_A, 'memory', 'delete-me.md');
expect(existsSync(factPath)).toBe(false);
// GET should return empty entries
const getRes = await request(app).get('/api/local/memory/entries');
const names = (getRes.body.entries as Array<{ name: string }>).map(e => e.name);
expect(names).not.toContain('delete-me');
});
it('returns 404 for a nonexistent entry', async () => {
const res = await request(app).delete('/api/local/memory/entries/does-not-exist');
expect(res.status).toBe(404);
});
it('returns 404 for an invalid name (do not leak existence)', async () => {
// Name exceeding 64 chars → treated as 404 to not reveal system structure
const res = await request(app).delete(`/api/local/memory/entries/${'z'.repeat(65)}`);
expect(res.status).toBe(404);
});
it('returns 401 when unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp).delete('/api/local/memory/entries/some-entry');
expect(res.status).toBe(401);
});
});
});
+194
View File
@@ -0,0 +1,194 @@
/**
* memory-api.ts — REST router for user memory entries
*
* Mounted at /api/local/memory
*
* Routes:
* GET /entries — list parsed entries + MEMORY.md index
* PUT /entries/:name — upsert entry (frontmatter validation enforced)
* DELETE /entries/:name — remove entry + update index
*
* Auth: all routes require an authenticated user (req.user).
* Owner: each operation scopes to req.user.id — no cross-user access.
*/
import { Router, type Request, type Response } from 'express';
import { join } from 'path';
import { logger } from '../logger.js';
import {
isValidMemoryName,
MEMORY_TYPES,
type MemoryType,
listMemoryEntries,
readMemoryIndexFromDir,
upsertMemoryEntry,
removeMemoryEntry,
} from '../user-folder/memory.js';
import { userMemoryDir } from '../user-folder/paths.js';
import { withUserLock } from '../engine/reflection/user-lock.js';
import { loadConfig } from '../config.js';
// ── Types ──────────────────────────────────────────────────────────────────────
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
// ── Deps ───────────────────────────────────────────────────────────────────────
export interface MemoryApiDeps {
/** Root data dir (same as userFolderRoot / dataDir in the rest of the app). */
dataDir: string;
/** When false (local-dev mode), inject a synthetic 'local' user if req.user absent. */
authActive?: boolean;
}
// ── Factory ────────────────────────────────────────────────────────────────────
export function createMemoryApi(deps: MemoryApiDeps): Router {
const { dataDir } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
// JSON body parser for this router
r.use((_req, _res, next) => {
// body-parser already applied globally only for certain routes; apply here too
next();
});
// ── Auth gate ──────────────────────────────────────────────────────────────
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// ── GET /entries ───────────────────────────────────────────────────────────
r.get('/entries', (req: Request, res: Response) => {
const u = getUser(req)!;
const memDir = userMemoryDir(dataDir, u.id);
try {
const entries = listMemoryEntries(memDir);
const index = readMemoryIndexFromDir(memDir);
res.json({ entries, index });
} catch (err) {
logger.error(`[memory-api] GET /entries failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list memory entries' });
}
});
// ── PUT /entries/:name ─────────────────────────────────────────────────────
// Validation thresholds (name pattern, four-value type, body byte cap) are
// shared with the reflection applier's semantic validator at
// `src/engine/reflection/semantic-validator.ts`. The rejection codes here
// match the ReflectionRejectionCode union so the UI can render either source
// consistently. If you add/rename a code, update both files together.
r.put('/entries/:name', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { name } = req.params;
// Validate name (mirrors semantic-validator's rejected_bad_name path)
if (!isValidMemoryName(name)) {
res.status(400).json({ error: 'rejected_bad_name' });
return;
}
const body = req.body as Record<string, unknown> | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'rejected_bad_request' });
return;
}
const { description, type, body: entryBody } = body as {
description?: unknown;
type?: unknown;
body?: unknown;
};
// Validate description
if (typeof description !== 'string' || description.trim() === '') {
res.status(400).json({ error: 'rejected_bad_description' });
return;
}
if (description.includes('\n') || description.includes('\r')) {
res.status(400).json({ error: 'rejected_bad_description' });
return;
}
// Validate type
if (!MEMORY_TYPES.includes(type as MemoryType)) {
res.status(400).json({ error: 'rejected_unknown_type' });
return;
}
// Validate body
if (typeof entryBody !== 'string') {
res.status(400).json({ error: 'rejected_bad_body' });
return;
}
// Body byte-length cap from config
const cfg = loadConfig();
const maxBodyBytes = cfg.reflection.maxEntryBodyBytes;
if (Buffer.byteLength(entryBody, 'utf-8') > maxBodyBytes) {
res.status(400).json({ error: 'rejected_body_too_large' });
return;
}
try {
const result = await withUserLock(dataDir, u.id, async () => {
return upsertMemoryEntry(dataDir, u.id, {
name,
type: type as MemoryType,
description: description.trim(),
body: entryBody,
});
});
logger.info(`[memory-api] PUT /entries/${name} user=${u.id} path=${result.path}`);
res.json({ ok: true, name, path: result.path });
} catch (err) {
logger.error(`[memory-api] PUT /entries/${name} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to upsert memory entry' });
}
});
// ── DELETE /entries/:name ──────────────────────────────────────────────────
r.delete('/entries/:name', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { name } = req.params;
// Validate name — do not leak existence for invalid names
if (!isValidMemoryName(name)) {
res.status(404).json({ error: 'not_found' });
return;
}
try {
const removed = await withUserLock(dataDir, u.id, async () => {
return removeMemoryEntry(dataDir, u.id, name);
});
if (!removed) {
res.status(404).json({ error: 'not_found' });
return;
}
logger.info(`[memory-api] DELETE /entries/${name} user=${u.id}`);
res.json({ ok: true, name });
} catch (err) {
logger.error(`[memory-api] DELETE /entries/${name} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to delete memory entry' });
}
});
return r;
}
+203
View File
@@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runMigrations } from '../db/migrate.js';
import { NotesRepository } from '../notes/notes-repository.js';
import { NotesService } from '../notes/notes-service.js';
import { createNotesApi } from './notes-api.js';
describe('notes-api', () => {
let tmpRoot: string;
let db: Database.Database;
let service: NotesService;
let auditCalls: any[];
function makeApp(userId: string, orgIds: string[] = []): express.Application {
const app = express();
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user', orgIds };
next();
});
app.use('/api/notes', createNotesApi({ service }));
return app;
}
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'notes-api-test-'));
db = new Database(join(tmpRoot, 'test.db'));
runMigrations(db);
db.prepare(`INSERT INTO users (id, email, name) VALUES ('alice','[email protected]','Alice'),('bob','[email protected]','Bob')`).run();
const repo = new NotesRepository(db);
auditCalls = [];
service = new NotesService({
db, repo, userFolderRoot: tmpRoot,
getUserOrgIds: () => ['team1'],
audit: (action, actor, target) => auditCalls.push({ action, actor, target }),
});
});
afterEach(() => {
db.close();
rmSync(tmpRoot, { recursive: true, force: true });
});
describe('GET /discover', () => {
it('returns visible notes for the consumer', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/discover');
expect(res.status).toBe(200);
expect(res.body.rows.map((r: any) => r.file_name)).toEqual(['pub.md']);
});
it('supports keyword search via q', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\ntitle: kubernetes\nvisibility: public\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/discover?q=kubernetes');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(1);
});
it('paginates', async () => {
for (let i = 0; i < 12; i++) {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: `f${i}.md`, content: `---\nvisibility: public\n---\nbody ${i}` });
}
const res = await request(makeApp('bob')).get('/api/notes/discover?limit=5&offset=5');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(5);
});
it('returns 401 if unauthenticated (authActive=true, default)', async () => {
const app = express();
app.use('/api/notes', createNotesApi({ service }));
const res = await request(app).get('/api/notes/discover');
expect(res.status).toBe(401);
});
it('synthesizes local user when authActive=false (no-auth deployment)', async () => {
// Regression for issue #347: /api/notes/discover returned 401 in
// no-auth deployments because the route had no synthetic-local
// fallback that user-folder-api / dashboard-api already had,
// breaking the Notes panel under User Folder.
db.prepare(`INSERT INTO users (id, email, name) VALUES ('local','[email protected]','Local')`).run();
service.writeNote({ ownerId: 'local', folder: 'cve', fileName: 'mine.md', content: '---\nvisibility: private\n---\nlocal note' });
const app = express();
app.use('/api/notes', createNotesApi({ service, authActive: false }));
const res = await request(app).get('/api/notes/discover?owner_id=me');
expect(res.status, JSON.stringify(res.body)).toBe(200);
const names = res.body.rows.map((r: any) => r.file_name);
expect(names).toContain('mine.md');
});
it('resolves owner_id=me to the caller user.id', async () => {
// Alice writes notes; the FileTree under the Notes tab queries
// /discover?owner_id=me — must resolve to alice, not the literal string "me".
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'a.md', content: '---\nvisibility: private\n---\nmine' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'b.md', content: '---\nvisibility: public\n---\nmine2' });
service.writeNote({ ownerId: 'bob', folder: 'cve', fileName: 'c.md', content: '---\nvisibility: public\n---\nbob' });
const res = await request(makeApp('alice')).get('/api/notes/discover?owner_id=me');
expect(res.status).toBe(200);
const names = res.body.rows.map((r: any) => r.file_name).sort();
expect(names).toEqual(['a.md', 'b.md']);
});
});
describe('GET /file', () => {
it('returns note body for visible note and logs audit', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody content' });
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=cve&file_name=pub.md');
expect(res.status).toBe(200);
expect(res.body.body).toContain('body content');
expect(auditCalls).toHaveLength(1);
expect(auditCalls[0]).toMatchObject({ action: 'read_note', actor: 'bob' });
});
it('returns 404 for private note', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=cve&file_name=priv.md');
expect(res.status).toBe(404);
expect(auditCalls).toHaveLength(0);
});
it('rejects invalid folder/file_name', async () => {
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=../etc&file_name=passwd');
expect(res.status).toBe(400);
});
});
describe('reindex and inject-preview', () => {
it('POST /reindex rebuilds the index for the requester', async () => {
const fs = require('fs');
fs.mkdirSync(join(tmpRoot, 'alice', 'notes', 'cve'), { recursive: true });
fs.writeFileSync(join(tmpRoot, 'alice', 'notes', 'cve', 'foo.md'), '---\nvisibility: public\n---\nbody');
const res = await request(makeApp('alice')).post('/api/notes/reindex?owner_id=me');
expect(res.status).toBe(200);
expect(res.body.indexed).toBe(1);
});
it('GET /inject-preview shows what will be injected', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody A' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'bar.md', content: '---\nvisibility: public\n---\nbody B' });
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'inject', enabled: true });
const res = await request(makeApp('bob')).get('/api/notes/inject-preview');
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(2);
expect(res.body.total_kb).toBeGreaterThan(0);
expect(res.body.budget_kb).toBeDefined();
});
});
describe('subscriptions', () => {
it('GET /subscriptions returns the user\'s subscriptions', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody' });
// Create a subscription via PUT
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
const res = await request(makeApp('bob')).get('/api/notes/subscriptions');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(1);
expect(res.body.rows[0]).toMatchObject({ publisher_user_id: 'alice', folder: 'cve', mode: 'search' });
});
it('PUT /subscriptions creates a subscription if folder is visible', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody' });
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'inject', enabled: true });
expect(res.status).toBe(200);
});
it('PUT /subscriptions rejects if folder has no visible notes', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
expect(res.status).toBe(403);
});
it('PUT /subscriptions rejects invalid mode', async () => {
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'bogus', enabled: true });
expect(res.status).toBe(400);
});
it('DELETE /subscriptions removes a subscription', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody' });
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
const del = await request(makeApp('bob'))
.delete('/api/notes/subscriptions?publisher_user_id=alice&folder=cve');
expect(del.status).toBe(200);
const list = await request(makeApp('bob')).get('/api/notes/subscriptions');
expect(list.body.rows).toHaveLength(0);
});
});
});
+168
View File
@@ -0,0 +1,168 @@
import { Router, Request, Response, NextFunction, json as expressJson } from 'express';
import { NotesService } from '../notes/notes-service.js';
export interface NotesApiDeps {
service: NotesService;
/**
* Whether the bridge wired the auth subsystem (Passport / OAuth /
* sessions). When `false`, requests will not have a populated
* `req.user` because the upstream `requireAuth` middleware was not
* mounted; the route fills in a synthetic `local` user so the notes
* UI works in no-auth single-user mode (mirrors user-folder-api and
* dashboard-api). Defaults to `true` for backwards compatibility
* with the previous (auth-only) call sites.
*/
authActive?: boolean;
}
const NAME_RE = /^[a-zA-Z0-9._-]+$/;
export function createNotesApi(deps: NotesApiDeps): Router {
const router = Router();
const authActive = deps.authActive ?? true;
router.use((req: Request, res: Response, next: NextFunction) => {
if (!authActive && !(req as any).user) {
// No-auth single-user deployment: synthesize a `local` user so
// per-user storage (and the notes index) has a stable owner.
// Real auth flows are unaffected because Passport populates
// req.user before we get here.
//
// orgIds defaults to [] explicitly — NotesService reads
// user.orgIds.length when computing org-scoped visibility, and
// crashes on the undefined access otherwise.
(req as any).user = { id: 'local', role: 'user', orgIds: [] };
}
if (!(req as any).user) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
// `/api/notes` is NOT behind the `/api/local` requireAuth prefix, so it
// must enforce account status itself. Otherwise a disabled/pending user
// holding a still-valid session keeps notes access (parity with
// requireAuth in auth.ts). The synthetic no-auth `local` user has no
// status field and is unaffected.
const status = (req as any).user.status;
if (authActive && status !== undefined && status !== 'active') {
res.status(403).json({ error: 'account is not active' });
return;
}
next();
});
// Size-limited JSON parser replaces the hand-rolled unbounded reader below.
router.use(expressJson({ limit: '64kb' }));
router.get('/discover', (req, res) => {
const user = (req as any).user;
const rawOwnerId = req.query.owner_id ? String(req.query.owner_id) : undefined;
// `me` is a UI-convenient alias for the caller's own id (matches /reindex).
// Without this alias the FileTree under the Notes tab silently shows zero
// entries when it queries `?owner_id=me`, because the DB stores rows under
// the real user.id and never matches the literal string "me".
const ownerId = rawOwnerId === 'me' ? user.id : rawOwnerId;
const folder = req.query.folder ? String(req.query.folder) : undefined;
const q = req.query.q ? String(req.query.q) : undefined;
const limit = req.query.limit ? Math.max(1, Math.min(200, parseInt(String(req.query.limit), 10) || 50)) : 50;
const offset = req.query.offset ? Math.max(0, parseInt(String(req.query.offset), 10) || 0) : 0;
try {
const rows = deps.service.discoverNotes({ user, ownerId, folder, q, limit, offset });
res.json({ rows });
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
router.get('/file', (req, res) => {
const user = (req as any).user;
const rawOwnerId = String(req.query.owner_id ?? '');
const ownerId = rawOwnerId === 'me' ? user.id : rawOwnerId;
const folder = String(req.query.folder ?? '');
const fileName = String(req.query.file_name ?? '');
if (!NAME_RE.test(folder) || !NAME_RE.test(fileName)) {
res.status(400).json({ error: 'invalid folder or file_name' });
return;
}
try {
const out = deps.service.getCrossUserNote({ user, ownerId, folder, fileName });
if (!out) {
res.status(404).json({ error: 'not found or no permission' });
return;
}
res.json({ fm: out.fm, body: out.body, content: out.content });
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
router.get('/subscriptions', (req, res) => {
const user = (req as any).user;
const rows = deps.service.listSubscriptions(user.id);
res.json({ rows });
});
router.put('/subscriptions', (req, res) => {
const user = (req as any).user;
const body = (req as any).body ?? {};
const publisherUserId = String(body.publisher_user_id ?? '');
const folder = String(body.folder ?? '');
const mode = String(body.mode ?? '');
const enabled = body.enabled === false ? 0 : 1;
if (!publisherUserId || !NAME_RE.test(folder)) {
res.status(400).json({ error: 'invalid publisher_user_id or folder' });
return;
}
if (mode !== 'search' && mode !== 'inject') {
res.status(400).json({ error: 'invalid mode (must be search or inject)' });
return;
}
try {
deps.service.upsertSubscription({
consumerUser: user,
publisherUserId, folder, mode, enabled,
});
res.json({ ok: true });
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('no visible notes')) {
res.status(403).json({ error: msg });
} else {
res.status(400).json({ error: msg });
}
}
});
router.delete('/subscriptions', (req, res) => {
const user = (req as any).user;
const publisherUserId = String(req.query.publisher_user_id ?? '');
const folder = String(req.query.folder ?? '');
if (!publisherUserId || !NAME_RE.test(folder)) {
res.status(400).json({ error: 'invalid publisher_user_id or folder' });
return;
}
deps.service.deleteSubscription({ consumerUserId: user.id, publisherUserId, folder });
res.json({ ok: true });
});
router.post('/reindex', (req, res) => {
const user = (req as any).user;
const ownerIdParam = req.query.owner_id ? String(req.query.owner_id) : 'me';
const ownerId = ownerIdParam === 'me' ? user.id : ownerIdParam;
if (ownerId !== user.id && user.role !== 'admin') {
res.status(403).json({ error: 'forbidden: only owner or admin can reindex' });
return;
}
try {
const stats = deps.service.reindex(ownerId);
res.json({ ok: true, ...stats });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/inject-preview', (req, res) => {
const user = (req as any).user;
const preview = deps.service.injectPreview(user);
res.json(preview);
});
return router;
}
+250
View File
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import express, { type Request, type Response, type NextFunction } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { VapidKeyStore } from '../vapid-store.js';
import { PushService } from '../push-service.js';
import { mountNotificationsApi, resetRateLimitsForTest } from './notifications-api.js';
// Mock web-push so the test never makes real network calls.
vi.mock('web-push', () => {
const sendNotification = vi.fn().mockResolvedValue({ statusCode: 201 });
return {
default: {
sendNotification,
setVapidDetails: vi.fn(),
generateVAPIDKeys: () => ({
publicKey: 'BPubKeyMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMa',
privateKey: 'privateKeyMaterialMaterialMaterialMaterial',
}),
},
};
});
const SUBJECT = 'https://aao.example/';
function buildApp(opts: {
repo: Repository;
pushService: PushService | null;
vapidStore: VapidKeyStore | null;
userId: string;
}): express.Application {
const app = express();
const requireAuth = (req: Request, _res: Response, next: NextFunction) => {
(req as unknown as { user: { id: string; role: string } }).user = {
id: opts.userId,
role: 'user',
};
next();
};
mountNotificationsApi(app, {
repo: opts.repo,
pushService: opts.pushService,
vapidStore: opts.vapidStore,
requireAuth,
});
return app;
}
describe('/api/notifications/*', () => {
let tempDir = '';
let repo: Repository;
let store: VapidKeyStore;
let service: PushService;
let app: express.Application;
let userId = '';
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-notif-api-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
store = new VapidKeyStore(join(tempDir, 'vapid.json'), join(tempDir, 'vapid-history'));
store.loadOrGenerate(SUBJECT);
service = new PushService(repo, store);
const user = repo.createUser({ email: '[email protected]', name: 'u', role: 'user', status: 'active' });
userId = user.id;
app = buildApp({ repo, pushService: service, vapidStore: store, userId });
resetRateLimitsForTest();
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
describe('GET /vapid-public-key', () => {
it('returns public key + keyId, never private key', async () => {
const r = await request(app).get('/api/notifications/vapid-public-key');
expect(r.status).toBe(200);
expect(r.body.publicKey).toBeTruthy();
expect(r.body.keyId).toBeTruthy();
expect(r.body).not.toHaveProperty('privateKey');
});
it('503 when push disabled (no service)', async () => {
const appOff = buildApp({ repo, pushService: null, vapidStore: null, userId });
const r = await request(appOff).get('/api/notifications/vapid-public-key');
expect(r.status).toBe(503);
});
});
describe('subscriptions lifecycle', () => {
const validBody = {
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
p256dh: 'pubkey',
auth: 'authsec',
userAgent: 'Chrome on Pixel',
};
it('POST → GET → DELETE full cycle', async () => {
const post = await request(app).post('/api/notifications/subscriptions').send(validBody);
expect(post.status).toBe(200);
const id = post.body.id;
const list = await request(app).get('/api/notifications/subscriptions');
expect(list.status).toBe(200);
expect(list.body.subscriptions).toHaveLength(1);
expect(list.body.subscriptions[0]).not.toHaveProperty('p256dh');
expect(list.body.subscriptions[0]).not.toHaveProperty('auth');
expect(list.body.subscriptions[0]).not.toHaveProperty('endpoint');
expect(list.body.subscriptions[0].endpointHost).toBe('fcm.googleapis.com');
const del = await request(app).delete(`/api/notifications/subscriptions/${id}`);
expect(del.status).toBe(200);
const list2 = await request(app).get('/api/notifications/subscriptions');
expect(list2.body.subscriptions).toHaveLength(0);
});
it('rejects non-https endpoint', async () => {
const r = await request(app).post('/api/notifications/subscriptions').send({
...validBody, endpoint: 'http://insecure.example/x',
});
expect(r.status).toBe(400);
});
it('rejects missing p256dh / auth', async () => {
const r1 = await request(app).post('/api/notifications/subscriptions').send({
endpoint: validBody.endpoint, auth: 'a',
});
expect(r1.status).toBe(400);
const r2 = await request(app).post('/api/notifications/subscriptions').send({
endpoint: validBody.endpoint, p256dh: 'p',
});
expect(r2.status).toBe(400);
});
it('DELETE: cannot delete another user\'s subscription (returns 404)', async () => {
// create another user with a subscription
const other = repo.createUser({ email: '[email protected]', name: 'b', role: 'user', status: 'active' });
const otherSub = repo.upsertPushSubscription({
userId: other.id,
endpoint: 'https://fcm.googleapis.com/fcm/send/other',
p256dh: 'p', auth: 'a', vapidKeyId: store.getCurrent().keyId,
});
const del = await request(app).delete(`/api/notifications/subscriptions/${otherSub.id}`);
expect(del.status).toBe(404);
// still there in DB
expect(repo.getPushSubscriptionById(otherSub.id)).not.toBeNull();
});
it('endpoint UNIQUE: re-POST from a different user transfers ownership', async () => {
// Seed userA's existing subscription with the same endpoint
const other = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
repo.upsertPushSubscription({
userId: other.id, endpoint: validBody.endpoint,
p256dh: 'oldp', auth: 'olda', vapidKeyId: store.getCurrent().keyId,
});
// Current user (userId) re-subscribes the same endpoint
const r = await request(app).post('/api/notifications/subscriptions').send(validBody);
expect(r.status).toBe(200);
const meSubs = repo.listPushSubscriptionsForUser(userId);
const otherSubs = repo.listPushSubscriptionsForUser(other.id);
expect(meSubs).toHaveLength(1);
expect(otherSubs).toHaveLength(0);
});
it('rate limit: 11th subscribe in same hour returns 429', async () => {
for (let i = 0; i < 10; i++) {
const r = await request(app).post('/api/notifications/subscriptions').send({
...validBody, endpoint: `https://fcm.googleapis.com/fcm/send/${i}`,
});
expect(r.status).toBe(200);
}
const r11 = await request(app).post('/api/notifications/subscriptions').send({
...validBody, endpoint: 'https://fcm.googleapis.com/fcm/send/11',
});
expect(r11.status).toBe(429);
expect(r11.body.retryAfter).toBeGreaterThan(0);
});
});
describe('preferences', () => {
it('GET returns defaults for new users', async () => {
const r = await request(app).get('/api/notifications/preferences');
expect(r.status).toBe(200);
expect(r.body.enabled).toBe(true);
expect(r.body.events).toEqual({
running: true, succeeded: true, failed: true, waiting_human: true,
});
expect(r.body.includeDetails).toBe(false);
expect(r.body.v1Migrated).toBe(false);
});
it('PUT applies partial update', async () => {
const r = await request(app).put('/api/notifications/preferences').send({
enabled: false, events: { succeeded: false },
});
expect(r.status).toBe(200);
expect(r.body.enabled).toBe(false);
expect(r.body.events.succeeded).toBe(false);
expect(r.body.events.running).toBe(true);
});
it('PUT rejects non-boolean fields', async () => {
const r = await request(app).put('/api/notifications/preferences').send({
enabled: 'yes',
});
expect(r.status).toBe(400);
});
it('migrate-from-localstorage: first call applies, second returns 409', async () => {
const r1 = await request(app)
.post('/api/notifications/preferences/migrate-from-localstorage')
.send({ enabled: true, events: { running: false } });
expect(r1.status).toBe(200);
expect(r1.body.prefs.v1Migrated).toBe(true);
expect(r1.body.prefs.events.running).toBe(false);
const r2 = await request(app)
.post('/api/notifications/preferences/migrate-from-localstorage')
.send({ enabled: false });
expect(r2.status).toBe(409);
});
});
describe('test endpoint', () => {
it('returns 200 ok when push enabled', async () => {
const r = await request(app).post('/api/notifications/test');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
});
it('503 when push disabled', async () => {
const appOff = buildApp({ repo, pushService: null, vapidStore: null, userId });
const r = await request(appOff).post('/api/notifications/test');
expect(r.status).toBe(503);
});
it('rate limit: 6th test in same hour returns 429', async () => {
for (let i = 0; i < 5; i++) {
const r = await request(app).post('/api/notifications/test');
expect(r.status).toBe(200);
}
const r6 = await request(app).post('/api/notifications/test');
expect(r6.status).toBe(429);
});
});
});
+291
View File
@@ -0,0 +1,291 @@
import { type Application, type Request, type Response, type NextFunction } from 'express';
import express from 'express';
import { logger } from '../logger.js';
import type { Repository, NotifyEventType, NotificationPrefsUpdate } from '../db/repository.js';
import type { VapidKeyStore } from '../vapid-store.js';
import type { PushService } from '../push-service.js';
/**
* `/api/notifications/*` routes for Web Push V2.
* Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
*
* When `pushService` is null (push.enabled === false), all POST/DELETE/test
* endpoints return 503; read endpoints still work so the UI can show
* "管理者により無効化されています".
*/
type AuthedUser = { id: string; role?: string };
function getUser(req: Request): AuthedUser | undefined {
return (req as unknown as { user?: AuthedUser }).user;
}
function requireUser(req: Request, res: Response): AuthedUser | null {
const user = getUser(req);
if (!user) {
res.status(401).json({ error: 'auth required' });
return null;
}
return user;
}
// ── Per-user in-memory sliding-window rate limiter ─────────────────────
// Maps `${key}:${userId}` → array of recent request timestamps (ms).
// Window is fixed at 1 hour; sufficient for low-frequency notification ops.
const rateBuckets = new Map<string, number[]>();
const ONE_HOUR_MS = 60 * 60 * 1000;
function rateLimit(key: string, maxPerHour: number) {
return (req: Request, res: Response, next: NextFunction): void => {
const user = getUser(req);
if (!user) {
res.status(401).json({ error: 'auth required' });
return;
}
const bucketKey = `${key}:${user.id}`;
const now = Date.now();
const cutoff = now - ONE_HOUR_MS;
const bucket = (rateBuckets.get(bucketKey) ?? []).filter(t => t > cutoff);
if (bucket.length >= maxPerHour) {
res.status(429).json({
error: 'rate limit exceeded',
retryAfter: Math.ceil((bucket[0]! + ONE_HOUR_MS - now) / 1000),
});
return;
}
bucket.push(now);
rateBuckets.set(bucketKey, bucket);
next();
};
}
/** Test hook — only used by unit tests. */
export function resetRateLimitsForTest(): void {
rateBuckets.clear();
}
// ── Input validation ──────────────────────────────────────────────────
function validateSubscriptionInput(body: unknown): {
endpoint: string;
p256dh: string;
auth: string;
userAgent?: string;
} | { error: string } {
if (!body || typeof body !== 'object') return { error: 'body required' };
const b = body as Record<string, unknown>;
if (typeof b.endpoint !== 'string') return { error: 'endpoint required' };
if (!b.endpoint.startsWith('https://')) return { error: 'endpoint must be https' };
if (b.endpoint.length > 2048) return { error: 'endpoint too long' };
if (typeof b.p256dh !== 'string' || b.p256dh.length === 0 || b.p256dh.length > 200) {
return { error: 'p256dh must be 1..200 chars' };
}
if (typeof b.auth !== 'string' || b.auth.length === 0 || b.auth.length > 200) {
return { error: 'auth must be 1..200 chars' };
}
const ua = typeof b.userAgent === 'string' ? b.userAgent.slice(0, 200) : undefined;
return { endpoint: b.endpoint, p256dh: b.p256dh, auth: b.auth, ...(ua ? { userAgent: ua } : {}) };
}
function validatePrefsInput(body: unknown): NotificationPrefsUpdate | { error: string } {
if (!body || typeof body !== 'object') return { error: 'body required' };
const b = body as Record<string, unknown>;
const update: NotificationPrefsUpdate = {};
if (b.enabled !== undefined) {
if (typeof b.enabled !== 'boolean') return { error: 'enabled must be boolean' };
update.enabled = b.enabled;
}
if (b.events !== undefined) {
if (!b.events || typeof b.events !== 'object') return { error: 'events must be object' };
const events = b.events as Record<string, unknown>;
const eventUpdate: Partial<Record<NotifyEventType, boolean>> = {};
for (const key of ['running', 'succeeded', 'failed', 'waiting_human'] as const) {
if (events[key] !== undefined) {
if (typeof events[key] !== 'boolean') return { error: `events.${key} must be boolean` };
eventUpdate[key] = events[key] as boolean;
}
}
update.events = eventUpdate as Record<NotifyEventType, boolean>;
}
if (b.includeDetails !== undefined) {
if (typeof b.includeDetails !== 'boolean') return { error: 'includeDetails must be boolean' };
update.includeDetails = b.includeDetails;
}
return update;
}
// ── Public DTOs (do NOT leak p256dh / auth / privateKey / etc.) ────────
function toPublicSubscription(sub: {
id: string;
endpoint: string;
userAgent: string | null;
createdAt: string;
lastSuccessAt: string | null;
lastFailureAt: string | null;
failureCount: number;
}) {
return {
id: sub.id,
// Truncate endpoint to scheme + host for UI display; full URL is sensitive.
endpointHost: (() => {
try { return new URL(sub.endpoint).host; } catch { return 'unknown'; }
})(),
userAgent: sub.userAgent,
createdAt: sub.createdAt,
lastSuccessAt: sub.lastSuccessAt,
lastFailureAt: sub.lastFailureAt,
failureCount: sub.failureCount,
};
}
// ── Mount ──────────────────────────────────────────────────────────────
export interface NotificationsApiDeps {
repo: Repository;
pushService: PushService | null;
vapidStore: VapidKeyStore | null;
/** Plugged in by server.ts when auth is active; identity transform otherwise. */
requireAuth: (req: Request, res: Response, next: NextFunction) => void;
}
export function mountNotificationsApi(app: Application, deps: NotificationsApiDeps): void {
const { repo, pushService, vapidStore, requireAuth } = deps;
const json = express.json({ limit: '64kb' });
// GET /vapid-public-key — always 200 when push is enabled; required before subscribe.
app.get('/api/notifications/vapid-public-key', requireAuth, (req, res) => {
const user = requireUser(req, res); if (!user) return;
if (!pushService || !vapidStore) {
res.status(503).json({ error: 'push disabled' });
return;
}
const k = vapidStore.getCurrent();
res.json({ publicKey: k.publicKey, keyId: k.keyId });
});
// GET /subscriptions — caller's own devices.
app.get('/api/notifications/subscriptions', requireAuth, (req, res) => {
const user = requireUser(req, res); if (!user) return;
const subs = repo.listPushSubscriptionsForUser(user.id);
res.json({ subscriptions: subs.map(toPublicSubscription) });
});
// POST /subscriptions — register/upsert (endpoint UNIQUE moves ownership).
app.post(
'/api/notifications/subscriptions',
requireAuth,
json,
rateLimit('push:subscribe', 10),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
if (!pushService || !vapidStore) {
res.status(503).json({ error: 'push disabled' });
return;
}
const parsed = validateSubscriptionInput(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const current = vapidStore.getCurrent();
const { id } = repo.upsertPushSubscription({
userId: user.id,
endpoint: parsed.endpoint,
p256dh: parsed.p256dh,
auth: parsed.auth,
userAgent: parsed.userAgent ?? null,
vapidKeyId: current.keyId,
});
res.json({ id });
},
);
// DELETE /subscriptions/:id — only delete your own.
app.delete(
'/api/notifications/subscriptions/:id',
requireAuth,
rateLimit('push:unsubscribe', 30),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
const sub = repo.getPushSubscriptionById(req.params.id!);
if (!sub || sub.userId !== user.id) {
res.status(404).json({ error: 'not found' });
return;
}
repo.deletePushSubscription(sub.id);
res.json({ ok: true });
},
);
// GET /preferences — auto-creates a default row on first access.
app.get('/api/notifications/preferences', requireAuth, (req, res) => {
const user = requireUser(req, res); if (!user) return;
res.json(repo.getUserNotificationPrefs(user.id));
});
// PUT /preferences — partial update.
app.put(
'/api/notifications/preferences',
requireAuth,
json,
rateLimit('push:prefs', 30),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
const parsed = validatePrefsInput(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
repo.upsertUserNotificationPrefs(user.id, parsed);
res.json(repo.getUserNotificationPrefs(user.id));
},
);
// POST /preferences/migrate-from-localstorage — one-shot V1 → V2.
app.post(
'/api/notifications/preferences/migrate-from-localstorage',
requireAuth,
json,
rateLimit('push:migrate', 3),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
const parsed = validatePrefsInput(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const flipped = repo.markV1MigrationComplete(user.id);
if (!flipped) {
res.status(409).json({ error: 'already migrated' });
return;
}
repo.upsertUserNotificationPrefs(user.id, parsed);
res.json({ ok: true, prefs: repo.getUserNotificationPrefs(user.id) });
},
);
// POST /test — send a test push.
app.post(
'/api/notifications/test',
requireAuth,
rateLimit('push:test', 5),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
if (!pushService) {
res.status(503).json({ error: 'push disabled' });
return;
}
pushService.enqueue({
event: 'succeeded',
taskId: 0,
taskTitle: 'テスト通知',
pieceName: 'V2 Web Push 動作確認',
ownerId: user.id,
});
res.json({ ok: true });
},
);
logger.info('[notifications-api] mounted (/api/notifications/*)');
}
+170
View File
@@ -0,0 +1,170 @@
import { Router } from 'express';
import httpProxy from 'http-proxy';
const { createProxyServer } = httpProxy;
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
import express from 'express';
import type { Server } from 'http';
import type { SessionManager, BrowserSession } from '../engine/browser-session.js';
import type { UpgradeAuthChecker } from './auth.js';
import { logger } from '../logger.js';
/**
* noVNC upgrade を許可するかの判定コールバック。
* server.ts が Repository を見て構築する。novnc-proxy 側は Repository を
* 知らずに済ませるための薄いインターフェース。
*/
export type NovncSessionAuthorizer = (
session: BrowserSession,
user: Express.User,
) => Promise<boolean>;
const __dirname = dirname(fileURLToPath(import.meta.url));
/** vendor/noVNC の Web 配布物 (vnc.html を含む) ディレクトリ */
function novncStaticDir(): string {
return resolve(__dirname, '../../vendor/noVNC');
}
/**
* vendor/noVNC/vnc.html が配置済みかをチェックする。
* Browser タブの iframe が `/novnc/vnc.html` を読みに行くので、
* これが無いと express.static が 404 を返し catch-all ハンドラが
* `{"error":"Not found"}` を返してしまう。
* Docker / 新規セットアップ時の取り違いを早めに検知するため、
* API 層と起動時ログの両方で参照する。
*/
export function isNovncStaticInstalled(): boolean {
return existsSync(resolve(novncStaticDir(), 'vnc.html'));
}
/**
* noVNC クライアント URL を生成するヘルパー(全箇所で統一使用)。
*
* `path` は **絶対パス** (`/novnc/<sid>/websockify`) で渡す必要がある:
* - noVNC v1.6.0 は `new URL(path, location.href)` で WebSocket URL を組み立てる。
* vnc.html 自体が `/novnc/vnc.html` 配下にあるため、相対パス
* (`novnc/<sid>/websockify`) を渡すと `/novnc/novnc/<sid>/websockify` と
* `/novnc/` が二重になり接続できなくなる。
* - noVNC v1.5.0 系は単純文字列連結 (`/${path}`) で組み立てるため、絶対パスを渡すと
* `//novnc/...` と先頭スラッシュが二重になる。Express の upgrade ハンドラ側で
* `^\/+novnc\/` と寛容にマッチさせて両系統を吸収する。
*/
export function buildNovncPath(sessionId: string): string {
return `/novnc/vnc.html?path=/novnc/${sessionId}/websockify&autoconnect=true&resize=scale`;
}
export function createNovncRouter(): Router {
const router = Router();
if (!isNovncStaticInstalled()) {
logger.warn(
'[novnc-proxy] vendor/noVNC/vnc.html が見つかりません。Browser タブの iframe で 404 が出ます。' +
' scripts/setup-novnc.sh を実行するか、Dockerfile で noVNC tarball を展開してください。',
);
}
router.use(express.static(novncStaticDir()));
return router;
}
/**
* noVNC WebSocket プロキシをセットアップする。
*
* セキュリティ (auth 有効時):
* - kind === 'pool' (CAPTCHA Pool): user.role === 'admin' のみ通す
* - kind === 'task' (Task Session): authorizeSession コールバックでタスク
* visibility を判定し、見られるユーザーだけ通す
* - 旧モデル (kind 未設定 / userId 直接マッチ): 従来どおり owner / admin
* - authenticateUpgrade 未設定 (dev モード) は session 存在確認だけで通す
*/
export function setupNovncWebSocketProxy(
server: Server,
getSessionManager: () => SessionManager | null,
authenticateUpgrade?: UpgradeAuthChecker,
authorizeSession?: NovncSessionAuthorizer,
): void {
const proxy = createProxyServer({ ws: true });
proxy.on('error', (err, _req, res) => {
logger.warn(`[novnc-proxy] WebSocket proxy error: ${err.message}`);
// res is a net.Socket for WebSocket upgrades, not http.ServerResponse
if (res && 'writeHead' in res && typeof res.writeHead === 'function') {
try { res.writeHead(502); res.end(); } catch {}
} else if (res && 'destroy' in res && typeof res.destroy === 'function') {
res.destroy();
}
});
server.on('upgrade', (req, socket, head) => {
const url = req.url ?? '';
// Match /novnc/:sessionId/websockify (先頭スラッシュ二重 //novnc/... も許容)
// 旧 noVNC (v1.5.x) は単純文字列連結で URL を組むため、絶対パスを `path` に
// 渡すと先頭が `//` で来る。新 (v1.6.0+) は `new URL()` で正規化して `/` 1個。
const match = url.match(/^\/+novnc\/([^/]+)\/websockify/);
if (!match) return; // Let other upgrade handlers (if any) handle it
const sessionId = match[1]!;
const sm = getSessionManager();
if (!sm) {
logger.warn(`[novnc-proxy] SessionManager not available, rejecting WebSocket for ${sessionId}`);
socket.destroy();
return;
}
const session = sm.getSession(sessionId);
if (!session) {
logger.warn(`[novnc-proxy] Unknown session ${sessionId}, rejecting WebSocket`);
socket.destroy();
return;
}
const performProxy = (): void => {
proxy.ws(req, socket, head, {
target: `http://127.0.0.1:${session.novncPort}`,
});
};
if (!authenticateUpgrade) {
// 認証無効モード: 従来どおり session 存在確認のみで接続を許可
performProxy();
return;
}
// 認証有効モード: cookie からユーザーを解決して session の種別ごとに認可
authenticateUpgrade(req).then(async (user) => {
if (!user) {
logger.warn(`[novnc-proxy] Unauthenticated WebSocket attempt for session ${sessionId}, rejecting`);
socket.destroy();
return;
}
let allowed: boolean;
if (authorizeSession) {
// 新モデル: pool / task に応じて authorizeSession で判定
try {
allowed = await authorizeSession(session, user);
} catch (err) {
logger.warn(`[novnc-proxy] authorizeSession threw for session ${sessionId}: ${(err as Error).message}, rejecting`);
socket.destroy();
return;
}
} else {
// 旧モデル fallback: owner-or-admin
const isOwner = session.userId === user.id;
const isAdmin = user.role === 'admin';
allowed = isOwner || isAdmin;
}
if (!allowed) {
logger.warn(`[novnc-proxy] User ${user.id} (role=${user.role}) denied access to session ${sessionId} (kind=${session.kind} taskId=${session.taskId ?? '-'} owner=${session.userId ?? '-'})`);
socket.destroy();
return;
}
performProxy();
}).catch((err) => {
logger.warn(`[novnc-proxy] Auth check failed for session ${sessionId}: ${(err as Error).message}`);
socket.destroy();
});
});
}
+499
View File
@@ -0,0 +1,499 @@
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { mountPiecesApi } from './pieces-api.js';
function makeGeneralPieceYaml(): string {
return [
'name: general',
'description: 汎用タスク',
'max_movements: 25',
'initial_movement: understand',
'movements:',
' - name: understand',
' edit: false',
' persona: analyst',
' instruction: |',
' タスクを確認する。',
' allowed_tools: [Read, Glob]',
' default_next: execute',
' rules:',
' - condition: 方針が立った',
' next: execute',
' - name: execute',
' edit: true',
' persona: worker',
' instruction: |',
' 作業を実行する。',
' allowed_tools: [Read, Write]',
' default_next: COMPLETE',
' rules:',
' - condition: 完了',
' next: COMPLETE',
].join('\n');
}
function makeMinimalPieceYaml(name: string, description = 'x'): string {
return [
`name: ${name}`,
`description: ${description}`,
'max_movements: 1',
'initial_movement: only',
'movements:',
' - name: only',
' edit: false',
' persona: p',
' instruction: i',
' allowed_tools: [Read]',
' default_next: COMPLETE',
' rules: []',
].join('\n');
}
describe('Pieces API (no auth — legacy behavior)', () => {
let app: express.Application;
let piecesDir: string;
beforeEach(() => {
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-'));
piecesDir = join(tempDir, 'pieces');
mkdirSync(piecesDir);
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
app = express();
app.use(express.json());
mountPiecesApi(app, { piecesDir });
});
it('GET /api/pieces returns piece list', async () => {
const res = await request(app).get('/api/pieces');
expect(res.status).toBe(200);
expect(res.body.pieces).toHaveLength(1);
expect(res.body.pieces[0].name).toBe('general');
expect(res.body.pieces[0].source).toBe('builtin');
expect(res.body.pieces[0].custom).toBe(false);
});
it('GET /api/pieces/:name returns full piece', async () => {
const res = await request(app).get('/api/pieces/general');
expect(res.status).toBe(200);
expect(res.body.piece.name).toBe('general');
expect(res.body.piece.movements).toHaveLength(2);
expect(res.body.source).toBe('builtin');
});
it('GET /api/pieces/:name returns 404 for unknown', async () => {
const res = await request(app).get('/api/pieces/nonexistent');
expect(res.status).toBe(404);
});
it('PUT /api/pieces/:name updates piece', async () => {
const res = await request(app)
.put('/api/pieces/general')
.send({
name: 'general',
description: '更新済み',
max_movements: 30,
initial_movement: 'understand',
movements: [
{ name: 'understand', edit: false, persona: 'analyst', instruction: 'テスト', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('POST /api/pieces creates new piece', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'custom',
description: 'カスタム',
max_movements: 10,
initial_movement: 'work',
movements: [
{ name: 'work', edit: true, persona: 'worker', instruction: '作業する', allowed_tools: ['Read', 'Write'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(201);
expect(res.body.ok).toBe(true);
});
it('POST /api/pieces rejects duplicate name', async () => {
const res = await request(app)
.post('/api/pieces')
.send({ name: 'general', description: 'x', max_movements: 1, initial_movement: 'a', movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }] });
expect(res.status).toBe(409);
});
it('POST /api/pieces rejects rules[].next: COMPLETE (Phase 6b)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'phase6b-reject',
description: 'should be rejected',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
rules: [{ condition: 'ok', next: 'COMPLETE' }] },
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/rules\[\]\.next cannot be "COMPLETE"/);
});
it('POST /api/pieces accepts default_next: COMPLETE (engine-internal sentinel)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'phase6b-default-ok',
description: 'default_next is fine',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(201);
});
// Phase 4: per-movement SSH connection allowlist validation.
it('POST /api/pieces rejects SshExec without allowed_ssh_connections', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-missing-allowlist',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/allowed_ssh_connections is required/);
});
it('POST /api/pieces accepts SshExec with UUID allowlist', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-uuid-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces accepts SshExec with ["*"] wildcard', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-wildcard-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['*'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces accepts SshExec with empty allowlist (explicit deny)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-empty-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: [],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces rejects allowed_ssh_connections with bad format', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-bad-format',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['BAD-NOT-LOWERCASE'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/must be '\*' or a lowercase hex/);
});
it('POST /api/pieces rejects non-array allowed_ssh_connections', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-non-array',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: 'not-an-array',
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/must be an array/);
});
it('POST /api/pieces accepts allowed_ssh_connections without SSH tools (no-op)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-noop',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('DELETE /api/pieces/:name deletes piece', async () => {
await request(app).post('/api/pieces').send({
name: 'deleteme', description: 'x', max_movements: 1, initial_movement: 'a',
movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }],
});
const res = await request(app).delete('/api/pieces/deleteme');
expect(res.status).toBe(200);
});
it('DELETE /api/pieces/general is forbidden', async () => {
const res = await request(app).delete('/api/pieces/general');
expect(res.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// Auth-aware tests (per-user custom pieces + non-admin write authz)
// ---------------------------------------------------------------------------
type UserShape = { id: string; role: 'admin' | 'user' };
function makeAuthApp(piecesDir: string, userPiecesRootDir: string, user: UserShape | null): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
if (user) (req as any).user = user;
next();
});
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
return app;
}
describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
let piecesDir: string;
let userPiecesRootDir: string;
beforeEach(() => {
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-auth-'));
piecesDir = join(tempDir, 'pieces');
userPiecesRootDir = join(tempDir, 'users');
mkdirSync(piecesDir);
mkdirSync(userPiecesRootDir);
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'built-in chat'));
});
it('GET /api/pieces returns built-ins for any authenticated non-admin', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
expect(res.body.pieces.map((p: any) => p.name).sort()).toEqual(['chat', 'general']);
for (const p of res.body.pieces) {
expect(p.source).toBe('builtin');
expect(p.custom).toBe(false);
}
});
it('GET /api/pieces merges caller\'s user-custom pieces (own only)', async () => {
// Alice has my-tool, Bob has bob-tool
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-tool.yaml'), makeMinimalPieceYaml('my-tool', "alice's piece"));
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-tool.yaml'), makeMinimalPieceYaml('bob-tool', "bob's piece"));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
expect(Object.keys(byName).sort()).toEqual(['chat', 'general', 'my-tool']);
expect(byName['my-tool'].source).toBe('user-custom');
expect(byName['my-tool'].ownerId).toBe('alice');
expect(byName['my-tool'].custom).toBe(true);
// Bob's piece must not appear for Alice
expect(byName['bob-tool']).toBeUndefined();
});
it("GET /api/pieces — user-custom shadows built-in with the same name", async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'general.yaml'), makeMinimalPieceYaml('general', 'alice override'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
const general = res.body.pieces.find((p: any) => p.name === 'general');
expect(general.source).toBe('user-custom');
expect(general.description).toBe('alice override');
});
it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.post('/api/pieces')
.send({
name: 'alice-custom',
description: 'alice piece',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(201);
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'alice-custom.yaml'))).toBe(true);
// Built-in dir untouched
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
});
it('POST /api/pieces by admin writes to piecesDir (legacy behavior)', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
.post('/api/pieces')
.send({
name: 'admin-piece',
description: 'admin piece',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(201);
expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(true);
});
it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/general')
.send({
name: 'general',
description: 'should not write',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(403);
});
it('PUT /api/pieces/:name on own user-custom by owner returns 200', async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-piece.yaml'), makeMinimalPieceYaml('my-piece', 'v1'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/my-piece')
.send({
name: 'my-piece',
description: 'v2',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(200);
});
it("PUT /api/pieces/:name — non-admin cannot edit another user's piece", async () => {
// Bob has a piece. Alice tries to edit it via the same name.
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-only.yaml'), makeMinimalPieceYaml('bob-only', 'bob'));
// Alice doesn't have bob-only — the request 404s (she can't see it), proving isolation.
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/bob-only')
.send({
name: 'bob-only',
description: 'hijacked',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(404);
});
it('DELETE /api/pieces/:name on built-in by non-admin returns 403', async () => {
// Use a deletable built-in (not general/chat which are protected separately)
writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'extra'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.delete('/api/pieces/extra');
expect(res.status).toBe(403);
expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
});
it('DELETE /api/pieces/:name on own user-custom by owner returns 200', async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'), makeMinimalPieceYaml('goner', 'gone'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.delete('/api/pieces/goner');
expect(res.status).toBe(200);
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
});
it('admin can edit and delete built-in pieces', async () => {
writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
name: 'admin-target',
description: 'after',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(putRes.status).toBe(200);
const delRes = await request(adminApp).delete('/api/pieces/admin-target');
expect(delRes.status).toBe(200);
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(false);
});
});
+356
View File
@@ -0,0 +1,356 @@
import { type Application, type Request, type Response } from 'express';
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { parse, stringify } from 'yaml';
import { patchYaml } from './yaml-patch.js';
import { detectDrift, type DriftStatus } from '../engine/reflection/drift-detect.js';
import { userPiecesDir } from '../user-folder/paths.js';
import { logger } from '../logger.js';
export type PieceSource = 'builtin' | 'global-custom' | 'user-custom';
interface PieceSummary {
name: string;
description: string;
triggers?: { keywords: string[] };
drift?: DriftStatus;
requiredMcp?: string[];
/** Backward-compat: true for any non-builtin (global-custom OR user-custom). */
custom: boolean;
source: PieceSource;
/** Set only when source === 'user-custom'. */
ownerId?: string;
}
function loadPieceFile(filePath: string): any {
const raw = readFileSync(filePath, 'utf-8');
return parse(raw);
}
function listPieceFiles(piecesDir: string): string[] {
return readdirSync(piecesDir)
.filter(f => f.endsWith('.yaml'))
.map(f => join(piecesDir, f));
}
// Phase 4 (SSH): movements using these tools must declare allowed_ssh_connections.
// Kept inline (not imported from engine/) so this API module stays decoupled
// from SSH internals — pieces can be validated even when SSH is disabled.
const SSH_TOOL_NAMES = new Set(['SshExec', 'SshUpload', 'SshDownload']);
const ALLOWED_SSH_ID = /^[a-f0-9-]{8,}$/;
function validatePiece(piece: any): string | null {
if (!piece.name || !/^[a-z0-9-]+$/.test(piece.name)) return 'name must be lowercase alphanumeric with hyphens';
if (!Array.isArray(piece.movements) || piece.movements.length === 0) return 'movements must be non-empty array';
// Required so `while (steps < piece.max_movements)` actually iterates;
// otherwise the run aborts with "Exceeded max movements (undefined)".
if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) {
return 'max_movements is required (positive integer)';
}
const names = new Set(piece.movements.map((m: any) => m.name));
if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement';
// Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS.
// Terminal moves (COMPLETE/ABORT/ASK) go through the `complete` tool now.
// default_next is engine-internal (context overflow / ASK limit / SpawnSubTask
// unavailable fallback) and still accepts COMPLETE/ABORT/ASK.
const validRuleNexts = new Set([...names, 'WAIT_SUBTASKS']);
const validDefaultNexts = new Set([...names, 'COMPLETE', 'ABORT', 'ASK', 'WAIT_SUBTASKS']);
for (const m of piece.movements) {
if (m.default_next && !validDefaultNexts.has(m.default_next)) {
return `movement "${m.name}": default_next "${m.default_next}" is invalid`;
}
if (Array.isArray(m.rules)) {
for (const r of m.rules) {
if (!validRuleNexts.has(r.next)) {
if (r.next === 'COMPLETE' || r.next === 'ABORT' || r.next === 'ASK') {
return `movement "${m.name}": rules[].next cannot be "${r.next}" (use the \`complete\` tool for terminal moves)`;
}
return `movement "${m.name}": rule next "${r.next}" is invalid`;
}
}
}
// Phase 4: allowed_ssh_connections consistency + format
const list = m.allowed_ssh_connections;
const tools = Array.isArray(m.allowed_tools) ? m.allowed_tools : [];
const hasSshTool = tools.some((t: unknown) => typeof t === 'string' && SSH_TOOL_NAMES.has(t));
if (list === undefined) {
if (hasSshTool) {
return `movement "${m.name}": allowed_ssh_connections is required when allowed_tools contains SSH tool(s)`;
}
} else if (!Array.isArray(list)) {
return `movement "${m.name}": allowed_ssh_connections must be an array`;
} else {
for (let i = 0; i < list.length; i++) {
const entry = list[i];
if (typeof entry !== 'string') {
return `movement "${m.name}": allowed_ssh_connections[${i}] must be a string`;
}
if (entry !== '*' && !ALLOWED_SSH_ID.test(entry)) {
return `movement "${m.name}": allowed_ssh_connections[${i}]="${entry}" must be '*' or a lowercase hex/hyphen id (8+ chars)`;
}
}
}
}
return null;
}
const VALID_PIECE_NAME = /^[a-z0-9-]+$/;
function validateName(name: string): boolean {
return VALID_PIECE_NAME.test(name);
}
export function findPieceFile(name: string, piecesDir: string, customPiecesDir?: string): { path: string; custom: boolean } | null {
if (customPiecesDir) {
const customPath = join(customPiecesDir, `${name}.yaml`);
if (existsSync(customPath)) return { path: customPath, custom: true };
}
const builtinPath = join(piecesDir, `${name}.yaml`);
if (existsSync(builtinPath)) return { path: builtinPath, custom: false };
return null;
}
export interface PiecesApiOptions {
piecesDir: string;
/** Optional admin-managed shared custom dir (global to all users). */
customPiecesDir?: string;
/**
* Root of per-user data (typically `./data/users`). When set, each authenticated
* user can read/create/update/delete pieces under `{userPiecesRootDir}/{userId}/pieces/`.
* When unset, per-user piece support is disabled and non-admin POST returns 503.
*/
userPiecesRootDir?: string;
}
type AuthedUser = { id: string; role?: string };
function getUser(req: Request): AuthedUser | undefined {
return (req as any).user as AuthedUser | undefined;
}
function isAdminOrLegacy(user: AuthedUser | undefined): boolean {
// No req.user → legacy (auth disabled or test/internal). Treat as admin so
// existing callers without auth middleware continue to work.
return !user || user.role === 'admin';
}
/**
* Lookup priority for a given caller:
* 1. Caller's own user-custom dir (overrides everything below).
* 2. Global custom dir (admin-managed, all users see).
* 3. Built-in dir.
*/
function findPieceForCaller(
opts: PiecesApiOptions,
user: AuthedUser | undefined,
name: string,
): { path: string; source: PieceSource; ownerId?: string } | null {
if (opts.userPiecesRootDir && user) {
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, user.id), `${name}.yaml`);
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId: user.id };
}
if (opts.customPiecesDir) {
const gcPath = join(opts.customPiecesDir, `${name}.yaml`);
if (existsSync(gcPath)) return { path: gcPath, source: 'global-custom' };
}
const biPath = join(opts.piecesDir, `${name}.yaml`);
if (existsSync(biPath)) return { path: biPath, source: 'builtin' };
return null;
}
/**
* Mount the pieces REST API. Read endpoints (GET) require only authentication
* (any logged-in user can list/read pieces visible to them). Write endpoints
* (POST/PUT/DELETE) enforce per-piece authorization:
* - built-in / global-custom: admin only
* - user-custom: owner or admin
*/
export function mountPiecesApi(
app: Application,
optsOrPiecesDir: PiecesApiOptions | string,
legacyCustomPiecesDir?: string,
): void {
// Backwards-compatible signature: mountPiecesApi(app, piecesDir, customPiecesDir?)
const opts: PiecesApiOptions = typeof optsOrPiecesDir === 'string'
? { piecesDir: optsOrPiecesDir, customPiecesDir: legacyCustomPiecesDir }
: optsOrPiecesDir;
app.get('/api/pieces', (req: Request, res: Response) => {
try {
const user = getUser(req);
const seen = new Set<string>();
const pieces: PieceSummary[] = [];
// Order matters: user-custom overrides global-custom, which overrides built-in.
const sources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
if (opts.userPiecesRootDir && user) {
const ucDir = userPiecesDir(opts.userPiecesRootDir, user.id);
if (existsSync(ucDir)) sources.push({ dir: ucDir, source: 'user-custom', ownerId: user.id });
}
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
sources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
}
if (existsSync(opts.piecesDir)) {
sources.push({ dir: opts.piecesDir, source: 'builtin' });
}
for (const { dir, source, ownerId } of sources) {
for (const f of listPieceFiles(dir)) {
try {
const p = loadPieceFile(f);
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
if (seen.has(name)) continue;
seen.add(name);
// Drift is meaningful only for global-custom that shadows a built-in.
let drift: DriftStatus | undefined;
if (source === 'global-custom' && existsSync(opts.piecesDir)) {
const builtinPath = join(opts.piecesDir, `${name}.yaml`);
drift = detectDrift(f, builtinPath);
}
pieces.push({
name,
description: p.description,
triggers: p.triggers,
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
custom: source !== 'builtin',
source,
ownerId,
drift,
});
} catch {
// skip malformed piece files
}
}
}
res.json({ pieces });
} catch (e) {
res.status(500).json({ error: `Failed to list pieces: ${e}` });
}
});
app.get('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try {
const user = getUser(req);
const found = findPieceForCaller(opts, user, req.params.name);
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
const piece = loadPieceFile(found.path);
res.json({
piece: {
...piece,
requiredMcp: Array.isArray(piece.required_mcp) ? piece.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
},
custom: found.source !== 'builtin',
source: found.source,
ownerId: found.ownerId,
});
} catch (e) {
res.status(500).json({ error: `Failed to read piece: ${e}` });
}
});
app.put('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try {
const user = getUser(req);
const found = findPieceForCaller(opts, user, req.params.name);
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
// Authz: built-in / global-custom → admin (or legacy no-auth); user-custom → owner (or admin).
if (found.source !== 'user-custom') {
if (!isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: 'Only admins can modify built-in or global-custom pieces' });
return;
}
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
// Different user's user-custom — and not admin. Should be unreachable since
// findPieceForCaller scopes user-custom to the caller, but guard anyway.
res.status(403).json({ ok: false, error: "Cannot modify another user's custom piece" });
return;
}
const error = validatePiece(req.body);
if (error) { res.status(400).json({ ok: false, error }); return; }
if (req.body.name !== req.params.name) {
res.status(400).json({ ok: false, error: 'Body name must match URL parameter' }); return;
}
// Use parseDocument + setIn so untouched regions keep their original
// formatting (block styles, inline arrays, blank lines, comments).
// Full re-serialization via stringify would e.g. convert `instruction: |`
// to `instruction: >`, changing runtime prompt behavior. See #151.
const originalText = readFileSync(found.path, 'utf-8');
const patched = patchYaml(originalText, req.body);
writeFileSync(found.path, patched, 'utf-8');
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to update piece: ${e}` });
}
});
app.post('/api/pieces', (req: Request, res: Response) => {
try {
const error = validatePiece(req.body);
if (error) { res.status(400).json({ ok: false, error }); return; }
const user = getUser(req);
const adminOrLegacy = isAdminOrLegacy(user);
// Determine destination dir:
// - admin / legacy → preserve existing behavior (write to piecesDir).
// - non-admin user → write to their user-custom dir.
let destDir: string;
if (adminOrLegacy) {
destDir = opts.piecesDir;
} else {
if (!opts.userPiecesRootDir) {
res.status(503).json({ ok: false, error: 'User pieces directory not configured on this server' });
return;
}
destDir = userPiecesDir(opts.userPiecesRootDir, user!.id);
mkdirSync(destDir, { recursive: true });
}
// Reject if any visible-to-caller piece with this name already exists
// (built-in, global-custom, or caller's user-custom).
if (findPieceForCaller(opts, user, req.body.name)) {
res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
}
const filePath = join(destDir, `${req.body.name}.yaml`);
writeFileSync(filePath, stringify(req.body, { lineWidth: 120 }), 'utf-8');
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} actor=${user?.id ?? 'legacy'}`);
res.status(201).json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to create piece: ${e}` });
}
});
app.delete('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
if (req.params.name === 'general' || req.params.name === 'chat') {
res.status(403).json({ ok: false, error: 'Cannot delete general piece' }); return;
}
try {
const user = getUser(req);
const found = findPieceForCaller(opts, user, req.params.name);
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
// Authz mirrors PUT: built-in / global-custom → admin; user-custom → owner.
if (found.source !== 'user-custom') {
if (!isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: 'Only admins can delete built-in or global-custom pieces' });
return;
}
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
return;
}
unlinkSync(found.path);
logger.info(`[pieces-api] deleted piece=${req.params.name} source=${found.source} actor=${user?.id ?? 'legacy'}`);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to delete piece: ${e}` });
}
});
}
+414
View File
@@ -0,0 +1,414 @@
/**
* reflection-api.test.ts
*
* Unit tests for the reflection history REST API.
* Uses an in-memory SQLite repository + temp filesystem for snapshots.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { writeSnapshot, type WriteSnapshotMeta } from '../engine/reflection/snapshot.js';
import { createReflectionApi } from './reflection-api.js';
// ── Fixtures ──────────────────────────────────────────────────────────────────
const OWNER_ID = 'u-reflect-owner';
const OTHER_ID = 'u-reflect-other';
function makeMeta(overrides: Partial<WriteSnapshotMeta> = {}): WriteSnapshotMeta {
return {
originalJobId: 'j-test-001',
userId: OWNER_ID,
pieceName: 'chat',
outcome: 'applied',
reasoning: 'User prefers concise answers.',
modelUsed: 'qwen2.5:3b',
tokensIn: 800,
tokensOut: 60,
ratingAtTime: null,
memoryChanges: 1,
pieceEdited: false,
...overrides,
};
}
// Create an Express app wired up with the reflection API, with auth disabled
// (auth gate injects synthetic user from X-Test-User-Id header via a middleware).
function buildApp(dataDir: string, repo: Repository, userId: string = OWNER_ID) {
const app = express();
app.use(express.json());
// Inject user from query param for tests (simulates requireAuth)
app.use((req, _res, next) => {
const testUserId = req.headers['x-test-user-id'] as string | undefined;
if (testUserId) {
(req as any).user = { id: testUserId, role: testUserId === 'admin' ? 'admin' : 'user' };
}
next();
});
app.use(
'/api/local/reflection',
createReflectionApi({ dataDir, repo, authActive: false }),
);
return app;
}
// Helper to set the user for a request
function asUser(agent: request.SuperTest<request.Test>, userId: string) {
return { userId };
}
// ── Test suite ────────────────────────────────────────────────────────────────
describe('reflection-api', () => {
let tmpDir: string;
let repo: Repository;
let dbPath: string;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'reflect-api-'));
dbPath = join(tmpDir, 'test.db');
repo = new Repository(dbPath);
app = buildApp(tmpDir, repo);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
// ── GET /history — paged listing ──────────────────────────────────────────
describe('GET /history', () => {
it('returns empty list when no snapshots exist', async () => {
const res = await request(app)
.get('/api/local/reflection/history')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.items).toEqual([]);
expect(res.body.nextCursor).toBeNull();
});
it('returns items most-recent first with correct paging', async () => {
const deps = { dataDir: tmpDir };
const dateA = new Date('2026-05-10T08:00:00Z');
const dateB = new Date('2026-05-11T09:00:00Z');
const dateC = new Date('2026-05-12T10:00:00Z');
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-a' }), undefined, undefined, dateA);
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-b' }), undefined, undefined, dateB);
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-c' }), undefined, undefined, dateC);
const res = await request(app)
.get('/api/local/reflection/history?limit=2')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
const { items, nextCursor } = res.body;
// Most recent first
expect(items).toHaveLength(2);
expect(items[0].jobId).toBe('j-c');
expect(items[1].jobId).toBe('j-b');
// nextCursor should be the ts of the last returned item
expect(nextCursor).toBe(items[1].ts);
});
it('pages correctly with before cursor', async () => {
const deps = { dataDir: tmpDir };
const dateA = new Date('2026-05-10T08:00:00Z');
const dateB = new Date('2026-05-11T09:00:00Z');
const dateC = new Date('2026-05-12T10:00:00Z');
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-a' }), undefined, undefined, dateA);
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-b' }), undefined, undefined, dateB);
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-c' }), undefined, undefined, dateC);
// First page: limit=2 gives j-c, j-b; cursor = j-b.ts
const page1 = await request(app)
.get('/api/local/reflection/history?limit=2')
.set('x-test-user-id', OWNER_ID);
const cursor = page1.body.nextCursor;
// Second page: before=cursor should give j-a
const page2 = await request(app)
.get(`/api/local/reflection/history?limit=2&before=${encodeURIComponent(cursor)}`)
.set('x-test-user-id', OWNER_ID);
expect(page2.status).toBe(200);
expect(page2.body.items).toHaveLength(1);
expect(page2.body.items[0].jobId).toBe('j-a');
expect(page2.body.nextCursor).toBeNull(); // last page
});
it('returns 401 when unauthenticated (auth active)', async () => {
// Build an app with authActive=true (no synthetic user injection)
const authApp = express();
authApp.use(express.json());
authApp.use('/api/local/reflection', createReflectionApi({ dataDir: tmpDir, repo, authActive: true }));
const res = await request(authApp).get('/api/local/reflection/history');
expect(res.status).toBe(401);
});
});
// ── GET /history/:snapshotId — detail ─────────────────────────────────────
describe('GET /history/:snapshotId', () => {
it('returns full detail for owner', async () => {
const deps = { dataDir: tmpDir };
const before = { 'pref-terse.md': 'old content' };
const after = { 'pref-terse.md': 'new content' };
const fixedDate = new Date('2026-05-11T12:00:00Z');
const { snapshotId } = await writeSnapshot(deps, before, after, makeMeta(), undefined, undefined, fixedDate);
const res = await request(app)
.get(`/api/local/reflection/history/${snapshotId}`)
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.snapshotId).toBe(snapshotId);
expect(res.body.outcome).toBe('applied');
expect(res.body.beforeFiles).toHaveProperty('pref-terse.md', 'old content');
expect(res.body.afterFiles).toHaveProperty('pref-terse.md', 'new content');
expect(typeof res.body.diff).toBe('string');
});
it('returns 404 for non-owner (no existence leak)', async () => {
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());
const res = await request(app)
.get(`/api/local/reflection/history/${snapshotId}`)
.set('x-test-user-id', OTHER_ID);
expect(res.status).toBe(404);
});
it('returns 404 for non-existent snapshot', async () => {
const res = await request(app)
.get('/api/local/reflection/history/99999999T000000Z-ghost')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(404);
});
});
// ── POST /history/:snapshotId/revert — idempotent revert ──────────────────
describe('POST /history/:snapshotId/revert', () => {
it('reverts successfully on first call', async () => {
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());
const res = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert`)
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.reverted).toBe(true);
});
it('is idempotent: second call returns { reverted: false }', async () => {
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());
await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert`)
.set('x-test-user-id', OWNER_ID);
const res2 = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert`)
.set('x-test-user-id', OWNER_ID);
expect(res2.status).toBe(200);
expect(res2.body.reverted).toBe(false);
});
it('returns 404 for non-owner', async () => {
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());
const res = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert`)
.set('x-test-user-id', OTHER_ID);
expect(res.status).toBe(404);
});
});
// ── GET /metrics — graceful when table absent ─────────────────────────────
describe('GET /metrics', () => {
it('returns zero counts when reflection_metrics table does not exist', async () => {
const res = await request(app)
.get('/api/local/reflection/metrics')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
applied: 0,
partial: 0,
abstained: 0,
rejected: 0,
failed: 0,
tokensIn: 0,
tokensOut: 0,
pieceEdits: 0,
});
});
it('returns zero counts for a user with no rows (table exists)', async () => {
// Create the table manually
repo.getDb().exec(`
CREATE TABLE IF NOT EXISTS reflection_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
outcome TEXT NOT NULL,
piece_edited INTEGER NOT NULL DEFAULT 0,
tokens_in INTEGER,
tokens_out INTEGER,
created_at TEXT NOT NULL
)
`);
const res = await request(app)
.get('/api/local/reflection/metrics')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.applied).toBe(0);
expect(res.body.tokensIn).toBe(0);
});
it('aggregates correctly when rows exist', async () => {
// Phase 8.1's reflection_metrics table is auto-created by Repository
// init (NOT NULL on reflection_job_id, INTEGER created_at). Insert
// through that real schema.
const now = Date.now();
const ins = repo.getDb().prepare(`
INSERT INTO reflection_metrics
(reflection_job_id, original_job_id, user_id, piece_name, outcome,
memory_changes, piece_edited, tokens_in, tokens_out, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
ins.run('r-1', 'j-1', OWNER_ID, 'chat', 'applied', 2, 1, 500, 40, 100, now);
ins.run('r-2', 'j-2', OWNER_ID, 'chat', 'rejected', 0, 0, 300, 20, 80, now);
// Other user's row — should NOT appear
ins.run('r-3', 'j-3', OTHER_ID, 'chat', 'applied', 1, 0, 999, 99, 50, now);
const res = await request(app)
.get('/api/local/reflection/metrics')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.applied).toBe(1);
expect(res.body.rejected).toBe(1);
expect(res.body.pieceEdits).toBe(1);
expect(res.body.tokensIn).toBe(800);
expect(res.body.tokensOut).toBe(60);
// Other user's rows excluded
expect(res.body.tokensIn).not.toBe(1799);
});
});
// ── GET /latest-for-task/:taskId — ReflectionBadge feed ──────────────────
describe('GET /latest-for-task/:taskId', () => {
it('returns null when no snapshot for the task', async () => {
const res = await request(app)
.get('/api/local/reflection/latest-for-task/42')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body).toBeNull();
});
it('returns null for invalid taskId', async () => {
const res = await request(app)
.get('/api/local/reflection/latest-for-task/notanumber')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(400);
});
it('returns snapshot summary when a matching job+snapshot exists', async () => {
// Insert a job row with repo='local/task-7' owned by OWNER_ID
const db = repo.getDb();
const now = new Date().toISOString();
db.prepare(`
INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class,
instruction, attempt, max_attempts, ask_count, subtask_depth, task_kind, created_at, updated_at, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run('j-task7-001', 'local/task-7', 1, 'succeeded', 'chat', 'default', 'auto',
'Test task', 1, 1, 0, 0, 'agent', now, now, OWNER_ID);
// Write a snapshot referencing that job
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(
deps,
{ 'pref.md': 'before' },
{ 'pref.md': 'after' },
makeMeta({ originalJobId: 'j-task7-001', memoryChanges: 3, pieceEdited: true }),
undefined,
undefined,
new Date('2026-05-11T15:00:00Z'),
);
const res = await request(app)
.get('/api/local/reflection/latest-for-task/7')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body).not.toBeNull();
expect(res.body.snapshotId).toBe(snapshotId);
expect(res.body.outcome).toBe('applied');
expect(res.body.memoryChanges).toBe(3);
expect(res.body.pieceEdited).toBe(true);
});
it('returns null when the task job is owned by another user', async () => {
// Insert a job owned by OTHER_ID for task 8
const db = repo.getDb();
const now = new Date().toISOString();
db.prepare(`
INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class,
instruction, attempt, max_attempts, ask_count, subtask_depth, task_kind, created_at, updated_at, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run('j-task8-001', 'local/task-8', 1, 'succeeded', 'chat', 'default', 'auto',
'Other task', 1, 1, 0, 0, 'agent', now, now, OTHER_ID);
// Write a snapshot for OTHER_ID
const deps = { dataDir: tmpDir };
await writeSnapshot(
deps,
{},
{},
makeMeta({ originalJobId: 'j-task8-001', userId: OTHER_ID }),
undefined,
undefined,
new Date(),
);
// OWNER_ID should not see OTHER_ID's task
const res = await request(app)
.get('/api/local/reflection/latest-for-task/8')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body).toBeNull();
});
});
});
+315
View File
@@ -0,0 +1,315 @@
/**
* reflection-api.ts — REST router for reflection history + metrics
*
* Mounted at /api/local/reflection
*
* Routes:
* GET /history — paged index listing (limit, before cursor)
* GET /history/:snapshotId — full detail (meta + before/after files + diff)
* POST /history/:snapshotId/revert — idempotent revert
* GET /metrics — outcome counts + token sums (?days=30)
* GET /latest-for-task/:taskId — feeds the ReflectionBadge on OverviewTab
*
* Auth: all routes require an authenticated user (req.user).
* Owner: every operation is scoped to req.user.id — 404 on mismatch (no existence leak).
*/
import { Router, type Request, type Response } from 'express';
import { logger } from '../logger.js';
import {
listSnapshots,
readSnapshot,
revertSnapshotForUser,
type SnapshotDeps,
type SnapshotIndexEntry,
type SnapshotDetail,
} from '../engine/reflection/snapshot.js';
import type { Repository } from '../db/repository.js';
// ── Types ──────────────────────────────────────────────────────────────────────
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
// ── Deps ───────────────────────────────────────────────────────────────────────
export interface ReflectionApiDeps {
/** Root data dir (same as userFolderRoot in the rest of the app). */
dataDir: string;
/** Repository for job lookups (latest-for-task, metrics). */
repo: Repository;
/** When false (local-dev mode), inject a synthetic 'local' user if req.user absent. */
authActive?: boolean;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeDeps(dataDir: string): SnapshotDeps {
return { dataDir };
}
// ── Factory ────────────────────────────────────────────────────────────────────
export function createReflectionApi(deps: ReflectionApiDeps): Router {
const { dataDir, repo } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
// ── Auth gate ──────────────────────────────────────────────────────────────
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// ── GET /history ───────────────────────────────────────────────────────────
// Returns paged list of snapshot index entries (most recent first).
// Query params:
// limit — max items to return (default 50, max 200)
// before — ISO timestamp cursor (exclusive, for pagination)
r.get('/history', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawLimit = parseInt(String(req.query.limit ?? '50'), 10);
const limit = isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 200);
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
try {
const items = listSnapshots(makeDeps(dataDir), u.id, { limit, before });
// Compute nextCursor from the last item's ts (if we got a full page)
const nextCursor: string | null =
items.length === limit ? (items[items.length - 1]?.ts ?? null) : null;
res.json({ items, nextCursor });
} catch (err) {
logger.error(`[reflection-api] GET /history failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list reflection history' });
}
});
// ── GET /history/:snapshotId ───────────────────────────────────────────────
// Returns full snapshot detail for the owner. 404 for non-owner or missing.
r.get('/history/:snapshotId', (req: Request, res: Response) => {
const u = getUser(req)!;
const { snapshotId } = req.params;
try {
const detail: SnapshotDetail | null = readSnapshot(makeDeps(dataDir), u.id, snapshotId);
if (!detail) {
// Either doesn't exist or belongs to another user — always 404
res.status(404).json({ error: 'not_found' });
return;
}
// Owner check: the meta.json embeds userId
if (detail.userId !== u.id) {
res.status(404).json({ error: 'not_found' });
return;
}
res.json(detail);
} catch (err) {
logger.error(`[reflection-api] GET /history/${snapshotId} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to read snapshot' });
}
});
// ── POST /history/:snapshotId/revert ──────────────────────────────────────
// Idempotent revert. Returns { reverted: true } on first call, { reverted: false } thereafter.
r.post('/history/:snapshotId/revert', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { snapshotId } = req.params;
// Owner check: read meta first (cheaper than a full revert attempt that fails)
try {
const detail = readSnapshot(makeDeps(dataDir), u.id, snapshotId);
if (!detail || detail.userId !== u.id) {
res.status(404).json({ error: 'not_found' });
return;
}
} catch (err) {
logger.error(`[reflection-api] POST /revert owner-check failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
res.status(500).json({ error: 'Failed to verify snapshot ownership' });
return;
}
try {
const result = await revertSnapshotForUser(makeDeps(dataDir), u.id, snapshotId);
logger.info(`[reflection-api] POST /revert snapshotId=${snapshotId} user=${u.id} reverted=${result.reverted}`);
res.json(result);
} catch (err) {
logger.error(`[reflection-api] POST /revert failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
res.status(500).json({ error: 'Failed to revert snapshot' });
}
});
// ── GET /metrics ───────────────────────────────────────────────────────────
// Returns aggregated outcome counts + token sums for the caller.
// Query params:
// days — look-back window in days (default 30)
//
// Gracefully returns zero counts when the reflection_metrics table doesn't
// exist yet (Phase 8.1 creates it and starts inserting rows).
r.get('/metrics', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawDays = parseInt(String(req.query.days ?? '30'), 10);
const days = isNaN(rawDays) || rawDays < 1 ? 30 : Math.min(rawDays, 365);
const zeroMetrics = {
applied: 0,
partial: 0,
abstained: 0,
rejected: 0,
failed: 0,
tokensIn: 0,
tokensOut: 0,
pieceEdits: 0,
};
try {
const db = repo.getDb();
// Check if the table exists before querying (Phase 8.1 creates it)
const tableExists = (db.prepare(`PRAGMA table_info('reflection_metrics')`).all() as Array<{ name: string }>).length > 0;
if (!tableExists) {
res.json(zeroMetrics);
return;
}
// Phase 8.1's reflection_metrics.created_at is INTEGER ms-since-epoch.
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
interface MetricsRow {
outcome: string;
piece_edited: number;
tokens_in: number | null;
tokens_out: number | null;
}
const rows = db
.prepare(
`SELECT outcome, piece_edited, tokens_in, tokens_out
FROM reflection_metrics
WHERE user_id = ? AND created_at >= ?`,
)
.all(u.id, cutoff) as MetricsRow[];
const metrics = { ...zeroMetrics };
for (const row of rows) {
switch (row.outcome) {
case 'applied': metrics.applied++; break;
case 'partial': metrics.partial++; break;
case 'abstained': metrics.abstained++; break;
case 'rejected': metrics.rejected++; break;
case 'failed': metrics.failed++; break;
}
metrics.tokensIn += row.tokens_in ?? 0;
metrics.tokensOut += row.tokens_out ?? 0;
if (row.piece_edited) metrics.pieceEdits++;
}
res.json(metrics);
} catch (err) {
// If the table doesn't exist yet (race window between check and query), return zeros
const msg = String(err);
if (msg.includes('no such table')) {
res.json(zeroMetrics);
return;
}
logger.error(`[reflection-api] GET /metrics failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to load reflection metrics' });
}
});
// ── GET /latest-for-task/:taskId ──────────────────────────────────────────
// Returns the most recent snapshot triggered by the given local task's job,
// or null if none found. Used by the ReflectionBadge on OverviewTab (Phase 7.5).
//
// Owner check: the job must belong to the caller (owner_id match), or the
// user must be an admin. Returns null (not 404) when there's no snapshot —
// the badge simply stays hidden.
r.get('/latest-for-task/:taskId', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawTaskId = parseInt(req.params.taskId, 10);
if (isNaN(rawTaskId)) {
res.status(400).json({ error: 'invalid_task_id' });
return;
}
try {
const db = repo.getDb();
// Find all jobs for this local task, owned by the caller (or any if admin)
const repoName = `local/task-${rawTaskId}`;
interface JobRow {
id: string;
owner_id: string | null;
}
let rows: JobRow[];
if (u.role === 'admin') {
rows = db
.prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`)
.all(repoName) as JobRow[];
} else {
rows = db
.prepare(
`SELECT id, owner_id FROM jobs WHERE repo = ? AND owner_id = ? ORDER BY created_at DESC`,
)
.all(repoName, u.id) as JobRow[];
}
if (rows.length === 0) {
res.json(null);
return;
}
// Collect all job IDs for this task
const jobIds = new Set(rows.map((r) => r.id));
// List all snapshots for this user (no limit — we need to scan for a match)
const allSnapshots = listSnapshots(makeDeps(dataDir), u.id, { limit: 200 });
// Find the most recent snapshot whose originalJobId is in our job set
const match = allSnapshots.find((s: SnapshotIndexEntry) => jobIds.has(s.jobId));
if (!match) {
res.json(null);
return;
}
// Load the full detail so the badge can show outcome + counts
const detail = readSnapshot(makeDeps(dataDir), u.id, match.snapshotId);
if (!detail) {
res.json(null);
return;
}
res.json({
snapshotId: detail.snapshotId,
outcome: detail.outcome,
memoryChanges: detail.memoryChanges,
pieceEdited: detail.pieceEdited,
});
} catch (err) {
logger.error(`[reflection-api] GET /latest-for-task/${rawTaskId} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to load latest reflection for task' });
}
});
return r;
}
+534
View File
@@ -0,0 +1,534 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { Scheduler } from '../scheduler.js';
import { mountScheduledTasksApi } from './scheduled-tasks-api.js';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
let app: express.Application;
let repo: Repository;
let scheduler: Scheduler;
let tempDir: string;
beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-api-'));
repo = new Repository(join(tempDir, 'test.db'));
scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
app = express();
app.use(express.json());
mountScheduledTasksApi(app, repo, scheduler);
});
afterAll(() => {
repo.close();
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
});
describe('POST /api/scheduled-tasks with visibility', () => {
let vTempDir = '';
let vRepo: Repository;
let vApp: express.Application;
let aliceUser: Express.User;
beforeEach(() => {
vTempDir = mkdtempSync(join(tmpdir(), 'sched-vis-api-'));
vRepo = new Repository(join(vTempDir, 'db.sqlite'));
const real = vRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
aliceUser = {
...real,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const vScheduler = new Scheduler(vRepo, join(vTempDir, 'workspaces'));
vApp = express();
vApp.use(express.json());
vApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = aliceUser;
next();
});
mountScheduledTasksApi(vApp, vRepo, vScheduler);
});
afterEach(() => {
vRepo.close();
rmSync(vTempDir, { recursive: true, force: true });
});
it('creates scheduled task with owner_id set from req.user and visibility=org', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
minute: 0,
visibility: 'org',
visibilityScopeOrgId: '10',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('org');
expect(res.body.task.visibilityScopeOrgId).toBe('10');
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('defaults visibility to private and owner from req.user when not provided', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 10,
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('private');
expect(res.body.task.visibilityScopeOrgId).toBeNull();
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('rejects visibility=org with org not in user orgs', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
visibility: 'org',
visibilityScopeOrgId: '99',
});
expect(res.status).toBe(400);
});
it('rejects invalid visibility enum values', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
visibility: 'bogus',
});
expect(res.status).toBe(400);
});
});
describe('POST /api/scheduled-tasks', () => {
it('should create a daily schedule', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.send({
title: 'テスト日次',
body: 'テストプロンプト',
scheduleType: 'daily',
hour: 9,
minute: 0,
});
expect(res.status).toBe(201);
expect(res.body.task.cronExpression).toBe('0 9 * * *');
expect(res.body.task.isActive).toBe(true);
});
it('should require body', async () => {
const res = await request(app).post('/api/scheduled-tasks').send({ scheduleType: 'daily' });
expect(res.status).toBe(400);
});
});
describe('GET /api/scheduled-tasks', () => {
it('should list all scheduled tasks', async () => {
const res = await request(app).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tasks)).toBe(true);
});
});
describe('PATCH /api/scheduled-tasks/:id', () => {
it('should pause and resume', async () => {
const createRes = await request(app)
.post('/api/scheduled-tasks')
.send({ title: 'pause-test', body: 'test', scheduleType: 'daily', hour: 10 });
const id = createRes.body.task.id;
const pauseRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: false });
expect(pauseRes.body.task.isActive).toBe(false);
const resumeRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: true });
expect(resumeRes.body.task.isActive).toBe(true);
});
});
describe('DELETE /api/scheduled-tasks/:id', () => {
it('should delete a scheduled task', async () => {
const createRes = await request(app)
.post('/api/scheduled-tasks')
.send({ title: 'delete-test', body: 'test', scheduleType: 'daily', hour: 10 });
const id = createRes.body.task.id;
const delRes = await request(app).delete(`/api/scheduled-tasks/${id}`);
expect(delRes.status).toBe(200);
const getRes = await request(app).get(`/api/scheduled-tasks/${id}`);
expect(getRes.status).toBe(404);
});
});
describe('PATCH/DELETE /api/scheduled-tasks/:id owner-or-admin', () => {
let pTempDir = '';
let pRepo: Repository;
afterEach(() => {
pRepo.close();
rmSync(pTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const pScheduler = new Scheduler(pRepo, join(pTempDir, 'workspaces'));
const pApp = express();
pApp.use(express.json());
pApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(pApp, pRepo, pScheduler);
return pApp;
}
function seedTask(ownerId: string, visibility: 'private' | 'org' | 'public' = 'public') {
return pRepo.createScheduledTask({
title: 't',
body: 'b',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId,
visibility,
});
}
it('non-owner non-admin gets 404 on PATCH (even when visibility=public)', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'public');
const bobUser: Express.User = {
id: 'bob-id',
email: '[email protected]',
name: 'b',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(bobUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited' });
expect(res.status).toBe(404);
// Task title not changed
const after = await pRepo.getScheduledTask(task.id);
expect(after?.title).toBe('t');
});
it('non-owner non-admin gets 404 on DELETE', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'public');
const bobUser: Express.User = {
id: 'bob-id',
email: '[email protected]',
name: 'b',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(bobUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(404);
const after = await pRepo.getScheduledTask(task.id);
expect(after).not.toBeNull();
});
it('admin can PATCH any scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'private');
const adminUser: Express.User = {
id: 'admin-id',
email: '[email protected]',
name: 'admin',
avatarUrl: null,
role: 'admin',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(adminUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited-by-admin' });
expect(res.status).toBe(200);
expect(res.body.task.title).toBe('edited-by-admin');
});
it('admin can DELETE any scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'private');
const adminUser: Express.User = {
id: 'admin-id',
email: '[email protected]',
name: 'admin',
avatarUrl: null,
role: 'admin',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(adminUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
const after = await pRepo.getScheduledTask(task.id);
expect(after).toBeNull();
});
it('owner can PATCH own scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await seedTask(alice.id, 'private');
const pApp = buildAppForUser(aliceUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited-by-owner' });
expect(res.status).toBe(200);
expect(res.body.task.title).toBe('edited-by-owner');
});
it('owner can DELETE own scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await seedTask(alice.id, 'private');
const pApp = buildAppForUser(aliceUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
describe('GET /api/scheduled-tasks visibility filter', () => {
let lTempDir = '';
let lRepo: Repository;
afterEach(() => {
lRepo.close();
rmSync(lTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const lScheduler = new Scheduler(lRepo, join(lTempDir, 'workspaces'));
const lApp = express();
lApp.use(express.json());
lApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(lApp, lRepo, lScheduler);
return lApp;
}
it('non-owner does not see private scheduled tasks in list', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
const bobUser: Express.User = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(bobUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).not.toContain('alice-private');
});
it('owner sees own private scheduled tasks', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
const res = await request(buildAppForUser(aliceUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private');
});
it('admin sees all scheduled tasks regardless of visibility', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
const adminUser: Express.User = {
id: 'admin-id', email: '[email protected]', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(adminUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private');
});
});
describe('POST /api/scheduled-tasks browserSessionProfileId owner check', () => {
let bTempDir = '';
let bRepo: Repository;
let bSessRepo: BrowserSessionRepo;
let alice: { id: string };
let bob: { id: string };
let aliceProfileId: number;
let bobProfileId: number;
beforeEach(() => {
bTempDir = mkdtempSync(join(tmpdir(), 'sched-bsp-'));
bRepo = new Repository(join(bTempDir, 'db.sqlite'));
bSessRepo = new BrowserSessionRepo(bRepo.getDb());
alice = bRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
bob = bRepo.createUser({ email: '[email protected]', name: 'b', role: 'user', status: 'active' });
aliceProfileId = bSessRepo.createProfile({
ownerId: alice.id,
label: 'alice-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
bobProfileId = bSessRepo.createProfile({
ownerId: bob.id,
label: 'bob-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
});
afterEach(() => {
bRepo.close();
rmSync(bTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const bScheduler = new Scheduler(bRepo, join(bTempDir, 'workspaces'));
const bApp = express();
bApp.use(express.json());
bApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(bApp, bRepo, bScheduler, { sessRepo: bSessRepo });
return bApp;
}
function asUser(u: { id: string }, email: string): Express.User {
return {
id: u.id, email, name: 'x', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
it('accepts a valid profile owned by the requesting user (201)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: aliceProfileId,
});
expect(res.status).toBe(201);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('rejects a profile owned by a different user (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: bobProfileId,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not owned by you|not found/i);
});
it('rejects a positive integer that does not match any profile (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: 999999,
});
expect(res.status).toBe(400);
});
});
+345
View File
@@ -0,0 +1,345 @@
import { type Application, type Request, type Response } from 'express';
import { type Repository } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { convertToCron, calcNextRun, toSqliteDatetime } from '../scheduler.js';
import { type Scheduler } from '../scheduler.js';
export interface ScheduledTasksApiOptions {
/**
* Optional. When set, accepting browserSessionProfileId on create / update
* verifies the profile belongs to the requesting user. Without it, the
* field is silently dropped (legacy / no-auth deployments).
*/
sessRepo?: BrowserSessionRepo;
}
export function mountScheduledTasksApi(
app: Application,
repo: Repository,
scheduler: Scheduler,
apiOpts: ScheduledTasksApiOptions = {},
): void {
const { sessRepo } = apiOpts;
/**
* Validate and resolve a browserSessionProfileId from a request body.
* Returns:
* - { ok: true, value: number | null } when accepted (null = unset / clear).
* - { ok: false, error } when validation fails (caller sends 400).
* Pass an undefined raw to skip validation entirely (PATCH "field absent" case).
*/
function resolveBrowserSessionProfileId(
raw: unknown,
user: Express.User | undefined,
): { ok: true; value: number | null } | { ok: false; error: string } {
if (raw === undefined) return { ok: true, value: null };
if (raw === null || raw === '') return { ok: true, value: null };
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
return { ok: false, error: 'browserSessionProfileId must be a positive integer' };
}
if (sessRepo) {
if (!user?.id) {
return { ok: false, error: 'browserSessionProfileId requires an authenticated user' };
}
const owned = sessRepo.getProfileById(n, user.id);
if (!owned) {
return { ok: false, error: 'browser session profile not found or not owned by you' };
}
}
return { ok: true, value: n };
}
// 一覧取得
app.get('/api/scheduled-tasks', async (req: Request, res: Response) => {
try {
const viewer = req.user as Express.User | undefined;
const tasks = await repo.listScheduledTasks(viewer ? { viewer } : undefined);
res.json({ tasks });
} catch (err) {
res.status(500).json({ error: `Failed to list scheduled tasks: ${err}` });
}
});
// 詳細取得
app.get('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const task = await repo.getScheduledTask(id, { viewer });
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
res.json({ task });
} catch (err) {
res.status(500).json({ error: `Failed to get scheduled task: ${err}` });
}
});
// 新規作成
app.post('/api/scheduled-tasks', async (req: Request, res: Response) => {
try {
const { title, body, piece, profile, outputFormat, scheduleType, hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron, scheduledAt } = req.body;
// task_kind: 'agent' (default) or 'script'
const rawTaskKind = req.body?.taskKind;
const taskKind: 'agent' | 'script' = rawTaskKind === 'script' ? 'script' : 'agent';
let scriptName: string | null = null;
let scriptParams: string | null = null;
if (taskKind === 'script') {
const rawScriptName = req.body?.scriptName;
if (typeof rawScriptName !== 'string' || !rawScriptName.trim()) {
res.status(400).json({ error: 'scriptName is required when taskKind=script' });
return;
}
scriptName = rawScriptName.trim();
const rawScriptParams = req.body?.scriptParams;
if (rawScriptParams !== undefined && rawScriptParams !== null) {
if (typeof rawScriptParams === 'string') {
try {
const parsed = JSON.parse(rawScriptParams);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('scriptParams must be a JSON object');
}
scriptParams = JSON.stringify(parsed);
} catch (err) {
res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` });
return;
}
} else if (typeof rawScriptParams === 'object' && !Array.isArray(rawScriptParams)) {
scriptParams = JSON.stringify(rawScriptParams);
} else {
res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' });
return;
}
}
}
if (taskKind === 'agent' && !body) { res.status(400).json({ error: 'body is required' }); return; }
if (!scheduleType) { res.status(400).json({ error: 'scheduleType is required' }); return; }
// Visibility extraction + validation (mirrors POST /api/local/tasks)
const rawVisibility = req.body?.visibility ?? 'private';
if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
const visibility = rawVisibility as 'private' | 'org' | 'public';
const rawScopeOrgId = req.body?.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
}
const ownerId = (req.user as Express.User | undefined)?.id ?? null;
const profileBinding = resolveBrowserSessionProfileId(
req.body?.browserSessionProfileId,
req.user as Express.User | undefined,
);
if (!profileBinding.ok) {
res.status(400).json({ error: profileBinding.error });
return;
}
const cronExpr = convertToCron(scheduleType, { hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron });
let nextRunAt: string;
if (scheduleType === 'once') {
if (!scheduledAt) { res.status(400).json({ error: 'scheduledAt is required for once type' }); return; }
nextRunAt = toSqliteDatetime(new Date(scheduledAt));
} else {
const next = calcNextRun(cronExpr);
if (!next) { res.status(400).json({ error: 'Failed to calculate next run time' }); return; }
nextRunAt = next;
}
const task = await repo.createScheduledTask({
title: title || null,
body: taskKind === 'script' ? (body ?? '') : body,
pieceName: piece ?? 'auto',
profile: profile ?? 'auto',
outputFormat: outputFormat ?? 'markdown',
cronExpression: cronExpr,
nextRunAt,
ownerId,
visibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
browserSessionProfileId: profileBinding.value,
taskKind,
scriptName,
scriptParams,
});
res.status(201).json({ task });
} catch (err) {
res.status(400).json({ error: `Failed to create scheduled task: ${err}` });
}
});
// 編集
app.patch('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
res.status(404).json({ error: 'Not found' });
return;
}
const updates: Record<string, any> = {};
if (req.body.title !== undefined) updates.title = req.body.title;
if (req.body.body !== undefined) updates.body = req.body.body;
if (req.body.piece !== undefined) updates.pieceName = req.body.piece;
if (req.body.profile !== undefined) updates.profile = req.body.profile;
if (req.body.outputFormat !== undefined) updates.outputFormat = req.body.outputFormat;
// スケジュール変更
if (req.body.scheduleType) {
const cronExpr = convertToCron(req.body.scheduleType, {
hour: req.body.hour,
minute: req.body.minute,
dayOfWeek: req.body.dayOfWeek,
dayOfMonth: req.body.dayOfMonth,
cronExpression: req.body.cronExpression,
});
updates.cronExpression = cronExpr;
if (req.body.scheduleType === 'once' && req.body.scheduledAt) {
updates.nextRunAt = toSqliteDatetime(new Date(req.body.scheduledAt));
} else {
const next = calcNextRun(cronExpr);
if (next) updates.nextRunAt = next;
}
}
// 一時停止/再開
if (req.body.isActive !== undefined) {
updates.isActive = req.body.isActive;
// 再開時は next_run_at を再計算
if (req.body.isActive && !updates.cronExpression) {
const next = calcNextRun(existing.cronExpression);
if (next) updates.nextRunAt = next;
}
}
// Visibility 変更 (POST と同じバリデーション)
if (req.body.visibility !== undefined) {
const rawVisibility = req.body.visibility;
if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
const rawScopeOrgId = req.body.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (rawVisibility === 'org') {
const orgIds = viewer?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
}
updates.visibility = rawVisibility;
updates.visibilityScopeOrgId = rawVisibility === 'org' ? visibilityScopeOrgId : null;
}
// browserSessionProfileId 変更 (owner check)
if (req.body.browserSessionProfileId !== undefined) {
const binding = resolveBrowserSessionProfileId(req.body.browserSessionProfileId, viewer);
if (!binding.ok) {
res.status(400).json({ error: binding.error });
return;
}
updates.browserSessionProfileId = binding.value;
}
// taskKind / scriptName / scriptParams (PATCH 用)
if (req.body.taskKind !== undefined) {
if (req.body.taskKind !== 'agent' && req.body.taskKind !== 'script') {
res.status(400).json({ error: "taskKind must be 'agent' or 'script'" });
return;
}
updates.taskKind = req.body.taskKind;
}
if (req.body.scriptName !== undefined) {
if (req.body.scriptName === null || req.body.scriptName === '') {
updates.scriptName = null;
} else if (typeof req.body.scriptName === 'string') {
updates.scriptName = req.body.scriptName.trim();
} else {
res.status(400).json({ error: 'scriptName must be a string' });
return;
}
}
if (req.body.scriptParams !== undefined) {
if (req.body.scriptParams === null) {
updates.scriptParams = null;
} else if (typeof req.body.scriptParams === 'string') {
try {
const parsed = JSON.parse(req.body.scriptParams);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('scriptParams must be a JSON object');
}
updates.scriptParams = JSON.stringify(parsed);
} catch (err) {
res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` });
return;
}
} else if (typeof req.body.scriptParams === 'object' && !Array.isArray(req.body.scriptParams)) {
updates.scriptParams = JSON.stringify(req.body.scriptParams);
} else {
res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' });
return;
}
}
const updated = await repo.updateScheduledTask(id, updates);
res.json({ task: updated });
} catch (err) {
res.status(400).json({ error: `Failed to update scheduled task: ${err}` });
}
});
// 削除
app.delete('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
res.status(404).json({ error: 'Not found' });
return;
}
const deleted = await repo.deleteScheduledTask(id);
if (!deleted) { res.status(404).json({ error: 'Not found' }); return; }
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Failed to delete scheduled task: ${err}` });
}
});
// 手動即時実行
app.post('/api/scheduled-tasks/:id/trigger', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
res.status(404).json({ error: 'Not found' });
return;
}
await scheduler.executeById(id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Failed to trigger scheduled task: ${err}` });
}
});
}
+290
View File
@@ -0,0 +1,290 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import express, { Request, Response, NextFunction } from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { mountUsersApi } from './users-api.js';
describe('GET /api/jobs/:id visibility', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
it('non-viewer gets null from repo.getJob (drives 404 in handler)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-vis-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const job = await repo.createJob({
repo: 'local/task-1',
issueNumber: 1,
instruction: 'x',
pieceName: 'chat',
ownerId: alice.id,
visibility: 'private',
visibilityScopeOrgId: null,
});
const bobUser: Express.User = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private' as const,
defaultVisibilityOrgId: null,
};
// Verify at the data layer: bob (non-owner, no orgs) cannot see alice's private job.
expect(await repo.getJob(job.id, { viewer: bobUser })).toBeNull();
// Alice (owner) can.
expect(await repo.getJob(job.id, { viewer: aliceUser })).not.toBeNull();
// Internal callers (no viewer) still get the row (worker/scheduler pass-through).
expect(await repo.getJob(job.id)).not.toBeNull();
} finally {
repo.close();
}
});
it('admin sees any job regardless of visibility', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-vis-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const job = await repo.createJob({
repo: 'local/task-1',
issueNumber: 1,
instruction: 'x',
pieceName: 'chat',
ownerId: alice.id,
visibility: 'private',
visibilityScopeOrgId: null,
});
const adminUser: Express.User = {
id: 'admin-id', email: '[email protected]', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
expect(await repo.getJob(job.id, { viewer: adminUser })).not.toBeNull();
} finally {
repo.close();
}
});
});
describe('GET /api/users/me/orgs', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
/**
* Build a test app that mounts the REAL /api/users/me/orgs route via
* mountUsersApi (the same entry point createCoreServer uses) and injects a
* mocked req.user ahead of it. Pass `injectUser = null` to skip injection
* and exercise requireAuth.
*/
function buildApp(
repo: Repository,
injectUser: (Partial<Express.User> & { id: string }) | null,
): express.Application {
const app = express();
if (injectUser) {
app.use((req: Request, _res: Response, next: NextFunction) => {
(req as Request & { user: Express.User }).user = {
email: '[email protected]', name: 'u', avatarUrl: null, role: 'user', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
...injectUser,
} as Express.User;
(req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => true;
next();
});
// authActive=false: skip requireAuth (we pre-populate req.user above).
mountUsersApi(app, repo, false);
} else {
// authActive=true: exercise the real requireAuth guard. isAuthenticated()
// is missing so requireAuth should return 401.
app.use((req: Request, _res: Response, next: NextFunction) => {
(req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => false;
next();
});
mountUsersApi(app, repo, true);
}
return app;
}
it('returns 401 when the request is unauthenticated (requireAuth gate)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const app = buildApp(repo, null);
const res = await request(app).get('/api/users/me/orgs');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
} finally {
repo.close();
}
});
it('returns the cached gitea orgs for the authenticated user', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
repo.replaceUserGiteaOrgs(alice.id, [
{ orgId: 'org-1', orgName: 'alpha' },
{ orgId: 'org-2', orgName: 'beta' },
]);
const app = buildApp(repo, { id: alice.id });
const res = await request(app).get('/api/users/me/orgs');
expect(res.status).toBe(200);
expect(res.body.orgs).toHaveLength(2);
// listUserGiteaOrgs ORDERs by org_name ASC
expect(res.body.orgs[0].orgName).toBe('alpha');
expect(res.body.orgs[1].orgName).toBe('beta');
expect(res.body.orgs[0].orgId).toBe('org-1');
} finally {
repo.close();
}
});
it('returns empty array when user has no cached orgs', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const bob = repo.createUser({ email: '[email protected]', name: 'Bob', role: 'user', status: 'active' });
const app = buildApp(repo, { id: bob.id });
const res = await request(app).get('/api/users/me/orgs');
expect(res.status).toBe(200);
expect(res.body.orgs).toEqual([]);
} finally {
repo.close();
}
});
});
describe('PATCH /api/users/me/preferences', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
function buildApp(
repo: Repository,
injectUser: (Partial<Express.User> & { id: string }) | null,
): express.Application {
const app = express();
if (injectUser) {
app.use((req: Request, _res: Response, next: NextFunction) => {
(req as Request & { user: Express.User }).user = {
email: '[email protected]', name: 'u', avatarUrl: null, role: 'user', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
...injectUser,
} as Express.User;
(req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => true;
next();
});
mountUsersApi(app, repo, false);
} else {
app.use((req: Request, _res: Response, next: NextFunction) => {
(req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => false;
next();
});
mountUsersApi(app, repo, true);
}
return app;
}
it('returns 400 when defaultVisibility is invalid', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
const app = buildApp(repo, { id: alice.id });
const res = await request(app)
.patch('/api/users/me/preferences')
.send({ defaultVisibility: 'bogus' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid defaultVisibility');
} finally {
repo.close();
}
});
it('returns 400 when defaultVisibilityOrgId is not one of the user orgs', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
const app = buildApp(repo, { id: alice.id, orgIds: ['10'] });
const res = await request(app)
.patch('/api/users/me/preferences')
.send({ defaultVisibility: 'org', defaultVisibilityOrgId: '99' });
expect(res.status).toBe(400);
} finally {
repo.close();
}
});
it('returns 400 when defaultVisibility=org is sent without defaultVisibilityOrgId', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
const app = buildApp(repo, { id: alice.id, orgIds: ['10'] });
for (const payload of [
{ defaultVisibility: 'org' },
{ defaultVisibility: 'org', defaultVisibilityOrgId: null },
{ defaultVisibility: 'org', defaultVisibilityOrgId: '' },
]) {
const res = await request(app).patch('/api/users/me/preferences').send(payload);
expect(res.status).toBe(400);
}
expect(repo.getUserById(alice.id)!.defaultVisibility).toBe('private');
} finally {
repo.close();
}
});
it('writes preferences on valid input and persists them', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
const app = buildApp(repo, { id: alice.id, orgIds: ['10'] });
const res = await request(app)
.patch('/api/users/me/preferences')
.send({ defaultVisibility: 'org', defaultVisibilityOrgId: '10' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
const after = repo.getUserById(alice.id);
expect(after!.defaultVisibility).toBe('org');
expect(after!.defaultVisibilityOrgId).toBe('10');
} finally {
repo.close();
}
});
it('returns 401 when unauthenticated', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const app = buildApp(repo, null);
const res = await request(app)
.patch('/api/users/me/preferences')
.send({ defaultVisibility: 'public' });
expect(res.status).toBe(401);
} finally {
repo.close();
}
});
});
+1190
View File
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { mountShareApi } from './share-api.js';
function setup(user?: { id: string; role: 'admin' | 'user' }) {
const tempDir = mkdtempSync(join(tmpdir(), 'share-api-'));
const repo = new Repository(join(tempDir, 'test.db'));
const app = express();
app.use(express.json());
// Mock user middleware (simulate authenticated user)
const effectiveUser = user ?? { id: 'user-1', role: 'admin' as const };
app.use((req, _res, next) => {
(req as any).user = {
...effectiveUser,
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
(req as any).isAuthenticated = () => true;
next();
});
mountShareApi(app, repo);
return { app, repo, tempDir };
}
describe('Share API', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
it('POST /api/local/tasks/:id/share generates token', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(200);
expect(res.body.shareToken).toBeTruthy();
expect(res.body.shareUrl).toContain(res.body.shareToken);
});
it('DELETE /api/local/tasks/:id/share removes token', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const res = await request(ctx.app).delete(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
const updated = await ctx.repo.getLocalTask(task.id);
expect(updated?.shareToken).toBeNull();
});
it('GET /api/shared/:token returns task info', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'shared task', body: 'body' });
const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const token = shareRes.body.shareToken;
const res = await request(ctx.app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
expect(res.body.task.title).toBe('shared task');
// ownerId と workspacePath は非公開
expect(res.body.task.ownerId).toBeUndefined();
expect(res.body.task.workspacePath).toBeUndefined();
});
it('GET /api/shared/:token returns 404 for unknown token', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const res = await request(ctx.app).get('/api/shared/nonexistent');
expect(res.status).toBe(404);
});
it('GET /api/shared/:token/comments returns comments', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
await ctx.repo.addLocalTaskComment(task.id, 'user', 'hello', 'comment');
const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const token = shareRes.body.shareToken;
const res = await request(ctx.app).get(`/api/shared/${token}/comments`);
expect(res.status).toBe(200);
expect(res.body.comments.length).toBe(1);
});
it('GET /api/shared/:token/files lists output files', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
const wsPath = join(tempDir, 'ws');
mkdirSync(join(wsPath, 'output'), { recursive: true });
writeFileSync(join(wsPath, 'output', 'result.md'), '# Result');
await ctx.repo.updateLocalTask(task.id, { workspacePath: wsPath });
const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const token = shareRes.body.shareToken;
const res = await request(ctx.app).get(`/api/shared/${token}/files`);
expect(res.status).toBe(200);
expect(res.body.entries.length).toBe(1);
expect(res.body.entries[0].name).toBe('result.md');
});
it('GET /api/shared/:token/files/raw serves file content', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
const wsPath = join(tempDir, 'ws');
mkdirSync(join(wsPath, 'output'), { recursive: true });
writeFileSync(join(wsPath, 'output', 'result.md'), '# Hello');
await ctx.repo.updateLocalTask(task.id, { workspacePath: wsPath });
const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const token = shareRes.body.shareToken;
const res = await request(ctx.app).get(`/api/shared/${token}/files/raw?path=result.md`);
expect(res.status).toBe(200);
});
// --- Cross-user authorization ---
it('POST /share by non-owner non-admin returns 404', async () => {
const ctx = setup({ id: 'bob', role: 'user' });
tempDir = ctx.tempDir;
// Alice owns the task
const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(404);
// Token was NOT generated
const after = await ctx.repo.getLocalTask(task.id);
expect(after?.shareToken).toBeFalsy();
});
it('DELETE /share by non-owner non-admin returns 404', async () => {
const ctx = setup({ id: 'bob', role: 'user' });
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
// Alice already shared the task (simulate by setting token directly via repo)
await ctx.repo.shareLocalTask(task.id);
const res = await request(ctx.app).delete(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(404);
// Token still exists (Bob's DELETE was rejected)
const after = await ctx.repo.getLocalTask(task.id);
expect(after?.shareToken).toBeTruthy();
});
it('POST /share by owner succeeds', async () => {
const ctx = setup({ id: 'alice', role: 'user' });
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(200);
expect(res.body.shareToken).toBeTruthy();
});
it('POST /share by admin succeeds for any task', async () => {
const ctx = setup({ id: 'admin-1', role: 'admin' });
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(200);
expect(res.body.shareToken).toBeTruthy();
});
});
+174
View File
@@ -0,0 +1,174 @@
import express, { Request, Response } from 'express';
import { readdirSync, statSync, readFileSync, mkdirSync } from 'fs';
import { join, resolve, sep, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { checkTaskOwnership } from './local-api-helpers.js';
function ensurePathWithin(baseDir: string, requestedPath: string): string {
const resolvedBase = resolve(baseDir);
const resolvedPath = resolve(baseDir, requestedPath);
if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) {
throw new Error('Path escapes workspace');
}
return resolvedPath;
}
function sanitizeTaskForPublic(task: Record<string, unknown>): Record<string, unknown> {
const { ownerId, workspacePath, body, ...safe } = task;
return safe;
}
export function mountShareApi(app: express.Application, repo: Repository): void {
// ── 公開エンドポイント(認証不要) ──
app.get('/api/shared/:token', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
res.json({ task: sanitizeTaskForPublic(task as unknown as Record<string, unknown>) });
} catch (err) {
logger.error(`Shared task API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch shared task' });
}
});
app.get('/api/shared/:token/comments', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
const comments = await repo.listLocalTaskComments(task.id);
res.json({ comments });
} catch (err) {
logger.error(`Shared comments API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch comments' });
}
});
app.get('/api/shared/:token/files', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = join(task.workspacePath, 'output');
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return {
name: entry.name,
path: relativeDir ? `${relativeDir}/${entry.name}` : entry.name,
kind: entry.isDirectory() ? 'directory' : 'file',
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
};
});
res.json({ basePath: 'output', path: relativeDir, entries });
} catch (err) {
logger.error(`Shared files API error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
app.get('/api/shared/:token/files/content', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
const rootDir = join(task.workspacePath, 'output');
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
logger.error(`Shared file content API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/shared/:token/files/raw', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
const rootDir = join(task.workspacePath, 'output');
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
logger.error(`Shared file raw API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/shared/:token/subtasks/activities', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(task.id), task.id);
if (!latestJob) { res.json({ subtasks: [] }); return; }
const subJobs = await repo.getSubJobs(latestJob.id);
const subtasks = subJobs.map(job => ({
jobId: job.id,
issueNumber: job.issueNumber,
status: job.status,
currentMovement: job.currentMovement ?? null,
currentActivity: job.currentActivity ?? null,
activityLog: '',
}));
res.json({ subtasks });
} catch (err) {
logger.error(`Shared subtask activities API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask activities' });
}
});
// ── 認証付きエンドポイント ──
app.post('/api/local/tasks/:taskId/share', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
const shareToken = await repo.shareLocalTask(taskId);
res.json({ shareToken, shareUrl: `/ui/shared/${shareToken}` });
} catch (err) {
logger.error(`Share task API error: ${err}`);
res.status(500).json({ error: 'Failed to share task' });
}
});
app.delete('/api/local/tasks/:taskId/share', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
await repo.unshareLocalTask(taskId);
res.json({ ok: true });
} catch (err) {
logger.error(`Unshare task API error: ${err}`);
res.status(500).json({ error: 'Failed to unshare task' });
}
});
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
registerShutdownHook,
runShutdown,
installSignalHandlers,
__resetShutdownForTests,
__getRegisteredHookCountForTests,
} from './shutdown.js';
describe('shutdown registry', () => {
let exitCalls: number[];
beforeEach(() => {
exitCalls = [];
__resetShutdownForTests({ exitFn: (code) => { exitCalls.push(code); } });
});
it('runs all registered hooks concurrently', async () => {
const order: string[] = [];
registerShutdownHook('slow', async () => {
await new Promise((r) => setTimeout(r, 20));
order.push('slow');
});
registerShutdownHook('fast', async () => {
order.push('fast');
});
await runShutdown('SIGTERM');
// Concurrent: fast resolves before slow even though it registered second.
expect(order).toEqual(['fast', 'slow']);
expect(exitCalls).toEqual([0]);
});
it('continues other hooks when one rejects', async () => {
const ran: string[] = [];
registerShutdownHook('broken', async () => { throw new Error('boom'); });
registerShutdownHook('ok', async () => { ran.push('ok'); });
await runShutdown('SIGTERM');
expect(ran).toEqual(['ok']);
// exit still called once with 0 — a hook failure must not block exit.
expect(exitCalls).toEqual([0]);
});
it('is idempotent across repeated invocations', async () => {
let hookCalls = 0;
registerShutdownHook('counter', async () => { hookCalls++; });
await runShutdown('SIGTERM');
await runShutdown('SIGINT');
expect(hookCalls).toBe(1);
expect(exitCalls).toEqual([0]);
});
it('treats sync hooks identically to async hooks', async () => {
const ran: string[] = [];
registerShutdownHook('sync', () => { ran.push('sync'); });
registerShutdownHook('async', async () => { ran.push('async'); });
await runShutdown('SIGTERM');
expect(ran.sort()).toEqual(['async', 'sync']);
expect(exitCalls).toEqual([0]);
});
it('exits even when no hooks are registered', async () => {
await runShutdown('SIGTERM');
expect(exitCalls).toEqual([0]);
});
it('counts each registerShutdownHook call', () => {
registerShutdownHook('a', () => {});
registerShutdownHook('b', () => {});
expect(__getRegisteredHookCountForTests()).toBe(2);
});
it('installSignalHandlers installs exactly one listener per signal even when called twice', () => {
// Snapshot existing listener counts so we don't false-positive on
// listeners installed by the test harness itself.
const sigtermBefore = process.listenerCount('SIGTERM');
const sigintBefore = process.listenerCount('SIGINT');
try {
installSignalHandlers();
installSignalHandlers();
installSignalHandlers();
expect(process.listenerCount('SIGTERM') - sigtermBefore).toBe(1);
expect(process.listenerCount('SIGINT') - sigintBefore).toBe(1);
} finally {
// Remove the listeners we just installed so they don't leak
// into subsequent tests (vitest shares the process). We can't
// grab a handle to the wrapper, so removeAllListeners (capped
// to the count we added) is the safe option.
const sigtermNow = process.listenerCount('SIGTERM');
const sigintNow = process.listenerCount('SIGINT');
const sigtermAdded = sigtermNow - sigtermBefore;
const sigintAdded = sigintNow - sigintBefore;
const sigtermListeners = process.listeners('SIGTERM');
const sigintListeners = process.listeners('SIGINT');
for (let i = sigtermListeners.length - sigtermAdded; i < sigtermListeners.length; i++) {
process.removeListener('SIGTERM', sigtermListeners[i]! as () => void);
}
for (let i = sigintListeners.length - sigintAdded; i < sigintListeners.length; i++) {
process.removeListener('SIGINT', sigintListeners[i]! as () => void);
}
}
});
it('logs and surfaces sync throws as rejections', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
registerShutdownHook('syncthrow', () => { throw new Error('sync-boom'); });
await runShutdown('SIGTERM');
expect(exitCalls).toEqual([0]);
warn.mockRestore();
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* shutdown.ts — central registry for process-exit cleanup hooks.
*
* Why this exists
* ───────────────
* Phase B + Phase C each installed their own `process.on('SIGTERM', …)`
* and `process.on('SIGINT', …)` handlers (SSH console teardown, then
* BackendStatusRegistry shutdown). Stacking N independent handlers
* per signal has three issues:
*
* 1. Node's default MaxListeners is 10. Phase D will add more
* subsystems; we'll start emitting MaxListenersExceededWarning.
* 2. No ordering guarantee. If two hooks both touch shared state
* (e.g. a logger flush + a worker drain), interleaving is
* non-deterministic.
* 3. No idempotence guard. Multiple signals (SIGTERM then SIGINT)
* would re-run every hook.
*
* The registry solves all three: subsystems register a single hook
* each, the registry installs exactly one listener per signal, hooks
* run concurrently (Promise.allSettled — one slow hook doesn't gate
* the others), and a `shutdownStarted` flag prevents re-entry.
*
* Test surface
* ────────────
* `runShutdown` and `__resetShutdownForTests` are exported so unit
* tests can drive the pure logic without raising real signals (which
* would terminate the test runner). `installSignalHandlers` is the
* production entry point and is called once from `startServer`.
*/
import { logger } from '../logger.js';
export type ShutdownHook = () => Promise<void> | void;
interface RegisteredHook {
name: string;
fn: ShutdownHook;
}
const hooks: RegisteredHook[] = [];
let shutdownStarted = false;
let signalsInstalled = false;
let exitFn: (code: number) => void = (code) => process.exit(code);
/**
* Register a hook to run during graceful shutdown.
*
* `name` is used in shutdown logs only — it should describe the
* subsystem (e.g. `"ssh-console"`, `"backend-status-registry"`) so
* stuck or slow shutdowns are diagnosable from the log line.
*
* Hooks may be sync or return a Promise. Errors are caught and
* logged; one hook's failure never blocks the others.
*/
export function registerShutdownHook(name: string, fn: ShutdownHook): void {
hooks.push({ name, fn });
}
/**
* Drain all registered hooks and exit the process.
*
* Concurrent (Promise.allSettled) rather than sequential because the
* hooks operate on independent subsystems — sequential would just
* sum their latencies (and BackendStatusRegistry.stop alone can take
* up to ~3s waiting for in-flight probes to abort).
*
* Idempotent: if shutdown is already in progress, second calls are
* silently dropped (no double-drain, no double-exit).
*/
export async function runShutdown(signal: string): Promise<void> {
if (shutdownStarted) return;
shutdownStarted = true;
logger.info(`[shutdown] received ${signal}, draining ${hooks.length} hook(s)`);
const results = await Promise.allSettled(
hooks.map(async (h) => {
try {
await h.fn();
} catch (e) {
// Re-throw so allSettled records `rejected` with the original
// reason; the catch is here only to ensure sync throws surface
// the same way as async rejections.
throw e instanceof Error ? e : new Error(String(e));
}
}),
);
for (let i = 0; i < results.length; i++) {
const r = results[i]!;
const h = hooks[i]!;
if (r.status === 'rejected') {
const reason = r.reason instanceof Error ? r.reason.message : String(r.reason);
logger.warn(`[shutdown] hook ${h.name} rejected: ${reason}`);
}
}
exitFn(0);
}
/**
* Install the SIGTERM / SIGINT listeners exactly once.
*
* Safe to call multiple times — subsequent calls are no-ops so unit
* tests and integration paths can both invoke it without doubling
* the listeners.
*/
export function installSignalHandlers(): void {
if (signalsInstalled) return;
signalsInstalled = true;
process.on('SIGTERM', () => { void runShutdown('SIGTERM'); });
process.on('SIGINT', () => { void runShutdown('SIGINT'); });
}
/**
* Test-only reset. Clears registered hooks, the started flag, the
* installed-signals flag, and the exit function. Real production
* code must never call this — the singleton state is the entire
* point of the registry.
*/
export function __resetShutdownForTests(opts?: { exitFn?: (code: number) => void }): void {
hooks.length = 0;
shutdownStarted = false;
signalsInstalled = false;
exitFn = opts?.exitFn ?? ((code) => process.exit(code));
}
/** Test-only accessor for the current registered-hook count. */
export function __getRegisteredHookCountForTests(): number {
return hooks.length;
}
+420
View File
@@ -0,0 +1,420 @@
import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, lstatSync, renameSync, rmSync } from 'fs';
import { join, relative } from 'path';
import { randomBytes } from 'crypto';
import type { SkillCatalog, SkillEntry } from '../engine/skills.js';
import { VALID_SKILL_NAME } from '../engine/skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../engine/skills-scanner.js';
import { logger } from '../logger.js';
import { handleInstallFromUrl } from './skills-git-install.js';
const MAX_CONTENT_SIZE = 64 * 1024; // 64 KB
export interface MountSkillsApiOptions {
skillCatalog: SkillCatalog;
requireAuth?: RequestHandler;
requireAdmin?: RequestHandler;
authActive?: boolean;
auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise<void>;
}
type AuthedUser = { id?: string; role?: string };
function getUser(req: Request): AuthedUser | undefined {
return (req as any).user as AuthedUser | undefined;
}
function getUserId(req: Request): string {
const user = getUser(req);
return user?.id ?? 'local';
}
function isAdmin(req: Request): boolean {
const user = getUser(req);
return user?.role === 'admin';
}
/**
* Recursively list files in a directory, skipping symlinks.
* Returns paths relative to `baseDir`.
*/
function listDirFiles(baseDir: string, maxDepth: number = 5): string[] {
const results: string[] = [];
function walk(dir: string, depth: number): void {
if (depth > maxDepth) return;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
if (stat.isSymbolicLink()) continue;
if (stat.isDirectory()) {
walk(fullPath, depth + 1);
} else if (stat.isFile()) {
results.push(relative(baseDir, fullPath));
}
}
}
walk(baseDir, 0);
return results;
}
export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): void {
const { skillCatalog } = opts;
// JSON body parser for skills endpoints
app.use('/api/skills', express.json());
// Auth gating
if (opts.authActive && opts.requireAuth) {
app.use('/api/skills', opts.requireAuth);
}
// ── GET /api/skills ── list skills ──────────────────────────────
app.get('/api/skills', (req: Request, res: Response) => {
try {
const userId = getUserId(req);
const scope = (req.query.scope as string) ?? 'all';
if (!['all', 'system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: all, system, user' });
return;
}
const entries = skillCatalog.getForUser(userId);
const filtered = scope === 'all'
? entries
: entries.filter(e => e.source === scope);
const skills = filtered.map(e => ({
name: e.name,
description: e.description,
triggers: e.triggers,
source: e.source,
hasDir: e.dirPath !== null,
}));
res.json({ skills });
} catch (e) {
res.status(500).json({ error: `Failed to list skills: ${e}` });
}
});
// ── POST /api/skills/install-from-url ── Git URL install ────────
// Must be before /:name routes to avoid Express matching 'install-from-url' as :name
app.post('/api/skills/install-from-url', handleInstallFromUrl({
skillCatalog: opts.skillCatalog,
auditLog: opts.auditLog,
}));
// ── GET /api/skills/:name ── skill detail ──────────────────────
app.get('/api/skills/:name', (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
try {
const userId = getUserId(req);
const scopeHint = req.query.scope as string | undefined;
// Find the entry matching name (and optional scope filter)
const entries = skillCatalog.getForUser(userId);
let entry: SkillEntry | undefined;
if (scopeHint && ['system', 'user'].includes(scopeHint)) {
entry = entries.find(e => e.name === name && e.source === scopeHint);
}
if (!entry) {
entry = entries.find(e => e.name === name);
}
if (!entry) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Read content via catalog
const contentResult = skillCatalog.getSkillContent(name, userId);
const content = contentResult?.content ?? '';
// Read raw file for frontmatter
let raw = '';
try {
raw = readFileSync(entry.filePath, 'utf-8');
} catch { /* skip */ }
// File listing for directory skills
let files: string[] | undefined;
if (entry.dirPath) {
files = listDirFiles(entry.dirPath);
}
// Security scan
let findings;
if (entry.dirPath) {
findings = scanSkillDirectory(entry.dirPath);
} else {
findings = scanSkillContent(raw);
}
res.json({
name: entry.name,
description: entry.description,
triggers: entry.triggers,
source: entry.source,
hasDir: entry.dirPath !== null,
content,
files,
findings,
maxSeverity: maxSeverity(findings),
});
} catch (e) {
res.status(500).json({ error: `Failed to read skill: ${e}` });
}
});
// ── POST /api/skills ── create single-file skill ──────────────
app.post('/api/skills', async (req: Request, res: Response) => {
try {
const { name, content, scope } = req.body ?? {};
// Validate name
if (!name || typeof name !== 'string' || !VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name (lowercase alphanumeric, hyphens, underscores)' });
return;
}
// Validate scope
if (!scope || !['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// Validate content
if (!content || typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) {
res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can create system skills' });
return;
}
const userId = getUserId(req);
// Determine destination directory
const destDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Check for existing skill (directory or flat file)
const destDirPath = join(destDir, name);
const destFlatPath = join(destDir, `${name}.md`);
if (existsSync(destDirPath) || existsSync(destFlatPath)) {
res.status(409).json({ error: 'Skill already exists' });
return;
}
// Scan content before writing
const findings = scanSkillContent(content);
const severity = maxSeverity(findings);
// Always create directory format: {name}/SKILL.md
const tmpDir = join(destDir, `.tmp-${randomBytes(8).toString('hex')}`);
mkdirSync(tmpDir, { recursive: true });
writeFileSync(join(tmpDir, 'SKILL.md'), content, 'utf-8');
renameSync(tmpDir, destDirPath);
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.create', actor, { name, scope, severity });
}
logger.info(`[skills-api] created skill=${name} scope=${scope} actor=${actor} severity=${severity}`);
res.status(201).json({ name, scope, severity, findings });
} catch (e) {
res.status(500).json({ error: `Failed to create skill: ${e}` });
}
});
// ── PUT /api/skills/:name ── edit skill content ───────────────
app.put('/api/skills/:name', async (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
const scope = req.query.scope as string | undefined;
if (!scope) {
res.status(400).json({ error: 'scope query parameter is required' });
return;
}
if (!['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can edit system skills' });
return;
}
try {
const { content } = req.body ?? {};
if (!content || typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) {
res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` });
return;
}
const userId = getUserId(req);
const baseDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Find the skill file: either flat file or directory with SKILL.md
let targetPath: string | null = null;
const flatPath = join(baseDir, `${name}.md`);
const dirSkillPath = join(baseDir, name, 'SKILL.md');
if (existsSync(dirSkillPath)) {
targetPath = dirSkillPath;
} else if (existsSync(flatPath)) {
targetPath = flatPath;
}
if (!targetPath) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Scan new content
const findings = scanSkillContent(content);
const severity = maxSeverity(findings);
// Atomic write: tmpfile in same directory as target → rename
const targetDir = targetPath === dirSkillPath ? join(baseDir, name) : baseDir;
const tmpPath = join(targetDir, `.tmp-${randomBytes(8).toString('hex')}.md`);
writeFileSync(tmpPath, content, 'utf-8');
renameSync(tmpPath, targetPath);
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.update', actor, { name, scope, severity });
}
logger.info(`[skills-api] updated skill=${name} scope=${scope} actor=${actor} severity=${severity}`);
res.json({ ok: true, severity, findings });
} catch (e) {
res.status(500).json({ error: `Failed to update skill: ${e}` });
}
});
// ── DELETE /api/skills/:name ── delete skill ──────────────────
app.delete('/api/skills/:name', async (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
const scope = req.query.scope as string | undefined;
if (!scope) {
res.status(400).json({ error: 'scope query parameter is required' });
return;
}
if (!['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can delete system skills' });
return;
}
try {
const userId = getUserId(req);
const baseDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Find the skill: directory or flat file
const dirPath = join(baseDir, name);
const flatPath = join(baseDir, `${name}.md`);
let deleted = false;
if (existsSync(dirPath) && lstatSync(dirPath).isDirectory()) {
rmSync(dirPath, { recursive: true, force: true });
deleted = true;
} else if (existsSync(flatPath) && lstatSync(flatPath).isFile()) {
unlinkSync(flatPath);
deleted = true;
}
if (!deleted) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.delete', actor, { name, scope });
}
logger.info(`[skills-api] deleted skill=${name} scope=${scope} actor=${actor}`);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to delete skill: ${e}` });
}
});
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { validateUrl } from './skills-git-install.js';
describe('validateUrl', () => {
it('accepts a normal https git URL', () => {
expect(validateUrl('https://github.com/owner/repo')).toBeNull();
expect(validateUrl('https://gitea.example.com/team/skills.git')).toBeNull();
});
it('rejects non-https schemes', () => {
expect(validateUrl('http://github.com/o/r')).toMatch(/https/);
expect(validateUrl('ssh://[email protected]/o/r')).toMatch(/https/);
expect(validateUrl('file:///etc/passwd')).toMatch(/https/);
expect(validateUrl('git://github.com/o/r')).toMatch(/https/);
});
it('rejects empty / non-string input', () => {
expect(validateUrl('')).toMatch(/required/);
// @ts-expect-error intentional bad input
expect(validateUrl(undefined)).toMatch(/required/);
});
// Regression: shell command injection via the git URL.
// Previously only [;&|`$] were blocked, so a double-quote + newline could
// break out of `git clone "${url}"` and run an arbitrary command.
it('rejects newline + quote command-injection payloads', () => {
expect(validateUrl('https://x"\nid #')).toMatch(/disallowed|control/);
expect(validateUrl('https://x"\r\ntouch /tmp/pwned')).toMatch(/disallowed|control/);
expect(validateUrl('https://github.com/o/r"; rm -rf ~ #')).toMatch(/disallowed/);
});
it('rejects shell metacharacters and whitespace', () => {
for (const bad of ['https://x;id', 'https://x|id', 'https://x&&id', 'https://x`id`', 'https://x$(id)', "https://x'", 'https://a b', 'https://x<y']) {
expect(validateUrl(bad)).toMatch(/disallowed/);
}
});
it('rejects control characters', () => {
expect(validateUrl('https://x\x00y')).toMatch(/control|disallowed/);
});
it('rejects strings that pass the prefix but are not valid URLs', () => {
// No host after the scheme.
expect(validateUrl('https://')).not.toBeNull();
});
});
+363
View File
@@ -0,0 +1,363 @@
/**
* Git URL install handler for skills.
* POST /api/skills/install-from-url
*
* Two modes:
* - Preview: POST { url } → returns detected skills + scan findings
* - Install: POST { url, selectedSkills: [...] } → installs selected skills
*/
import type { Request, Response } from 'express';
import {
existsSync, mkdirSync, mkdtempSync, rmSync, cpSync,
readdirSync, lstatSync, readFileSync, writeFileSync,
} from 'fs';
import { join, relative } from 'path';
import { execFileSync } from 'child_process';
import { tmpdir } from 'os';
import matter from 'gray-matter';
import type { SkillCatalog } from '../engine/skills.js';
import { VALID_SKILL_NAME } from '../engine/skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity, type ScanFinding } from '../engine/skills-scanner.js';
import { logger } from '../logger.js';
// ── Types ───────────────────────────────────────────────────────────────────
export interface GitInstallDeps {
skillCatalog: SkillCatalog;
auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise<void>;
}
export interface DetectedSkill {
name: string;
description: string;
relativePath: string;
fullPath: string;
isDir: boolean;
findings: ScanFinding[];
maxSeverity: 'high' | 'medium' | 'none';
}
// ── Constants ───────────────────────────────────────────────────────────────
const MAX_REPO_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
const GIT_CLONE_TIMEOUT_MS = 30_000;
const SKIP_DIRS = new Set(['.git', 'node_modules', '.github', '.vscode']);
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
* Walk a cloned directory looking for skills:
* - Directories containing SKILL.md
* - Standalone .md files with frontmatter `name`
*/
export function detectSkillsInDir(rootDir: string): DetectedSkill[] {
const results: DetectedSkill[] = [];
function walk(dir: string): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
// Skip symlinks entirely (security)
if (stat.isSymbolicLink()) continue;
if (stat.isDirectory()) {
if (SKIP_DIRS.has(entry)) continue;
// Check if this directory is a skill (has SKILL.md)
const skillMdPath = join(fullPath, 'SKILL.md');
if (existsSync(skillMdPath)) {
try {
const skillStat = lstatSync(skillMdPath);
if (skillStat.isSymbolicLink()) continue; // Skip symlinked SKILL.md
} catch {
continue;
}
try {
const raw = readFileSync(skillMdPath, 'utf-8');
const { data } = matter(raw);
if (data && typeof data.name === 'string' && data.name && VALID_SKILL_NAME.test(data.name)) {
const findings = scanSkillDirectory(fullPath);
results.push({
name: data.name,
description: typeof data.description === 'string' ? data.description : '',
relativePath: relative(rootDir, fullPath),
fullPath,
isDir: true,
findings,
maxSeverity: maxSeverity(findings),
});
}
} catch {
// Unreadable SKILL.md — skip
}
// Don't recurse into skill directories (they're self-contained)
continue;
}
// Not a skill dir — recurse
walk(fullPath);
continue;
}
// Standalone .md file
if (stat.isFile() && entry.endsWith('.md')) {
try {
const raw = readFileSync(fullPath, 'utf-8');
const { data } = matter(raw);
if (data && typeof data.name === 'string' && data.name && VALID_SKILL_NAME.test(data.name)) {
const findings = scanSkillContent(raw);
results.push({
name: data.name,
description: typeof data.description === 'string' ? data.description : '',
relativePath: relative(rootDir, fullPath),
fullPath,
isDir: false,
findings,
maxSeverity: maxSeverity(findings),
});
}
} catch {
// Unreadable .md — skip
}
}
}
}
walk(rootDir);
return results;
}
/**
* Validate that a URL is safe for git clone (SSRF defense).
* Only HTTPS URLs are allowed.
*/
export function validateUrl(url: string): string | null {
if (!url || typeof url !== 'string') return 'url is required';
const trimmed = url.trim();
if (!trimmed.startsWith('https://')) {
return 'Only https:// URLs are allowed (http://, file://, ssh://, git:// and local paths are rejected for security)';
}
// Reject control characters (newlines, NUL, etc.), shell metacharacters,
// quotes and whitespace. The clone now runs via execFile (no shell), so this
// is defense-in-depth, but it also prevents a newline+quote breakout if the
// URL is ever reused in a shell context.
if (/[\u0000-\u001f\u007f;&|`$"'\\<>(){}\s]/.test(trimmed)) {
return 'URL contains disallowed characters';
}
// Must parse as a real https URL.
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return 'URL is not a valid URL';
}
if (parsed.protocol !== 'https:') {
return 'Only https:// URLs are allowed';
}
return null;
}
// ── Handler ─────────────────────────────────────────────────────────────────
export function handleInstallFromUrl(deps: GitInstallDeps): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
const { url, scope: rawScope, selectedSkills } = req.body ?? {};
// 1. Validate URL
const urlError = validateUrl(url);
if (urlError) {
res.status(400).json({ error: urlError });
return;
}
// 2. Validate scope
const scope: 'system' | 'user' = rawScope === 'system' ? 'system' : 'user';
// 3. System scope requires admin
const user = req.user as Express.User | undefined;
if (scope === 'system' && (!user || user.role !== 'admin')) {
res.status(403).json({ error: 'System-scope install requires admin role' });
return;
}
const userId = user?.id ?? 'anonymous';
// 4. Clone to temp directory
const tmpBase = mkdtempSync(join(tmpdir(), 'skill-git-'));
const cloneDir = join(tmpBase, 'repo');
try {
try {
// execFile (no shell): url and cloneDir are passed as literal argv
// entries, so shell metacharacters in `url` cannot inject commands.
// `--` terminates option parsing so a `url` starting with `-` cannot
// be treated as a git flag (defense-in-depth; validateUrl already
// requires an https:// prefix).
execFileSync(
'git',
['clone', '--depth', '1', '--no-recurse-submodules', '--no-checkout', '--', url, cloneDir],
{ timeout: GIT_CLONE_TIMEOUT_MS, stdio: 'pipe' },
);
execFileSync(
'git',
['-C', cloneDir, 'checkout', 'HEAD', '--', '.'],
{ timeout: GIT_CLONE_TIMEOUT_MS, stdio: 'pipe' },
);
} catch (cloneErr: unknown) {
const isTimeout = cloneErr instanceof Error && 'killed' in cloneErr && (cloneErr as any).killed;
if (isTimeout) {
res.status(408).json({ error: 'Git clone timed out (30s limit)' });
return;
}
const msg = cloneErr instanceof Error ? cloneErr.message : String(cloneErr);
res.status(400).json({ error: `Git clone failed: ${msg.slice(0, 300)}` });
return;
}
// 5. Verify clone exists
if (!existsSync(cloneDir)) {
res.status(400).json({ error: 'Git clone produced no output directory' });
return;
}
// 6. Size check
try {
const duOutput = execFileSync('du', ['-sb', cloneDir], { encoding: 'utf-8', timeout: 10_000 });
const sizeBytes = parseInt(duOutput.split('\t')[0], 10);
if (sizeBytes > MAX_REPO_SIZE_BYTES) {
res.status(400).json({
error: `Repository too large: ${Math.round(sizeBytes / 1024 / 1024)}MB exceeds 50MB limit`,
});
return;
}
} catch {
// du failed — continue (non-critical)
logger.warn('[skills-git-install] du -sb failed, skipping size check');
}
// 7. Detect skills
const detected = detectSkillsInDir(cloneDir);
if (detected.length === 0) {
res.status(400).json({
error: 'No skills found in repository. Skills must be directories with SKILL.md or standalone .md files with frontmatter "name".',
});
return;
}
// 8. Preview mode — explicitly requested via preview flag
const previewMode = req.body?.preview === true;
if (previewMode) {
const preview = detected.map(s => ({
name: s.name,
description: s.description,
relativePath: s.relativePath,
isDir: s.isDir,
findings: s.findings,
maxSeverity: s.maxSeverity,
}));
res.json({ preview, totalDetected: detected.length });
return;
}
// 9. Install mode — selectedSkills or all detected
const selectedSet = Array.isArray(selectedSkills) && selectedSkills.length > 0
? new Set(selectedSkills.filter((s: unknown) => typeof s === 'string'))
: new Set(detected.map(s => s.name));
if (selectedSet.size === 0) {
res.status(400).json({ error: 'No skills detected in repository' });
return;
}
// Resolve target directory
const targetDir = scope === 'system'
? deps.skillCatalog.getSystemDir()
: deps.skillCatalog.getUserSkillDir(userId);
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true });
}
const installed: string[] = [];
const errors: string[] = [];
for (const skill of detected) {
if (!selectedSet.has(skill.name)) continue;
try {
if (skill.isDir) {
// Copy entire skill directory
const destDir = join(targetDir, skill.name);
cpSync(skill.fullPath, destDir, { recursive: true });
} else {
// Single .md file → create as directory format ({name}/SKILL.md)
const content = readFileSync(skill.fullPath, 'utf-8');
const destDir = join(targetDir, skill.name);
mkdirSync(destDir, { recursive: true });
writeFileSync(join(destDir, 'SKILL.md'), content, 'utf-8');
}
installed.push(skill.name);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`${skill.name}: ${msg.slice(0, 200)}`);
logger.warn(`[skills-git-install] failed to install skill=${skill.name} err=${msg}`);
}
}
// Check for requested skills that weren't found in the repo
for (const name of selectedSet) {
if (!installed.includes(name) && !errors.some(e => e.startsWith(`${name}:`))) {
errors.push(`${name}: not found in repository`);
}
}
// 10. Invalidate cache
if (scope === 'system') {
deps.skillCatalog.refreshSystem();
} else {
deps.skillCatalog.invalidate(userId);
}
// 11. Audit log
if (deps.auditLog && installed.length > 0) {
deps.auditLog(null, 'skill_install_from_url', userId, {
url,
scope,
installed,
errors: errors.length > 0 ? errors : undefined,
}).catch(err => {
logger.warn(`[skills-git-install] audit log failed err=${err}`);
});
}
logger.info(`[skills-git-install] installed=${installed.length} errors=${errors.length} scope=${scope} user=${userId}`);
// 12. Return result
res.json({ installed, errors: errors.length > 0 ? errors : undefined });
} finally {
// Always clean up temp directory
try {
rmSync(tmpBase, { recursive: true, force: true });
} catch (cleanupErr) {
logger.warn(`[skills-git-install] tmpdir cleanup failed: ${cleanupErr}`);
}
}
};
}
+787
View File
@@ -0,0 +1,787 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import {
createSshUserRouter,
createSshAdminRouter,
type SshApiDeps,
type SshTester,
} from './ssh-api.js';
import { createConnectionRepo } from '../ssh/connection-repo.js';
import { createGrantsRepo } from '../ssh/grants-repo.js';
import { createAuditRepo } from '../ssh/audit-repo.js';
import { createAbuseRepo } from '../ssh/abuse-repo.js';
import { createAccessResolver } from '../ssh/access.js';
import { createMaintenanceController } from '../ssh/maintenance.js';
import { createAdminRateLimiter } from '../ssh/admin-rate-limit.js';
const VALID_KEY = 'a'.repeat(64);
const VALID_UUID = '6f9619ff-8b86-d011-b42d-00c04fc964ff';
const SAMPLE_PEM = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakeKey\n-----END OPENSSH PRIVATE KEY-----';
const SAMPLE_FP = 'SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCD';
const openDbs: Database.Database[] = [];
function makeDb(): Database.Database {
process.env.MCP_ENCRYPTION_KEY = VALID_KEY;
const db = new Database(':memory:');
openDbs.push(db);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?), (?), (?)').run('alice', 'bob', 'admin1');
return db;
}
function makeFakeTester(verdict: 'first_observe' | 'mismatch' | 'pass' = 'first_observe'): SshTester {
return {
async test() {
return {
fingerprint: 'SHA256:hostkeyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
hostKeyB64: Buffer.from([0, 0, 0, 11, ...Buffer.from('ssh-ed25519'), 0xaa]).toString('base64'),
hostKeyType: 'ssh-ed25519',
verdict,
};
},
};
}
interface Harness {
db: Database.Database;
app: express.Application;
deps: SshApiDeps;
maintenance: ReturnType<typeof createMaintenanceController>;
rateLimiter: ReturnType<typeof createAdminRateLimiter>;
}
function makeHarness(opts: {
userId?: string;
isAdmin?: boolean;
isAnon?: boolean;
orgIds?: string[];
tester?: SshTester;
forceUnlockLimit?: { windowMs: number; maxRequests: number };
onAccessRevoked?: SshApiDeps['onAccessRevoked'];
} = {}): Harness {
const db = makeDb();
const connectionRepo = createConnectionRepo(db);
const grantsRepo = createGrantsRepo(db);
const auditRepo = createAuditRepo(db);
const abuseRepo = createAbuseRepo(db, { windowMinutes: 10, failureThreshold: 5, lockMinutes: 30 });
const accessResolver = createAccessResolver(grantsRepo, { adminBypassesGrants: true });
const maintenance = createMaintenanceController();
const rateLimiter = createAdminRateLimiter(opts.forceUnlockLimit ?? { windowMs: 60_000, maxRequests: 10 });
const userId = opts.userId ?? 'alice';
const isAdmin = !!opts.isAdmin;
const isAnon = !!opts.isAnon;
const requireAuth: express.RequestHandler = (_req, res, next) => {
if (isAnon) { res.status(401).json({ error: 'unauthorized' }); return; }
next();
};
const requireAdmin: express.RequestHandler = (_req, res, next) => {
if (isAnon) { res.status(401).json({ error: 'unauthorized' }); return; }
if (!isAdmin) { res.status(403).json({ error: 'admin_required' }); return; }
next();
};
// Stub encryption: store the PEM bytes prefixed with a marker so decrypt
// can verify roundtrip. Real impl uses src/ssh/crypto.ts.
const SAMPLE_PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTPUBKEY';
const encryptKeyMaterial: SshApiDeps['encryptKeyMaterial'] = (_ownerId, pem, passphrase) => ({
blob: Buffer.concat([Buffer.from('ENC:'), pem]),
passphraseBlob: passphrase ? Buffer.concat([Buffer.from('PEN:'), passphrase]) : null,
keyVersion: 1,
fingerprint: SAMPLE_FP,
publicKey: SAMPLE_PUBKEY,
});
const decryptKeyMaterial: SshApiDeps['decryptKeyMaterial'] = (_ownerId, blob) => {
if (!blob.subarray(0, 4).equals(Buffer.from('ENC:'))) throw new Error('decrypt: bad blob');
return Buffer.from(blob.subarray(4));
};
const decryptPassphrase: SshApiDeps['decryptPassphrase'] = (_ownerId, blob) => {
if (!blob) return null;
if (!blob.subarray(0, 4).equals(Buffer.from('PEN:'))) throw new Error('decrypt: bad pass blob');
return Buffer.from(blob.subarray(4));
};
const generateKeypair: SshApiDeps['generateKeypair'] = (keyType) => ({
privateKeyPem: Buffer.from(`STUB-PEM-${keyType}`, 'utf8'),
publicKey: `ssh-${keyType === 'rsa-4096' ? 'rsa' : 'ed25519'} AAAAGENERATED${keyType}`,
});
const derivePublicKey: SshApiDeps['derivePublicKey'] = (_ownerId, _blob, _passBlob) => SAMPLE_PUBKEY;
const deps: SshApiDeps = {
db,
requireAuth,
requireAdmin,
getUserId: () => (isAnon ? null : userId),
isAdmin: () => isAdmin,
getOrgIds: () => opts.orgIds ?? [],
connectionRepo,
grantsRepo,
auditRepo,
abuseRepo,
accessResolver,
maintenance,
forceUnlockLimiter: rateLimiter,
encryptKeyMaterial,
decryptKeyMaterial,
decryptPassphrase,
generateKeypair,
derivePublicKey,
sshTester: opts.tester ?? makeFakeTester(),
onAccessRevoked: opts.onAccessRevoked,
};
const app = express();
app.use(express.json());
app.use('/api/ssh', createSshUserRouter(deps));
app.use('/api/ssh/admin', createSshAdminRouter(deps));
return { db, app, deps, maintenance, rateLimiter };
}
async function createOwnedConnection(h: Harness, overrides: Record<string, unknown> = {}): Promise<string> {
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'prod',
host: 'srv.example.com',
port: 22,
username: 'deploy',
privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/home/deploy',
...overrides,
});
if (res.status !== 201) throw new Error(`unexpected status ${res.status}: ${JSON.stringify(res.body)}`);
return res.body.connection.id;
}
afterEach(() => {
for (const db of openDbs) {
try { db.close(); } catch {}
}
openDbs.length = 0;
delete process.env.MCP_ENCRYPTION_KEY;
});
// ──────────────────────────────────────────────────────────────────────
// Auth + maintenance + reason gating
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: auth gating', () => {
it('GET /api/ssh/connections requires auth', async () => {
const h = makeHarness({ isAnon: true });
const res = await request(h.app).get('/api/ssh/connections');
expect(res.status).toBe(401);
});
it('POST /api/ssh/connections requires auth', async () => {
const h = makeHarness({ isAnon: true });
const res = await request(h.app).post('/api/ssh/connections').send({});
expect(res.status).toBe(401);
});
it('GET /api/ssh/admin/connections requires admin', async () => {
const h = makeHarness({ isAdmin: false });
const res = await request(h.app).get('/api/ssh/admin/connections');
expect(res.status).toBe(403);
});
it('POST /api/ssh/admin/globals requires admin', async () => {
const h = makeHarness({ isAdmin: false });
const res = await request(h.app).post('/api/ssh/admin/globals').send({ reason: 'admin test' });
expect(res.status).toBe(403);
});
});
describe('SSH API: maintenance mode', () => {
it('blocks user POST /connections with 503 + Retry-After', async () => {
const h = makeHarness();
h.maintenance.enter('rotating master key');
const res = await request(h.app).post('/api/ssh/connections').send({});
expect(res.status).toBe(503);
expect(res.headers['retry-after']).toBe('30');
expect(res.body.error).toBe('rotation_in_progress');
});
it('blocks admin PATCH disable with 503', async () => {
const h = makeHarness({ isAdmin: true });
h.maintenance.enter('rotating');
const res = await request(h.app)
.patch('/api/ssh/admin/connections/anyid/disable')
.send({ reason: 'maintenance test reason' });
expect(res.status).toBe(503);
});
it('does NOT block read endpoints during maintenance', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
h.maintenance.enter('rotating');
const res = await request(h.app).get('/api/ssh/connections');
expect(res.status).toBe(200);
expect(res.body.connections.find((c: { id: string }) => c.id === id)).toBeTruthy();
});
});
describe('SSH API: reason gating', () => {
it('admin disable rejects missing reason with 400', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app).patch(`/api/ssh/admin/connections/${id}/disable`).send({});
expect(res.status).toBe(400);
});
it('admin disable rejects reason < 8 chars', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/admin/connections/${id}/disable`)
.send({ reason: 'short' });
expect(res.status).toBe(400);
expect(String(res.body.error)).toMatch(/at least 8/);
});
it('admin grant create requires reason', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'bob',
pieceName: 'general',
});
expect(res.status).toBe(400);
});
});
// ──────────────────────────────────────────────────────────────────────
// User connection CRUD
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: user CRUD', () => {
it('POST /connections creates a user-owned connection', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
expect(id).toBeTruthy();
const list = await request(h.app).get('/api/ssh/connections');
expect(list.body.connections.map((c: { id: string }) => c.id)).toContain(id);
});
it('POST /connections rejects missing required fields', async () => {
const h = makeHarness();
const res = await request(h.app).post('/api/ssh/connections').send({ label: 'incomplete' });
expect(res.status).toBe(400);
});
it('POST /connections rejects allowRemoteUnrestricted (admin-only)', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'x', host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/safe', allowRemoteUnrestricted: true,
});
expect(res.status).toBe(403);
expect(res.body.error).toBe('allow_remote_unrestricted_admin_only');
});
it('POST /connections rejects allowPrivateAddresses (admin-only)', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'x', host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/safe', allowPrivateAddresses: true,
});
expect(res.status).toBe(403);
});
it('POST /connections rejects bad remotePathPrefix (../)', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'x', host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/safe/../etc',
});
expect(res.status).toBe(400);
});
it('GET /connections returns 404 for another user\'s connection', async () => {
const h = makeHarness({ userId: 'alice' });
const id = await createOwnedConnection(h);
// Switch to bob (different harness, same DB? no — we'd need a shared DB).
// Easier test: PATCH /connections/:id as bob — fail with 403/404.
// Build a "bob" harness that reuses the underlying schema layer.
const hBob = makeHarness({ userId: 'bob' });
// Pre-create the same connection in bob's DB (different DBs, so we must replicate).
// Different approach: insert directly via repo into a single shared DB.
const idShared = await createOwnedConnection(h);
expect(idShared).toBeTruthy();
// For real isolation we'd need a shared DB; here we just assert the API
// returns 404 for an arbitrary unknown id from a different "user" harness.
const res = await request(hBob.app).get(`/api/ssh/connections/${idShared}`);
expect(res.status).toBe(404);
// Suppress unused warnings.
void id;
});
it('GET /connections/:id returns own', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app).get(`/api/ssh/connections/${id}`);
expect(res.status).toBe(200);
expect(res.body.connection.id).toBe(id);
// Encrypted blob fields are stripped.
expect(res.body.connection.privateKeyEnc).toBeUndefined();
expect(res.body.connection.passphraseEnc).toBeUndefined();
// Public key is derived and returned so the user can paste it into
// authorized_keys.
expect(res.body.publicKey).toMatch(/^ssh-/);
});
it('POST /connections with keypairSource=generate returns publicKey and ignores privateKeyPem', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'gen', host: 'h', port: 22, username: 'u',
remotePathPrefix: '/safe',
keypairSource: 'generate',
generateKeyType: 'ed25519',
// Stale user-supplied key should be ignored when generating.
privateKeyPem: 'this-should-be-ignored',
});
expect(res.status).toBe(201);
expect(res.body.connection.id).toBeTruthy();
// The mock `encryptKeyMaterial` returns a fixed SAMPLE_PUBKEY regardless of
// generateKeyType — the algorithm-correctness of the generated key is
// unit-tested in crypto.test.ts. Here we only verify the API endpoint
// surfaces a public key field at all when keypairSource=generate is used.
expect(res.body.publicKey).toMatch(/^ssh-/);
});
it('POST /connections with keypairSource=generate accepts rsa-4096 keyType', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'gen-rsa', host: 'h', port: 22, username: 'u',
remotePathPrefix: '/safe',
keypairSource: 'generate',
generateKeyType: 'rsa-4096',
});
expect(res.status).toBe(201);
expect(res.body.publicKey).toMatch(/^ssh-/);
});
it('PATCH /connections/:id updates own', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/connections/${id}`)
.send({ label: 'renamed' });
expect(res.status).toBe(200);
expect(res.body.connection.label).toBe('renamed');
});
it('PATCH /connections/:id rejects admin-only flag toggle', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/connections/${id}`)
.send({ allowRemoteUnrestricted: true });
expect(res.status).toBe(403);
});
it('DELETE /connections/:id deletes own', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app).delete(`/api/ssh/connections/${id}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────────────
// Host key flow
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: host key TOFU', () => {
it('POST /:id/test returns fingerprint + pending token on first_observe', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app).post(`/api/ssh/connections/${id}/test`);
expect(res.status).toBe(200);
expect(res.body.verdict).toBe('first_observe');
expect(res.body.fingerprint).toMatch(/^SHA256:/);
expect(res.body.pendingToken).toBeTruthy();
});
it('POST /:id/verify-host-key consumes a valid token', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const testRes = await request(h.app).post(`/api/ssh/connections/${id}/test`);
expect(testRes.status).toBe(200);
const verifyRes = await request(h.app)
.post(`/api/ssh/connections/${id}/verify-host-key`)
.send({ fingerprint: testRes.body.fingerprint, token: testRes.body.pendingToken });
expect(verifyRes.status).toBe(200);
expect(verifyRes.body.ok).toBe(true);
expect(verifyRes.body.connection.hostKeyVerifiedAt).toBeTruthy();
});
it('POST /:id/verify-host-key rejects bad token (409)', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
await request(h.app).post(`/api/ssh/connections/${id}/test`);
const res = await request(h.app)
.post(`/api/ssh/connections/${id}/verify-host-key`)
.send({ fingerprint: 'SHA256:wrongAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', token: VALID_UUID });
expect(res.status).toBe(409);
});
it('POST /:id/replace-host-key requires reason', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
// First verify so we have a verified key, then observe a new one.
const testRes = await request(h.app).post(`/api/ssh/connections/${id}/test`);
await request(h.app)
.post(`/api/ssh/connections/${id}/verify-host-key`)
.send({ fingerprint: testRes.body.fingerprint, token: testRes.body.pendingToken });
// Observe a mismatch.
const tester = makeFakeTester('mismatch');
const h2 = makeHarness({ tester });
// (h2 has its own DB; we'll just verify the reason-required behavior on the
// replace endpoint by sending a clearly-invalid request to h's endpoint.)
void h2;
const res = await request(h.app)
.post(`/api/ssh/connections/${id}/replace-host-key`)
.send({ fingerprint: 'SHA256:newAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', token: VALID_UUID });
expect(res.status).toBe(400);
expect(String(res.body.error)).toMatch(/reason/);
});
});
// ──────────────────────────────────────────────────────────────────────
// User audit + grants
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: user audit + grants', () => {
it('GET /connections/:id/audit returns entries for owner', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app).get(`/api/ssh/connections/${id}/audit`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.audit)).toBe(true);
// The create itself audits an upsert row.
expect(res.body.audit.length).toBeGreaterThan(0);
expect(res.body.audit[0].action).toBe('ssh.connection.upsert');
});
it('GET /grants/visible-to-me lists user-subject grants', async () => {
const hAdmin = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(hAdmin);
// Admin creates a grant for alice → general
await request(hAdmin.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'alice',
pieceName: 'general',
reason: 'access for alice on general',
});
// alice queries her visible grants (must reuse the same DB — different
// harnesses use different DBs, so we drive alice's request via the admin
// harness app but the requireAuth/getUserId always returns admin here.
// Substitute by querying directly via the SQL helper through the admin
// grants list as a proxy.
const adminList = await request(hAdmin.app).get('/api/ssh/admin/grants');
expect(adminList.status).toBe(200);
expect(adminList.body.grants.some((g: { subjectId: string }) => g.subjectId === 'alice')).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────────────
// Admin endpoints
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: admin connections', () => {
it('GET /api/ssh/admin/connections lists all', async () => {
const h = makeHarness({ isAdmin: true });
await createOwnedConnection(h);
const res = await request(h.app).get('/api/ssh/admin/connections');
expect(res.status).toBe(200);
expect(res.body.connections.length).toBeGreaterThan(0);
});
it('PATCH /admin/connections/:id/disable disables with reason', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/admin/connections/${id}/disable`)
.send({ reason: 'disabled for security review' });
expect(res.status).toBe(200);
expect(res.body.connection.disabledByAdmin).toBe(true);
expect(res.body.connection.disabledByAdminReason).toBe('disabled for security review');
});
it('PATCH /admin/connections/:id/enable re-enables with reason', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
await request(h.app)
.patch(`/api/ssh/admin/connections/${id}/disable`)
.send({ reason: 'temporary disable' });
const res = await request(h.app)
.patch(`/api/ssh/admin/connections/${id}/enable`)
.send({ reason: 'restored after review' });
expect(res.status).toBe(200);
expect(res.body.connection.disabledByAdmin).toBe(false);
});
it('DELETE /admin/connections/:id requires reason and deletes', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.delete(`/api/ssh/admin/connections/${id}`)
.send({ reason: 'admin removal after audit' });
expect(res.status).toBe(200);
});
it('POST /admin/connections/:id/force-unlock is rate-limited', async () => {
const h = makeHarness({ isAdmin: true, forceUnlockLimit: { windowMs: 60_000, maxRequests: 2 } });
const id = await createOwnedConnection(h);
// First two calls succeed (with valid reason).
const r1 = await request(h.app).post(`/api/ssh/admin/connections/${id}/force-unlock`).send({ reason: 'unlock after probing' });
expect(r1.status).toBe(200);
const r2 = await request(h.app).post(`/api/ssh/admin/connections/${id}/force-unlock`).send({ reason: 'unlock after probing' });
expect(r2.status).toBe(200);
// Third is rate-limited.
const r3 = await request(h.app).post(`/api/ssh/admin/connections/${id}/force-unlock`).send({ reason: 'unlock after probing' });
expect(r3.status).toBe(429);
expect(r3.headers['retry-after']).toBeTruthy();
});
});
describe('SSH API: admin globals', () => {
it('POST /admin/globals creates a global connection', async () => {
const h = makeHarness({ isAdmin: true });
const res = await request(h.app)
.post('/api/ssh/admin/globals')
.send({
label: 'prod-bastion',
host: 'bastion.example.com',
port: 22,
username: 'ops',
privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/srv/ops',
reason: 'global bastion provisioned',
});
expect(res.status).toBe(201);
expect(res.body.connection.ownerId).toBeNull();
});
it('POST /admin/globals accepts allowRemoteUnrestricted with reason', async () => {
const h = makeHarness({ isAdmin: true });
const res = await request(h.app)
.post('/api/ssh/admin/globals')
.send({
label: 'unrestricted',
host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
allowRemoteUnrestricted: true,
reason: 'unrestricted required for break-fix work',
});
expect(res.status).toBe(201);
expect(res.body.connection.allowRemoteUnrestricted).toBe(true);
expect(res.body.connection.remotePathPrefix).toBe('/');
});
it('PATCH /admin/globals/:id rejects user-owned connection', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/admin/globals/${id}`)
.send({ reason: 'attempt to patch user-owned via global' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('not_global');
});
});
describe('SSH API: admin grants', () => {
it('POST /admin/grants creates a grant', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'bob',
pieceName: 'general',
reason: 'bob needs general access',
});
expect(res.status).toBe(201);
expect(res.body.grant.subjectId).toBe('bob');
});
it('POST /admin/grants rejects piece_name + applies_to_all conflict', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'bob',
pieceName: 'general',
appliesToAllPieces: true,
reason: 'conflict expected',
});
expect(res.status).toBe(400);
});
it('POST /admin/grants accepts applies_to_all without piece_name', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'bob',
appliesToAllPieces: true,
reason: 'bob admin-style grant',
});
expect(res.status).toBe(201);
expect(res.body.grant.appliesToAllPieces).toBe(true);
expect(res.body.grant.pieceName).toBeNull();
});
it('DELETE /admin/grants/:id removes with reason', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const create = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id, subjectType: 'user', subjectId: 'bob',
pieceName: 'general', reason: 'grant for removal test',
});
const grantId = create.body.grant.id;
const res = await request(h.app)
.delete(`/api/ssh/admin/grants/${grantId}`)
.send({ reason: 'revoke after expiry' });
expect(res.status).toBe(200);
});
it('DELETE /admin/grants/:id calls onAccessRevoked for user-subject grants (kicks active WS viewers)', async () => {
const onAccessRevoked = vi.fn().mockReturnValue(2);
const h = makeHarness({ isAdmin: true, onAccessRevoked });
const connectionId = await createOwnedConnection(h);
const create = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId, subjectType: 'user', subjectId: 'bob',
pieceName: 'general', reason: 'grant for kick-on-revoke test',
});
const grantId = create.body.grant.id;
const res = await request(h.app)
.delete(`/api/ssh/admin/grants/${grantId}`)
.send({ reason: 'revoke for security' });
expect(res.status).toBe(200);
expect(onAccessRevoked).toHaveBeenCalledTimes(1);
expect(onAccessRevoked).toHaveBeenCalledWith({ connectionId, userId: 'bob' });
});
it('DELETE /admin/grants/:id does NOT call onAccessRevoked for org-subject grants (MVP deferral)', async () => {
const onAccessRevoked = vi.fn();
const h = makeHarness({ isAdmin: true, onAccessRevoked });
const connectionId = await createOwnedConnection(h);
const create = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId, subjectType: 'org', subjectId: 'team1',
pieceName: 'general', reason: 'org grant for kick deferral test',
});
const grantId = create.body.grant.id;
const res = await request(h.app)
.delete(`/api/ssh/admin/grants/${grantId}`)
.send({ reason: 'revoke for security' });
expect(res.status).toBe(200);
expect(onAccessRevoked).not.toHaveBeenCalled();
});
it('DELETE /admin/grants/:id works when onAccessRevoked is undefined (console disabled)', async () => {
const h = makeHarness({ isAdmin: true }); // no onAccessRevoked
const connectionId = await createOwnedConnection(h);
const create = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId, subjectType: 'user', subjectId: 'bob',
pieceName: 'general', reason: 'grant without console hook',
});
const grantId = create.body.grant.id;
const res = await request(h.app)
.delete(`/api/ssh/admin/grants/${grantId}`)
.send({ reason: 'revoke for console-disabled case' });
expect(res.status).toBe(200);
});
});
describe('SSH API: rotate-master-key stub', () => {
it('POST /admin/rotate-master-key sets maintenance + 202', async () => {
const h = makeHarness({ isAdmin: true });
const res = await request(h.app)
.post('/api/ssh/admin/rotate-master-key')
.send({ reason: 'rotate quarterly' });
expect(res.status).toBe(202);
expect(res.body.jobId).toMatch(/^rotate-/);
expect(res.body.notImplemented).toBe(true);
expect(h.maintenance.isActive()).toBe(true);
});
it('POST /admin/rotate-master-key rejects when already active', async () => {
const h = makeHarness({ isAdmin: true });
h.maintenance.enter('already rotating');
const res = await request(h.app)
.post('/api/ssh/admin/rotate-master-key')
.send({ reason: 'second attempt' });
expect(res.status).toBe(409);
});
it('GET /admin/rotate-master-key/:jobId returns progress for active job', async () => {
const h = makeHarness({ isAdmin: true });
const start = await request(h.app)
.post('/api/ssh/admin/rotate-master-key')
.send({ reason: 'rotate stub' });
const res = await request(h.app).get(`/api/ssh/admin/rotate-master-key/${start.body.jobId}`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('in_progress');
});
});
describe('SSH API: admin audit query', () => {
it('GET /admin/audit returns cross-user audit rows', async () => {
const h = makeHarness({ isAdmin: true });
await createOwnedConnection(h);
const res = await request(h.app).get('/api/ssh/admin/audit?limit=10');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.audit)).toBe(true);
expect(res.body.audit.length).toBeGreaterThan(0);
});
it('GET /admin/audit filters by action', async () => {
const h = makeHarness({ isAdmin: true });
await createOwnedConnection(h);
const res = await request(h.app).get('/api/ssh/admin/audit?action=ssh.connection.upsert');
expect(res.status).toBe(200);
expect(res.body.audit.every((r: { action: string }) => r.action === 'ssh.connection.upsert')).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
import type { Repository } from '../db/repository.js';
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
getLocalTask: vi.fn(),
getLatestJobForIssue: vi.fn(),
getSubJobs: vi.fn(),
getJob: vi.fn(),
...overrides,
} as unknown as Repository;
}
const DUMMY_TASK = { id: 1, title: 'test task', workspacePath: '/tmp/workspace' };
const DUMMY_LATEST_JOB = { id: 'job-parent-1', issueNumber: 1 };
describe('Subtask Activity API', () => {
let app: express.Application;
let repo: Repository;
let tmpDirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'subtask-activity-test-'));
tmpDirs.push(dir);
return dir;
}
beforeEach(() => {
tmpDirs = [];
repo = makeRepo();
app = express();
app.use(express.json());
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
});
afterEach(() => {
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
// -------------------------------------------------------------------
// GET /:id/subtasks/activities
// -------------------------------------------------------------------
describe('GET /:id/subtasks/activities', () => {
it('returns subtask list with currentMovement from DB', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs).mockResolvedValue([
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: null },
{ id: 'sub-2', issueNumber: 3, status: 'succeeded', currentMovement: null, worktreePath: null },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks).toHaveLength(2);
expect(res.body.subtasks[0].jobId).toBe('sub-1');
expect(res.body.subtasks[0].currentMovement).toBe('execute');
expect(res.body.subtasks[1].currentMovement).toBeNull();
});
it('includes nested subtasks when parent is waiting_subtasks', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs)
.mockResolvedValueOnce([
{ id: 'sub-1', issueNumber: 1, status: 'waiting_subtasks', currentMovement: null, worktreePath: null },
] as never)
.mockResolvedValueOnce([
{ id: 'grand-1', issueNumber: 1, status: 'running', currentMovement: 'execute', worktreePath: null },
{ id: 'grand-2', issueNumber: 2, status: 'queued', currentMovement: null, worktreePath: null },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks).toHaveLength(3); // sub-1, grand-1, grand-2
expect(res.body.subtasks.map((s: { jobId: string }) => s.jobId)).toEqual(['sub-1', 'grand-1', 'grand-2']);
});
it('returns 404 when task not found', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(null as never);
const res = await request(app).get('/api/local/tasks/99/subtasks/activities');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Task not found');
});
it('returns 404 when no job found', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(null as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(404);
expect(res.body.error).toBe('No job found');
});
it('returns empty activityLog when worktreePath is null', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs).mockResolvedValue([
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: null },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks[0].activityLog).toBe('');
});
it('returns activity log content when file exists', async () => {
const worktree = makeTmpDir();
const logsDir = join(worktree, 'logs');
mkdirSync(logsDir);
writeFileSync(join(logsDir, 'activity.log'), 'step 1\nstep 2\n');
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs).mockResolvedValue([
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: worktree },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks[0].activityLog).toBe('step 1\nstep 2\n');
});
it('truncates activity log to 4000 chars in bulk API', async () => {
const worktree = makeTmpDir();
const logsDir = join(worktree, 'logs');
mkdirSync(logsDir);
const longContent = 'x'.repeat(5000);
writeFileSync(join(logsDir, 'activity.log'), longContent);
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs).mockResolvedValue([
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: null, worktreePath: worktree },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks[0].activityLog).toHaveLength(4000);
// Should be the last 4000 chars
expect(res.body.subtasks[0].activityLog).toBe('x'.repeat(4000));
});
});
// -------------------------------------------------------------------
// GET /:id/subtasks/:jobId/activity
// -------------------------------------------------------------------
describe('GET /:id/subtasks/:jobId/activity', () => {
it('returns individual activity log via getJob', async () => {
const worktree = makeTmpDir();
const logsDir = join(worktree, 'logs');
mkdirSync(logsDir);
writeFileSync(join(logsDir, 'activity.log'), 'individual log content');
vi.mocked(repo.getLocalTask).mockResolvedValue({ ...DUMMY_TASK, workspacePath: worktree } as never);
vi.mocked(repo.getJob).mockResolvedValue(
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: worktree } as never,
);
const res = await request(app).get('/api/local/tasks/1/subtasks/sub-1/activity');
expect(res.status).toBe(200);
expect(res.body.activityLog).toBe('individual log content');
});
it('returns full (non-truncated) activity log for individual endpoint', async () => {
const worktree = makeTmpDir();
const logsDir = join(worktree, 'logs');
mkdirSync(logsDir);
const longContent = 'y'.repeat(5000);
writeFileSync(join(logsDir, 'activity.log'), longContent);
vi.mocked(repo.getLocalTask).mockResolvedValue({ ...DUMMY_TASK, workspacePath: worktree } as never);
vi.mocked(repo.getJob).mockResolvedValue(
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: null, worktreePath: worktree } as never,
);
const res = await request(app).get('/api/local/tasks/1/subtasks/sub-1/activity');
expect(res.status).toBe(200);
// Individual endpoint does NOT truncate (maxChars = 0)
expect(res.body.activityLog).toHaveLength(5000);
});
it('returns 404 when task not found', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(null as never);
const res = await request(app).get('/api/local/tasks/99/subtasks/sub-1/activity');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Task not found');
});
it('returns 404 when subtask not found', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getJob).mockResolvedValue(null as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/nonexistent/activity');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Subtask not found');
});
});
// -------------------------------------------------------------------
// Visibility gate regression (bulk activities)
// -------------------------------------------------------------------
describe('GET /:id/subtasks/activities visibility gate', () => {
// When getLocalTask returns null (viewer does not have access),
// canViewTask rejects with 404. This simulates B3: a non-owner user
// asking for another user's private task's bulk subtask activities.
it('returns 404 when viewer cannot see the parent task', async () => {
const privateRepo = makeRepo();
const privateApp = express();
privateApp.use(express.json());
privateApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
next();
});
privateApp.use('/api/local/tasks', createSubtaskActivityRouter(privateRepo));
// getLocalTask returns null because the viewer filter matches no rows
vi.mocked(privateRepo.getLocalTask).mockResolvedValue(null as never);
const res = await request(privateApp).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Task not found');
// Should never reach getLatestJobForIssue
expect(privateRepo.getLatestJobForIssue).not.toHaveBeenCalled();
});
it('allows access when visibility=public even for non-owner', async () => {
const pubRepo = makeRepo();
const pubApp = express();
pubApp.use(express.json());
pubApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
next();
});
pubApp.use('/api/local/tasks', createSubtaskActivityRouter(pubRepo));
vi.mocked(pubRepo.getLocalTask).mockResolvedValue({
id: 1, title: 'pub', workspacePath: '/tmp/w',
ownerId: 'alice-id', visibility: 'public', visibilityScopeOrgId: null,
} as never);
vi.mocked(pubRepo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(pubRepo.getSubJobs).mockResolvedValue([] as never);
const res = await request(pubApp).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { Router, Request, Response } from 'express';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { type Repository, type Job, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { canViewTask } from './local-api-helpers.js';
const MAX_ACTIVITY_LOG_CHARS = 4000;
function readActivityLog(worktreePath: string | null, maxChars: number = 0): string {
if (!worktreePath) return '';
const logPath = join(worktreePath, 'logs', 'activity.log');
if (!existsSync(logPath)) return '';
try {
const content = readFileSync(logPath, 'utf-8');
return maxChars > 0 && content.length > maxChars
? content.slice(-maxChars)
: content;
} catch {
return '';
}
}
export function createSubtaskActivityRouter(repo: Repository): Router {
const router = Router();
// GET /:id/subtasks/activities — bulk fetch all subtask activities (includes nested subtasks)
router.get('/:id/subtasks/activities', async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!latestJob) { res.status(404).json({ error: 'No job found' }); return; }
// 再帰的に全サブジョブ(孫含む)を収集
const collectAllSubJobs = async (parentId: string): Promise<Job[]> => {
const jobs = await repo.getSubJobs(parentId);
const result = [...jobs];
for (const job of jobs) {
if (job.status === 'waiting_subtasks') {
result.push(...await collectAllSubJobs(job.id));
}
}
return result;
};
const allJobs = await collectAllSubJobs(latestJob.id);
const subtasks = allJobs.map(job => ({
jobId: job.id,
issueNumber: job.issueNumber,
status: job.status,
currentMovement: job.currentMovement ?? null,
currentActivity: job.currentActivity ?? null,
activityLog: readActivityLog(job.worktreePath, MAX_ACTIVITY_LOG_CHARS),
}));
res.json({ subtasks });
} catch (err) {
logger.error(`Subtask activities API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask activities' });
}
});
// GET /:id/subtasks/:jobId/activity — individual subtask activity (supports nested subtasks)
router.get('/:id/subtasks/:jobId/activity', async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id);
const jobId = req.params.jobId;
const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
// jobId で直接取得(孫タスクにも対応)
const job = await repo.getJob(jobId, viewer ? { viewer } : undefined);
if (!job || !job.worktreePath) { res.status(404).json({ error: 'Subtask not found' }); return; }
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !job.worktreePath.startsWith(task!.workspacePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
res.json({ activityLog: readActivityLog(job.worktreePath) });
} catch (err) {
logger.error(`Subtask activity API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask activity' });
}
});
return router;
}
+102
View File
@@ -0,0 +1,102 @@
import { type Application, type Request, type Response } from 'express';
import { existsSync, readdirSync, statSync } from 'fs';
import { resolve, sep } from 'path';
import { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { canViewTask } from './local-api-helpers.js';
export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
// NOTE: listing MUST be registered before the wildcard route
app.get('/api/local/tasks/:id/subtasks/:jobId/files', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.id);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const jobId = req.params.jobId;
const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
// jobId で直接取得(孫タスクにも対応)
const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined);
if (!subJob || !subJob.worktreePath) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const basePath = resolve(subJob.worktreePath);
const categories: Record<string, string[]> = {};
for (const dir of ['output', 'logs', 'input']) {
const dirPath = resolve(basePath, dir);
if (!existsSync(dirPath)) continue;
const dirFiles = readdirSync(dirPath, { recursive: true })
.map(f => String(f))
.filter(f => !statSync(resolve(dirPath, f)).isDirectory());
if (dirFiles.length > 0) categories[dir] = dirFiles;
}
// 後方互換: files は output/ のファイル一覧
res.json({ files: categories['output'] ?? [], categories });
} catch (err) {
logger.error(`Subtask file list API error: ${err}`);
res.status(500).json({ error: 'Failed to list subtask files' });
}
});
app.get('/api/local/tasks/:id/subtasks/:jobId/files/*', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.id);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const jobId = req.params.jobId;
const filePath = req.params[0];
const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
// jobId で直接取得(孫タスクにも対応)
const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined);
if (!subJob || !subJob.worktreePath) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const base = resolve(subJob.worktreePath);
const resolved = resolve(base, filePath);
// Require the trailing separator so a sibling like `<base>-x` cannot pass
// the prefix check; allow the base dir itself.
if (resolved !== base && !resolved.startsWith(base + sep)) {
res.status(403).json({ error: 'Access denied' }); return;
}
if (!existsSync(resolved)) { res.status(404).json({ error: 'File not found' }); return; }
const stat = statSync(resolved);
if (stat.isDirectory()) {
const dirFiles = readdirSync(resolved);
res.json({ files: dirFiles }); return;
}
res.sendFile(resolved);
} catch (err) {
logger.error(`Subtask files API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask file' });
}
});
}
+229
View File
@@ -0,0 +1,229 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express, { type RequestHandler } from 'express';
import request from 'supertest';
import {
mountToolsApi,
_resetToolCatalogCacheForTests,
type McpCatalogDeps,
type ToolCatalogEntry,
} from './tools-api.js';
import { setSshSubsystem, type SshSubsystem } from '../engine/tools/ssh.js';
/**
* Build an express app exposing /api/tools. `user` controls what
* (req as any).user is set to before the catalog handler runs.
*/
function makeApp(opts?: {
user?: { id?: string; role?: string } | null;
authActive?: boolean;
mcp?: McpCatalogDeps | null;
}): express.Application {
const app = express();
app.use(express.json());
if (opts?.user !== null) {
const u = opts?.user ?? { id: 'u1', role: 'user' };
app.use((req, _res, next) => {
(req as unknown as { user: typeof u }).user = u;
// supertest doesn't have passport; fake isAuthenticated.
(req as unknown as { isAuthenticated: () => boolean }).isAuthenticated = () => true;
next();
});
}
const requireAuth: RequestHandler = (req, res, next) => {
if ((req as unknown as { user?: { id?: string } }).user?.id) {
next();
} else {
res.status(401).json({ error: 'Unauthorized' });
}
};
mountToolsApi(app, {
authActive: opts?.authActive ?? false,
requireAuth,
mcp: opts?.mcp ?? null,
});
return app;
}
describe('GET /api/tools (runtime catalog)', () => {
beforeEach(() => {
_resetToolCatalogCacheForTests();
setSshSubsystem(null);
});
afterEach(() => {
setSshSubsystem(null);
_resetToolCatalogCacheForTests();
});
it('returns ToolCatalogResponse with builtin tools', async () => {
const res = await request(makeApp()).get('/api/tools');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tools)).toBe(true);
const names = (res.body.tools as ToolCatalogEntry[]).map((t) => t.name);
// Core builtin tools must be present.
expect(names).toContain('Read');
expect(names).toContain('Write');
expect(names).toContain('Bash');
});
it('tags core tools with source=builtin and category=core', async () => {
const res = await request(makeApp()).get('/api/tools');
const read = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'Read');
expect(read).toBeDefined();
expect(read!.source).toBe('builtin');
expect(read!.category).toBe('core');
expect(read!.scope).toBe('piece');
expect(read!.available).toBe(true);
});
it('includes meta tools tagged source=meta scope=global', async () => {
const res = await request(makeApp()).get('/api/tools');
const tools = res.body.tools as ToolCatalogEntry[];
const readDoc = tools.find((t) => t.name === 'ReadToolDoc');
expect(readDoc).toBeDefined();
expect(readDoc!.source).toBe('meta');
expect(readDoc!.scope).toBe('global');
expect(readDoc!.available).toBe(true);
const brainstorm = tools.find((t) => t.name === 'Brainstorm');
expect(brainstorm?.source).toBe('meta');
expect(brainstorm?.scope).toBe('global');
});
it('marks ssh tools available=false with reason when SSH subsystem is not initialised', async () => {
setSshSubsystem(null);
const res = await request(makeApp()).get('/api/tools');
const ssh = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'SshExec');
// SSH module may not exist in some lean builds; only assert when present.
if (ssh) {
expect(ssh.category).toBe('ssh');
expect(ssh.available).toBe(false);
expect(ssh.reason).toMatch(/SSH subsystem not initialised/);
expect(ssh.scope).toBe('piece');
}
});
it('marks ssh tools available=true when subsystem is initialised', async () => {
setSshSubsystem({} as SshSubsystem);
const res = await request(makeApp()).get('/api/tools');
const ssh = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'SshExec');
if (ssh) {
expect(ssh.available).toBe(true);
expect(ssh.reason).toBeUndefined();
}
});
it('includes MCP tools for authenticated user', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [
{ id: 'canva', name: 'Canva', enabled: true },
],
},
tokenManager: {
hasToken: () => true,
},
toolCache: {
getAllForServers: () => [
{ serverId: 'canva', toolName: 'createDesign' },
{ serverId: 'canva', toolName: 'listDesigns' },
],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const tools = res.body.tools as ToolCatalogEntry[];
const create = tools.find((t) => t.name === 'mcp__canva__createDesign');
expect(create).toBeDefined();
expect(create!.source).toBe('mcp');
expect(create!.category).toBe('mcp:canva');
expect(create!.serverId).toBe('canva');
expect(create!.scope).toBe('user');
expect(create!.available).toBe(true);
});
it('marks MCP tools unavailable with reason when user is not connected (offline)', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'gh', name: 'GitHub', enabled: true }],
},
tokenManager: {
hasToken: () => false, // user not connected
},
toolCache: {
getAllForServers: () => [{ serverId: 'gh', toolName: 'listIssues' }],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const tool = (res.body.tools as ToolCatalogEntry[]).find(
(t) => t.name === 'mcp__gh__listIssues',
);
expect(tool).toBeDefined();
expect(tool!.available).toBe(false);
expect(tool!.reason).toMatch(/offline/);
});
it('omits MCP tools when caller has no user id (unauthenticated)', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'canva', name: 'Canva', enabled: true }],
},
tokenManager: { hasToken: () => true },
toolCache: {
getAllForServers: () => [{ serverId: 'canva', toolName: 'createDesign' }],
},
};
const res = await request(makeApp({ user: null, mcp })).get('/api/tools');
expect(res.status).toBe(200);
const tools = res.body.tools as ToolCatalogEntry[];
expect(tools.some((t) => t.source === 'mcp')).toBe(false);
});
it('returns 401 when authActive=true and caller is not authenticated', async () => {
const res = await request(makeApp({ user: null, authActive: true })).get('/api/tools');
expect(res.status).toBe(401);
});
it('?legacy=1 returns flat array of tool names', async () => {
const res = await request(makeApp()).get('/api/tools?legacy=1');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tools)).toBe(true);
// Must be string[], NOT objects.
for (const t of res.body.tools as unknown[]) {
expect(typeof t).toBe('string');
}
expect(res.body.tools).toContain('Read');
expect(res.body.tools).toContain('ReadToolDoc');
});
it('?legacy=1 includes per-user MCP names when authenticated', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'canva', name: 'Canva', enabled: true }],
},
tokenManager: { hasToken: () => true },
toolCache: {
getAllForServers: () => [{ serverId: 'canva', toolName: 'createDesign' }],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools?legacy=1');
expect(res.body.tools).toContain('mcp__canva__createDesign');
});
it('surfaces a placeholder entry when MCP server has no cached tools', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'fresh', name: 'Fresh', enabled: true }],
},
tokenManager: { hasToken: () => false },
toolCache: { getAllForServers: () => [] },
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const placeholder = (res.body.tools as ToolCatalogEntry[]).find(
(t) => t.serverId === 'fresh',
);
expect(placeholder).toBeDefined();
expect(placeholder!.available).toBe(false);
expect(placeholder!.reason).toMatch(/offline|no cached tools/);
expect(placeholder!.scope).toBe('user');
});
});
+311
View File
@@ -0,0 +1,311 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { ToolDef } from '../llm/openai-compat.js';
import { getSshSubsystem } from '../engine/tools/ssh.js';
/**
* Tool catalog entry exposed by GET /api/tools.
*
* The catalog is built at request-time from the same module set the agent loop
* uses, plus per-caller MCP context. UI consumers (Piece allowed_tools editor)
* should rely on this rather than the previous hand-maintained static list.
*
* See docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md
* step 4 for the design rationale.
*/
export interface ToolCatalogEntry {
name: string;
source: 'builtin' | 'meta' | 'mcp';
/**
* Coarse grouping for UI. Values are stable strings derived from the source
* module file name (e.g. 'core', 'web', 'office'). MCP tools use
* `mcp:<serverId>` so the UI can group them by server.
*/
category: string;
/** MCP server id (source: 'mcp' only). */
serverId?: string;
/** Whether the tool can be invoked at this moment. */
available: boolean;
/** Human-readable explanation when available=false. */
reason?: string;
/**
* Where the tool is "switched on":
* - 'global' → always injected (meta tools like ReadToolDoc)
* - 'piece' → must appear in a piece's `allowed_tools`
* - 'user' → per-user resource (MCP)
*/
scope: 'global' | 'piece' | 'user';
}
export interface ToolCatalogResponse {
tools: ToolCatalogEntry[];
}
/**
* Meta tools are auto-injected by `agent-loop.buildSystemPrompt()` regardless of
* a piece's `allowed_tools`. Keep this list in sync with `META_TOOLS` in
* `src/engine/tools/index.ts`.
*/
const META_TOOLS = new Set<string>([
'ReadToolDoc',
'CreateChecklist',
'CheckItem',
'GetChecklist',
'MissionUpdate',
'ListUserAssets',
'RunUserScript',
'UpdateUserMemory',
'ReadUserMemory',
'ReadUserTemplate',
'RenderUserTemplate',
'WriteUserScript',
'WriteUserTemplate',
'Brainstorm',
'ReadAppDoc',
'ListAppDocs',
'GetMyOrchestratorState',
'ReadSkill',
'ListSkills',
'InstallSkill',
]);
/**
* Modules to load. The key becomes the `category` field for builtin tools.
* `core` is loaded separately because its export name is `ALL_TOOL_DEFS`.
*
* Categories that map to "user-scoped" assets (per-user MCP servers, SSH) get
* scope='user' below — see categoryScope().
*/
const MODULE_SPECS: Array<{ category: string; specifier: string }> = [
{ category: 'web', specifier: '../engine/tools/web.js' },
{ category: 'image', specifier: '../engine/tools/image.js' },
{ category: 'data', specifier: '../engine/tools/data.js' },
{ category: 'office', specifier: '../engine/tools/office.js' },
{ category: 'review', specifier: '../engine/tools/review.js' },
{ category: 'x', specifier: '../engine/tools/x.js' },
{ category: 'orchestration', specifier: '../engine/tools/orchestration.js' },
{ category: 'browser', specifier: '../engine/tools/browser.js' },
{ category: 'maps', specifier: '../engine/tools/maps.js' },
{ category: 'youtube', specifier: '../engine/tools/youtube.js' },
{ category: 'pieces', specifier: '../engine/tools/pieces.js' },
{ category: 'amazon', specifier: '../engine/tools/amazon.js' },
{ category: 'speech', specifier: '../engine/tools/speech.js' },
{ category: 'checklist', specifier: '../engine/tools/checklist.js' },
{ category: 'knowledge', specifier: '../engine/tools/knowledge.js' },
{ category: 'ms-learn', specifier: '../engine/tools/ms-learn.js' },
{ category: 'slide', specifier: '../engine/tools/slide.js' },
{ category: 'docs', specifier: '../engine/tools/docs.js' },
{ category: 'mission', specifier: '../engine/tools/mission.js' },
{ category: 'user-folder', specifier: '../engine/tools/user-folder.js' },
{ category: 'brainstorm', specifier: '../engine/tools/brainstorm.js' },
{ category: 'app-docs', specifier: '../engine/tools/app-docs.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh-console.js' },
{ category: 'notes', specifier: '../engine/tools/notes.js' },
{ category: 'dashboard', specifier: '../engine/tools/dashboard.js' },
{ category: 'skills', specifier: '../engine/tools/skills.js' },
];
interface ToolModule {
TOOL_DEFS?: Record<string, ToolDef>;
ALL_TOOL_DEFS?: Record<string, ToolDef>;
}
/**
* Deps the catalog needs to enumerate per-user MCP tools. Optional — if
* absent, MCP tools are omitted entirely (e.g. when MCP_ENCRYPTION_KEY is
* not configured and the aggregator was never set up).
*/
export interface McpCatalogDeps {
registry: {
listEnabledForUser(userId: string): Array<{ id: string; name: string; enabled: boolean }>;
};
tokenManager: {
hasToken(userId: string, serverId: string): boolean;
};
toolCache: {
getAllForServers(serverIds: string[]): Array<{ serverId: string; toolName: string }>;
};
}
export interface MountToolsApiOptions {
/** When true, /api/tools is gated behind requireAuth. */
authActive?: boolean;
/** requireAuth middleware (only consulted when authActive is true). */
requireAuth?: RequestHandler;
/** Subsystems used to enumerate per-user MCP tools. */
mcp?: McpCatalogDeps | null;
}
// ──────────────────────────────────────────────────────────────────────
// Module / category caches
// ──────────────────────────────────────────────────────────────────────
let _cachedBuiltinEntries: Array<Omit<ToolCatalogEntry, 'available' | 'reason'>> | null = null;
async function loadBuiltinEntries(): Promise<Array<Omit<ToolCatalogEntry, 'available' | 'reason'>>> {
if (_cachedBuiltinEntries) return _cachedBuiltinEntries;
// name → category info. First-write-wins so a later module specifying the
// same tool name keeps the original category — matches runtime tools/index.ts.
const seen = new Map<string, { category: string; source: 'builtin' | 'meta' }>();
// Core tools (Read/Write/Edit/Bash/Glob/Grep) — always categorised 'core'.
try {
const coreMod = (await import('../engine/tools/core.js')) as ToolModule;
const defs = coreMod.ALL_TOOL_DEFS ?? {};
for (const name of Object.keys(defs)) {
if (!seen.has(name)) {
seen.set(name, { category: 'core', source: META_TOOLS.has(name) ? 'meta' : 'builtin' });
}
}
} catch {
// core should always load; if not, we have bigger problems
}
for (const { category, specifier } of MODULE_SPECS) {
try {
const mod = (await import(specifier)) as ToolModule;
const defs = mod.TOOL_DEFS ?? {};
for (const name of Object.keys(defs)) {
if (!seen.has(name)) {
seen.set(name, {
category,
source: META_TOOLS.has(name) ? 'meta' : 'builtin',
});
}
}
} catch {
// module not available — skip
}
}
const entries: Array<Omit<ToolCatalogEntry, 'available' | 'reason'>> = [];
for (const [name, info] of seen) {
entries.push({
name,
source: info.source,
category: info.category,
scope: info.source === 'meta' ? 'global' : 'piece',
});
}
entries.sort((a, b) => a.name.localeCompare(b.name));
_cachedBuiltinEntries = entries;
return entries;
}
/** Test-only: reset the module-load cache. */
export function _resetToolCatalogCacheForTests(): void {
_cachedBuiltinEntries = null;
}
// ──────────────────────────────────────────────────────────────────────
// SSH availability
// ──────────────────────────────────────────────────────────────────────
function annotateSshAvailability(entry: Omit<ToolCatalogEntry, 'available' | 'reason'>): ToolCatalogEntry {
if (entry.category !== 'ssh') {
return { ...entry, available: true };
}
const sub = getSshSubsystem();
if (sub) {
return { ...entry, available: true };
}
return {
...entry,
available: false,
reason: 'SSH subsystem not initialised',
};
}
// ──────────────────────────────────────────────────────────────────────
// MCP catalog
// ──────────────────────────────────────────────────────────────────────
function buildMcpEntries(userId: string, mcp: McpCatalogDeps): ToolCatalogEntry[] {
const servers = mcp.registry.listEnabledForUser(userId);
if (servers.length === 0) return [];
const cache = mcp.toolCache.getAllForServers(servers.map((s) => s.id));
const entries: ToolCatalogEntry[] = [];
for (const server of servers) {
const connected = mcp.tokenManager.hasToken(userId, server.id);
const serverTools = cache.filter((t) => t.serverId === server.id);
if (serverTools.length === 0) {
// No cached tools yet — surface the server as a single placeholder so
// the UI can still show it (e.g. "<server> — not yet connected").
entries.push({
name: `mcp__${server.id}__`,
source: 'mcp',
category: `mcp:${server.id}`,
serverId: server.id,
available: false,
reason: connected
? `mcp server ${server.name} has no cached tools`
: `mcp server ${server.name} offline`,
scope: 'user',
});
continue;
}
for (const t of serverTools) {
entries.push({
name: `mcp__${server.id}__${t.toolName}`,
source: 'mcp',
category: `mcp:${server.id}`,
serverId: server.id,
available: connected,
reason: connected ? undefined : `mcp server ${server.name} offline`,
scope: 'user',
});
}
}
entries.sort((a, b) => a.name.localeCompare(b.name));
return entries;
}
// ──────────────────────────────────────────────────────────────────────
// Handler
// ──────────────────────────────────────────────────────────────────────
export function mountToolsApi(app: Application, options: MountToolsApiOptions = {}): void {
const handler = async (req: Request, res: Response): Promise<void> => {
const builtinBase = await loadBuiltinEntries();
const builtin = builtinBase.map(annotateSshAvailability);
// MCP entries require an authenticated caller. When auth is disabled the
// request still carries no user; we treat that case as "no MCP catalog"
// because MCP is inherently per-user.
const user = (req.user as { id?: string } | undefined) ?? null;
let mcpEntries: ToolCatalogEntry[] = [];
if (options.mcp && user?.id) {
try {
mcpEntries = buildMcpEntries(user.id, options.mcp);
} catch {
// Defensive: never let MCP enumeration crash the whole catalog.
mcpEntries = [];
}
}
const all: ToolCatalogEntry[] = [...builtin, ...mcpEntries];
// Legacy shape: flat array of names. Maintained so the existing
// ui/src/api.ts fetchTools() (and any external consumer treating
// response.tools as string[]) keeps working until step 5.
if (req.query.legacy === '1') {
res.json({ tools: all.map((t) => t.name) });
return;
}
const payload: ToolCatalogResponse = { tools: all };
res.json(payload);
};
const guards: RequestHandler[] = [];
if (options.authActive && options.requireAuth) {
guards.push(options.requireAuth);
}
app.get('/api/tools', ...guards, handler);
}
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createUserFolderApi } from './user-folder-api.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeApp(userId: string, userFolderRoot: string): express.Application {
const app = express();
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user' };
next();
});
app.use('/api/users/me', createUserFolderApi({ userFolderRoot }));
return app;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('User Folder API — AGENTS.md routes', () => {
let tmpRoot: string;
let app: express.Application;
const USER_A = 'user-a';
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'agents-md-test-'));
app = makeApp(USER_A, tmpRoot);
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
// ── GET /agents-md ────────────────────────────────────────────────────────
it('GET /agents-md returns exists=false when file is missing', async () => {
const res = await request(app).get('/api/users/me/agents-md');
expect(res.status).toBe(200);
expect(res.body).toEqual({ exists: false, content: '' });
});
// ── PUT /agents-md ────────────────────────────────────────────────────────
it('PUT /agents-md writes content; subsequent GET reflects it', async () => {
const text = '# My Instructions\n\nAlways be concise.\n';
const putRes = await request(app)
.put('/api/users/me/agents-md')
.set('Content-Type', 'text/plain')
.send(text);
expect(putRes.status).toBe(200);
expect(putRes.body.ok).toBe(true);
expect(typeof putRes.body.bytes).toBe('number');
const getRes = await request(app).get('/api/users/me/agents-md');
expect(getRes.status).toBe(200);
expect(getRes.body.exists).toBe(true);
expect(getRes.body.content).toBe(text);
});
it('PUT /agents-md with oversized body returns 413', async () => {
// 64 KB + 1 byte — exceeds USER_AGENTS_MAX_BYTES
const oversized = 'x'.repeat(64 * 1024 + 1);
const res = await request(app)
.put('/api/users/me/agents-md')
.set('Content-Type', 'text/plain')
.send(oversized);
expect(res.status).toBe(413);
expect(res.body.error).toMatch(/exceeds/);
});
// ── DELETE /agents-md ─────────────────────────────────────────────────────
it('DELETE /agents-md removes the file', async () => {
// First write something
await request(app)
.put('/api/users/me/agents-md')
.set('Content-Type', 'text/plain')
.send('# Hello\n');
// Confirm it exists
const beforeGet = await request(app).get('/api/users/me/agents-md');
expect(beforeGet.body.exists).toBe(true);
// Delete it
const delRes = await request(app).delete('/api/users/me/agents-md');
expect(delRes.status).toBe(200);
expect(delRes.body.ok).toBe(true);
expect(delRes.body.existed).toBe(true);
// Confirm it's gone
const afterGet = await request(app).get('/api/users/me/agents-md');
expect(afterGet.body.exists).toBe(false);
});
it('DELETE /agents-md when file does not exist returns ok=true, existed=false', async () => {
const res = await request(app).delete('/api/users/me/agents-md');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.existed).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+992
View File
@@ -0,0 +1,992 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import express from 'express';
import { existsSync, readFileSync, writeFileSync, statSync, readdirSync, renameSync, mkdirSync, unlinkSync } from 'fs';
import { join, dirname, basename } from 'path';
import {
USER_SUBDIRS,
type UserSubdir,
ensureUserFolder,
resolveUserSubdir,
userRoot,
readUserAgentsMd,
writeUserAgentsMd,
deleteUserAgentsMd,
} from '../user-folder/paths.js';
import { logger } from '../logger.js';
import { compileScript } from '../user-folder/script-compiler.js';
import { parseScript, serializeScript } from '../user-folder/frontmatter.js';
import { runUserScript } from '../user-folder/script-runner.js';
import type { RecordedAction } from '../engine/browser-recorder.js';
import { recorder } from '../engine/browser-recorder.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { loadSessionStateForUser } from '../user-folder/session-loader.js';
import {
deletePet,
getPet,
importPetZip,
listPets,
PetConflictError,
PetValidationError,
readPetSettings,
resolvePetAsset,
slugifyPetId,
writePetSettings,
} from '../user-folder/pets.js';
import type { NotesService } from '../notes/notes-service.js';
interface Deps {
userFolderRoot: string;
sessRepo?: BrowserSessionRepo;
masterKeyPath?: string;
authActive?: boolean; // default true; when false, fall back to synthetic 'local' user
notesService?: NotesService;
}
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
const MAX_FILE_BYTES = 1024 * 1024; // 1 MB
function isUserSubdir(s: string): s is UserSubdir {
return (USER_SUBDIRS as readonly string[]).includes(s);
}
// Subdirs that users may write to / delete from. 'trash' is system-managed.
// 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers
// then delegate immediately to NotesService rather than the generic file writer.
const WRITABLE_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'notes'] as const;
type WritableSubdir = typeof WRITABLE_SUBDIRS[number];
function isWritableSubdir(s: string): s is WritableSubdir {
return (WRITABLE_SUBDIRS as readonly string[]).includes(s);
}
/**
* Write file atomically via tmp + rename.
* The tmp file is created in the same directory as the target to ensure
* rename is an atomic single-filesystem move.
*/
function writeAtomic(path: string, content: string): void {
const dir = dirname(path);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
let renamed = false;
try {
writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
renameSync(tmp, path);
renamed = true;
} finally {
if (!renamed) {
try { unlinkSync(tmp); } catch { /* tmp may not exist if writeFileSync threw */ }
}
}
}
/**
* Format a Date as YYYYMMDD-HHMMSS in UTC (used for trash prefix).
*/
function utcTimestamp(d: Date): string {
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
export function createUserFolderApi(deps: Deps): Router {
const { userFolderRoot } = deps;
const r = Router();
// ── Auth gate ────────────────────────────────────────────────────────────
const authActive = deps.authActive ?? true;
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
// Local-dev / no-auth mode: inject a synthetic 'local' user so handlers
// can operate against data/users/local/. Real OAuth deployments are
// unaffected because authActive=true and Passport populates req.user.
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// ── Pets: Codex Pets-compatible user imports ────────────────────────────
r.get('/pets', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
res.json({
pets: listPets(userFolderRoot, u.id),
settings: readPetSettings(userFolderRoot, u.id),
});
} catch (err) {
logger.error(`[user-folder-api] pets list failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list pets' });
}
});
r.post('/pets/import', express.raw({ limit: '12mb', type: '*/*' }), (req: Request, res: Response) => {
const u = getUser(req)!;
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const rawPetId = typeof req.query['petId'] === 'string'
? req.query['petId']
: typeof req.query['filename'] === 'string'
? req.query['filename']
: null;
const overwrite = req.query['overwrite'] === 'true';
try {
const detail = importPetZip(userFolderRoot, u.id, body, {
preferredId: rawPetId ? slugifyPetId(rawPetId) : null,
overwrite,
});
res.json({ ok: true, pet: detail });
} catch (err) {
if (err instanceof PetConflictError) {
res.status(409).json({ error: err.message, petId: err.petId });
return;
}
if (err instanceof PetValidationError || err instanceof SyntaxError) {
res.status(400).json({ error: (err as Error).message });
return;
}
logger.error(`[user-folder-api] pet import failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to import pet' });
}
});
r.get('/pets/settings', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
res.json({ settings: readPetSettings(userFolderRoot, u.id) });
} catch (err) {
logger.error(`[user-folder-api] pet settings read failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to read pet settings' });
}
});
r.put('/pets/settings', express.json({ limit: '32kb' }), (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const settings = writePetSettings(userFolderRoot, u.id, req.body);
res.json({ ok: true, settings });
} catch (err) {
if (err instanceof PetValidationError) {
res.status(400).json({ error: err.message });
return;
}
logger.error(`[user-folder-api] pet settings write failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to write pet settings' });
}
});
r.get('/pets/:petId/assets/:file', (req: Request, res: Response) => {
const u = getUser(req)!;
const asset = resolvePetAsset(userFolderRoot, u.id, req.params.petId, req.params.file);
if (!asset) {
res.status(404).json({ error: 'Asset not found' });
return;
}
res.setHeader('Content-Type', asset.contentType);
res.sendFile(asset.path);
});
r.get('/pets/:petId', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const pet = getPet(userFolderRoot, u.id, req.params.petId);
if (!pet) {
res.status(404).json({ error: 'Pet not found' });
return;
}
res.json({ pet });
} catch (err) {
if (err instanceof PetValidationError) {
res.status(400).json({ error: err.message });
return;
}
logger.error(`[user-folder-api] pet read failed user=${u.id} pet=${req.params.petId} err=${err}`);
res.status(500).json({ error: 'Failed to read pet' });
}
});
r.delete('/pets/:petId', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const deleted = deletePet(userFolderRoot, u.id, req.params.petId);
if (!deleted) {
res.status(404).json({ error: 'Pet not found' });
return;
}
res.json({ ok: true });
} catch (err) {
logger.error(`[user-folder-api] pet delete failed user=${u.id} pet=${req.params.petId} err=${err}`);
res.status(500).json({ error: 'Failed to delete pet' });
}
});
// ── GET /folder/list?subdir=scripts ──────────────────────────────────────
r.get('/folder/list', (req: Request, res: Response) => {
const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined;
if (!subdir || !isUserSubdir(subdir)) {
res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` });
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
const dirPath = join(userRoot(userFolderRoot, u.id), subdir);
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
const files = entries
.filter(e => e.isFile() && !e.name.startsWith('.'))
.map(e => {
const stat = statSync(join(dirPath, e.name));
return {
name: e.name,
size: stat.size,
mtime: stat.mtime.toISOString(),
};
});
res.json({ files });
} catch (err) {
logger.error(`[user-folder-api] list failed user=${u.id} subdir=${subdir} err=${err}`);
res.status(500).json({ error: 'Failed to list folder' });
}
});
// ── GET /folder/file?subdir=scripts&path=foo.js ──────────────────────────
r.get('/folder/file', (req: Request, res: Response) => {
const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined;
const relPath = req.query['path'] as string | undefined;
if (!subdir || !isUserSubdir(subdir)) {
res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` });
return;
}
if (!relPath) {
res.status(400).json({ error: 'path query parameter is required' });
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let fullPath: string;
try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
} catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return;
}
if (!existsSync(fullPath)) {
res.status(404).json({ error: 'File not found' });
return;
}
let stat: ReturnType<typeof statSync>;
try {
stat = statSync(fullPath);
} catch {
res.status(404).json({ error: 'File not found' });
return;
}
if (!stat.isFile()) {
res.status(404).json({ error: 'Not a file' });
return;
}
if (stat.size > MAX_FILE_BYTES) {
res.status(413).json({ error: 'File exceeds 1 MB limit' });
return;
}
try {
const content = readFileSync(fullPath, 'utf-8');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(content);
} catch (err) {
logger.error(`[user-folder-api] read failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
// ── PUT /folder/file?subdir=scripts&path=foo.js ──────────────────────────
r.put('/folder/file', express.text({ limit: '1mb', type: '*/*' }), async (req: Request, res: Response) => {
const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined;
const relPath = req.query['path'] as string | undefined;
if (!subdir || !isWritableSubdir(subdir)) {
res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` });
return;
}
if (!relPath) {
res.status(400).json({ error: 'path query parameter is required' });
return;
}
// ── notes subdir: delegate entirely to NotesService ──────────────────
if (subdir === 'notes') {
if (!deps.notesService) {
res.status(500).json({ error: 'notesService is not configured; cannot write notes' });
return;
}
// Validate path: must be exactly 2 segments (<folder>/<file.md>)
const segments = relPath.split('/').filter(s => s.length > 0);
if (segments.length !== 2) {
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
return;
}
const [folder, fileName] = segments as [string, string];
const content = typeof req.body === 'string' ? req.body : '';
try {
deps.notesService.writeNote({ ownerId: u.id, folder, fileName, content });
res.json({ ok: true, indexed: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/scope_org_id|invalid|path|\.md/.test(msg)) {
res.status(400).json({ error: msg });
return;
}
logger.error(`[user-folder-api] notes write failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to write note' });
}
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let fullPath: string;
try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
} catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return;
}
const content = typeof req.body === 'string' ? req.body : '';
try {
writeAtomic(fullPath, content);
const stat = statSync(fullPath);
res.json({ ok: true, size: stat.size, mtime: stat.mtime.toISOString() });
} catch (err) {
logger.error(`[user-folder-api] write failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to write file' });
}
});
// ── DELETE /folder/file?subdir=scripts&path=foo.js ───────────────────────
r.delete('/folder/file', async (req: Request, res: Response) => {
const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined;
const relPath = req.query['path'] as string | undefined;
if (!subdir || !isWritableSubdir(subdir)) {
res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` });
return;
}
if (!relPath) {
res.status(400).json({ error: 'path query parameter is required' });
return;
}
// ── notes subdir: delegate entirely to NotesService ──────────────────
if (subdir === 'notes') {
if (!deps.notesService) {
res.status(500).json({ error: 'notesService is not configured; cannot delete notes' });
return;
}
const segments = relPath.split('/').filter(s => s.length > 0);
if (segments.length !== 2) {
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
return;
}
const [folder, fileName] = segments as [string, string];
try {
deps.notesService.deleteNote({ ownerId: u.id, folder, fileName });
res.json({ ok: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/scope_org_id|invalid|path|\.md/.test(msg)) {
res.status(400).json({ error: msg });
return;
}
logger.error(`[user-folder-api] notes delete failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to delete note' });
}
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let fullPath: string;
try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
} catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return;
}
if (!existsSync(fullPath)) {
res.status(404).json({ error: 'File not found' });
return;
}
// Extract the base filename for the trash name
const originalName = relPath.split('/').pop()!;
const ts = utcTimestamp(new Date());
const suffix = Math.random().toString(16).slice(2, 6);
const trashedAs = `${ts}-${suffix}-${originalName}`;
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
const trashPath = join(trashDir, trashedAs);
try {
renameSync(fullPath, trashPath);
res.json({ ok: true, trashedAs });
} catch (err) {
logger.error(`[user-folder-api] delete/trash failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to move file to trash' });
}
});
// ── POST /browser-macros/compile ──────────────────────────────────────────────
r.post('/browser-macros/compile', express.json({ limit: '256kb' }), async (req: Request, res: Response) => {
const u = getUser(req)!;
const {
recordingName,
scriptName,
description,
sessionProfileId,
paramHints,
} = req.body as {
recordingName?: unknown;
scriptName?: unknown;
description?: unknown;
sessionProfileId?: unknown;
paramHints?: unknown;
};
if (typeof recordingName !== 'string' || !recordingName.trim()) {
res.status(400).json({ error: 'recordingName is required' });
return;
}
if (typeof scriptName !== 'string' || !scriptName.trim()) {
res.status(400).json({ error: 'scriptName is required' });
return;
}
if (typeof description !== 'string') {
res.status(400).json({ error: 'description is required' });
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
// Resolve recording path
let recordingPath: string;
try {
recordingPath = resolveUserSubdir(userFolderRoot, u.id, 'recordings', `${recordingName}.json`);
} catch {
res.status(400).json({ error: 'Invalid recordingName' });
return;
}
if (!existsSync(recordingPath)) {
res.status(404).json({ error: `Recording not found: ${recordingName}.json` });
return;
}
// Parse recording JSON
let recording: { recordTo?: unknown; capturedAt?: unknown; actions?: unknown };
try {
recording = JSON.parse(readFileSync(recordingPath, 'utf-8')) as typeof recording;
} catch {
res.status(400).json({ error: 'Recording is not valid JSON' });
return;
}
// Validate shape
if (!recording || typeof recording !== 'object' || !Array.isArray(recording.actions)) {
res.status(400).json({ error: 'Recording is missing required fields (expected { recordTo, capturedAt, actions })' });
return;
}
// Conflict policy: check if script already exists
const scriptFileName = scriptName.endsWith('.js') ? scriptName : `${scriptName}.js`;
let scriptPath: string;
try {
scriptPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
} catch {
res.status(400).json({ error: 'Invalid scriptName' });
return;
}
if (existsSync(scriptPath) && req.query['overwrite'] !== 'true') {
res.status(409).json({ error: 'Script already exists; pass ?overwrite=true to replace' });
return;
}
// Validate paramHints shape
if (paramHints !== undefined) {
if (!Array.isArray(paramHints)) {
res.status(400).json({ error: 'paramHints must be an array' });
return;
}
for (let i = 0; i < paramHints.length; i++) {
const hint = paramHints[i];
if (!hint || typeof hint !== 'object' ||
typeof hint.name !== 'string' || !hint.name ||
typeof hint.valueToReplace !== 'string' ||
!['string', 'number', 'boolean'].includes((hint as any).type)) {
res.status(400).json({
error: `paramHints[${i}] must be { name: string, valueToReplace: string, type: 'string' | 'number' | 'boolean' }`,
});
return;
}
}
}
// Compile
let compiled: ReturnType<typeof compileScript>;
try {
compiled = compileScript({
recording: recording.actions as RecordedAction[],
description,
sessionProfileId: typeof sessionProfileId === 'number' ? sessionProfileId : undefined,
paramHints: Array.isArray(paramHints)
? (paramHints as { name: string; valueToReplace: string; type: 'string' | 'number' | 'boolean' }[])
: undefined,
recordingSource: `${recordingName}.json`,
});
} catch (err) {
logger.error(`[user-folder-api] compile failed user=${u.id} recording=${recordingName} err=${err}`);
res.status(500).json({ error: `Compile failed: ${(err as Error).message}` });
return;
}
// Stamp timestamps and re-serialize
const now = new Date().toISOString();
const meta = { ...compiled.meta, createdAt: now, updatedAt: now };
const { body } = parseScript(compiled.source);
const source = serializeScript({ frontmatter: meta, body });
// Write atomically
try {
writeAtomic(scriptPath, source);
} catch (err) {
logger.error(`[user-folder-api] write failed user=${u.id} script=${scriptName} err=${err}`);
res.status(500).json({ error: 'Failed to write script file' });
return;
}
const size = statSync(scriptPath).size;
res.json({ ok: true, scriptName: scriptFileName, source, size });
});
// ── POST /scripts/:name/run ───────────────────────────────────────────────────
// body.kind: 'script' | 'browser-macro' — determines which subdir to load from.
// If omitted, both are tried (scripts/ first, then browser-macros/).
r.post('/scripts/:name/run', express.json({ limit: '256kb' }), async (req: Request, res: Response) => {
const u = getUser(req)!;
const rawName = req.params['name'] ?? '';
const scriptFileName = rawName.endsWith('.js') ? rawName : `${rawName}.js`;
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
const { params, timeoutMs, kind } = ((req.body as Record<string, unknown>) ?? {}) as {
params?: Record<string, unknown>;
timeoutMs?: number;
kind?: string;
};
// Resolve script path depending on kind
let scriptPath: string | null = null;
let resolvedRuntime: 'plain' | 'playwright' = 'plain';
if (!kind || kind === 'script') {
try {
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'scripts', scriptFileName);
if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'plain'; }
} catch { /* invalid path */ }
}
if (!scriptPath && (!kind || kind === 'browser-macro')) {
try {
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'playwright'; }
} catch { /* invalid path */ }
}
if (!scriptPath && kind === 'script') {
// explicit kind but no match — keep null to hit 404 below
}
if (scriptPath === null) {
res.status(404).json({ error: `Script not found: ${scriptFileName}` });
return;
}
// Clamp timeoutMs to 5 minutes max (prevent malicious long-running requests)
const requestedTimeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 60_000;
const cappedTimeout = Math.min(requestedTimeout, 300_000);
// Load session storageState if it's a playwright-runtime script with sessionProfileId
let storageState: object | undefined;
if (resolvedRuntime === 'playwright') {
try {
const source = readFileSync(scriptPath, 'utf-8');
const parsed = parseScript(source);
const sessionProfileId = parsed.frontmatter.sessionProfileId;
if (sessionProfileId !== undefined) {
if (!deps.sessRepo || !deps.masterKeyPath) {
res.status(500).json({
error: 'Session profile required but session repository is not configured',
});
return;
}
const sessionResult = await loadSessionStateForUser(
{ sessRepo: deps.sessRepo, masterKeyPath: deps.masterKeyPath },
u.id,
sessionProfileId,
);
if (!sessionResult.ok) {
res.status(500).json({ error: sessionResult.error.message });
return;
}
storageState = sessionResult.storageState;
}
} catch (err) {
res.status(500).json({ error: `Failed to parse script: ${(err as Error).message}` });
return;
}
}
// Run
const startMs = Date.now();
try {
const scriptResult = await runUserScript({
scriptPath,
params: params ?? {},
runtime: resolvedRuntime,
storageState,
timeoutMs: cappedTimeout,
});
const durationMs = Date.now() - startMs;
res.json({ result: scriptResult.result, logs: scriptResult.logs, durationMs });
} catch (err) {
const durationMs = Date.now() - startMs;
const message = (err as Error).message;
res.status(500).json({ error: message, durationMs });
}
});
// ── GET /browser-macros/:name/diff ───────────────────────────────────────────
// Returns { current: string|null, candidate: string, candidateMtime: string }
// or 404 if no .next.js exists.
r.get('/browser-macros/:name/diff', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawName = req.params['name'] ?? '';
// Normalize: strip any trailing .js suffix to get the bare name
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let candidatePath: string;
let currentPath: string;
try {
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`);
} catch {
res.status(400).json({ error: 'Invalid script name' });
return;
}
if (!existsSync(candidatePath)) {
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
return;
}
let candidate: string;
let candidateMtime: string;
try {
candidate = readFileSync(candidatePath, 'utf-8');
candidateMtime = statSync(candidatePath).mtime.toISOString();
} catch (err) {
logger.error(`[user-folder-api] diff read candidate failed user=${u.id} name=${baseName} err=${err}`);
res.status(500).json({ error: 'Failed to read candidate file' });
return;
}
// current may not exist (orphaned .next.js)
let current: string | null = null;
if (existsSync(currentPath)) {
try {
current = readFileSync(currentPath, 'utf-8');
} catch (err) {
logger.error(`[user-folder-api] diff read current failed user=${u.id} name=${baseName} err=${err}`);
res.status(500).json({ error: 'Failed to read current file' });
return;
}
}
res.json({ current, candidate, candidateMtime });
});
// ── POST /browser-macros/:name/accept ────────────────────────────────────────
// Atomically archives browser-macros/{name}.js to trash, then renames .next.js into place.
// NOTE: Not fully atomic — a crash between step 1 and step 2 would leave no
// browser-macros/{name}.js. Acceptable given the complexity of a copy-then-rename
// alternative. The .next.js is always preserved or moved to trash.
r.post('/browser-macros/:name/accept', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawName = req.params['name'] ?? '';
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let candidatePath: string;
let currentPath: string;
try {
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`);
} catch {
res.status(400).json({ error: 'Invalid script name' });
return;
}
if (!existsSync(candidatePath)) {
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
return;
}
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
const ts = utcTimestamp(new Date());
const suffix = Math.random().toString(16).slice(2, 6);
let archivedAs: string | null = null;
try {
// Step 1: Archive the existing script to trash (if it exists)
if (existsSync(currentPath)) {
archivedAs = `${ts}-${suffix}-${baseName}.js`;
const trashPath = join(trashDir, archivedAs);
renameSync(currentPath, trashPath);
}
// Step 2: Rename .next.js into the canonical script location
renameSync(candidatePath, currentPath);
} catch (err) {
logger.error(`[user-folder-api] accept failed user=${u.id} name=${baseName} err=${err}`);
res.status(500).json({ error: 'Failed to accept patch' });
return;
}
logger.info(`[user-folder-api] accept user=${u.id} name=${baseName} archivedAs=${archivedAs ?? 'none'}`);
res.json({ ok: true, accepted: `${baseName}.js`, archivedAs });
});
// ── POST /browser-macros/:name/reject ────────────────────────────────────────
// Moves browser-macros/{name}.next.js to trash; the original .js is untouched.
r.post('/browser-macros/:name/reject', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawName = req.params['name'] ?? '';
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let candidatePath: string;
try {
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
} catch {
res.status(400).json({ error: 'Invalid script name' });
return;
}
if (!existsSync(candidatePath)) {
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
return;
}
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
const ts = utcTimestamp(new Date());
const suffix = Math.random().toString(16).slice(2, 6);
const trashedAs = `${ts}-${suffix}-${baseName}.next.js`;
const trashPath = join(trashDir, trashedAs);
try {
renameSync(candidatePath, trashPath);
} catch (err) {
logger.error(`[user-folder-api] reject failed user=${u.id} name=${baseName} err=${err}`);
res.status(500).json({ error: 'Failed to reject patch' });
return;
}
logger.info(`[user-folder-api] reject user=${u.id} name=${baseName} trashedAs=${trashedAs}`);
res.json({ ok: true, rejected: `${baseName}.next.js`, trashedAs });
});
// ── POST /recordings/flush?taskId=<id> ───────────────────────────────────
// Flush the in-memory recording buffer for a given taskId to disk.
// Returns { ok: true, recordingName, path } or 404 if no buffer exists.
r.post('/recordings/flush', (req: Request, res: Response) => {
const u = getUser(req)!;
const taskId = req.query['taskId'] as string | undefined;
if (!taskId) {
res.status(400).json({ error: 'taskId query parameter is required' });
return;
}
let absPath: string | null;
try {
absPath = recorder.flush(taskId, userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] recordings/flush failed user=${u.id} taskId=${taskId} err=${err}`);
res.status(500).json({ error: 'Failed to flush recording' });
return;
}
if (absPath === null) {
res.status(404).json({ error: 'no active recording for this task' });
return;
}
// e.g. absPath = "/data/users/user-a/recordings/my-rec.json"
// recordingName = "my-rec" (basename without .json)
const fileBasename = basename(absPath);
const recordingName = fileBasename.endsWith('.json')
? fileBasename.slice(0, -5)
: fileBasename;
const relPath = `recordings/${fileBasename}`;
logger.info(`[user-folder-api] recordings/flush user=${u.id} taskId=${taskId} recordingName=${recordingName}`);
res.json({ ok: true, recordingName, path: relPath });
});
// ── GET /agents-md ───────────────────────────────────────────────────────
r.get('/agents-md', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const content = readUserAgentsMd(userFolderRoot, u.id);
if (content === null) {
res.json({ exists: false, content: '' });
return;
}
res.json({ exists: true, content });
} catch (err) {
logger.error(`[user-folder-api] read AGENTS.md failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to read AGENTS.md' });
}
});
// ── PUT /agents-md ───────────────────────────────────────────────────────
r.put('/agents-md', express.text({ type: '*/*', limit: '256kb' }), (req: Request, res: Response) => {
const u = getUser(req)!;
const body = (req.body as unknown) as string;
if (typeof body !== 'string') {
res.status(400).json({ error: 'body must be text/plain' });
return;
}
try {
writeUserAgentsMd(userFolderRoot, u.id, body);
res.json({ ok: true, bytes: Buffer.byteLength(body, 'utf-8') });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('exceeds')) {
res.status(413).json({ error: msg });
return;
}
logger.error(`[user-folder-api] write AGENTS.md failed user=${u.id} err=${msg}`);
res.status(500).json({ error: 'Failed to write AGENTS.md' });
}
});
// ── DELETE /agents-md ────────────────────────────────────────────────────
r.delete('/agents-md', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const existed = deleteUserAgentsMd(userFolderRoot, u.id);
res.json({ ok: true, existed });
} catch (err) {
logger.error(`[user-folder-api] delete AGENTS.md failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to delete AGENTS.md' });
}
});
// ── Router-level error middleware ─────────────────────────────────────────
// Catches errors from route handlers (e.g. express.text body-too-large).
r.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
if (err && err.type === 'entity.too.large') {
res.status(413).json({ error: 'Request body exceeds 1 MB limit' });
return;
}
res.status(err?.status ?? 500).json({ error: err?.message ?? 'Internal error' });
});
return r;
}
+64
View File
@@ -0,0 +1,64 @@
import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { Repository } from '../db/repository.js';
import { requireAuth } from './auth.js';
const passthrough: RequestHandler = (_req, _res, next) => next();
/**
* /api/users/me endpoints.
*
* When `authActive` is true, routes are gated by `requireAuth` and rely on
* `req.user.id` being populated by passport. When auth is disabled (tests or
* standalone mode), the guard is skipped and callers are expected to inject
* `req.user` via their own middleware (e.g. the test harness).
*/
export function mountUsersApi(app: Application, repo: Repository, authActive = true): void {
const guard = authActive ? requireAuth : passthrough;
// Viewer's cached Gitea orgs (populated at OAuth callback by the gitea strategy).
app.get('/api/users/me/orgs', guard, (req: Request, res: Response) => {
const user = req.user as Express.User | undefined;
if (!user) {
// Defensive: with authActive=false and no injected user, return 401-shaped error.
res.status(401).json({ error: 'Unauthorized' });
return;
}
const orgs = repo.listUserGiteaOrgs(user.id);
res.json({ orgs });
});
// Update viewer's per-user preferences (currently just default visibility).
app.patch('/api/users/me/preferences', guard, express.json(), (req: Request, res: Response) => {
const user = req.user as Express.User | undefined;
if (!user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const body = (req.body ?? {}) as {
defaultVisibility?: unknown;
defaultVisibilityOrgId?: unknown;
};
const { defaultVisibility, defaultVisibilityOrgId } = body;
if (defaultVisibility !== undefined && defaultVisibility !== null &&
!['private', 'org', 'public'].includes(defaultVisibility as string)) {
res.status(400).json({ error: 'invalid defaultVisibility' });
return;
}
if (defaultVisibility === 'org') {
const scopeId = typeof defaultVisibilityOrgId === 'string' ? defaultVisibilityOrgId : '';
if (!scopeId) {
res.status(400).json({ error: 'default_visibility_org_id is required when defaultVisibility is "org"' });
return;
}
if (!user.orgIds.includes(scopeId)) {
res.status(400).json({ error: 'default_visibility_org_id must be one of your orgs' });
return;
}
}
repo.updateUser(user.id, {
defaultVisibility: (defaultVisibility as 'private' | 'org' | 'public' | undefined) ?? undefined,
defaultVisibilityOrgId: (defaultVisibilityOrgId as string | null | undefined) ?? null,
});
res.json({ ok: true });
});
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { parseTaskId, validateCreateTaskBody, validateCommentBody } from './validation.js';
describe('parseTaskId', () => {
it('正の整数を返す', () => {
expect(parseTaskId('1')).toBe(1);
expect(parseTaskId('999')).toBe(999);
});
it('0 は null', () => {
expect(parseTaskId('0')).toBeNull();
});
it('負数は null', () => {
expect(parseTaskId('-1')).toBeNull();
});
it('小数は null', () => {
expect(parseTaskId('1.5')).toBeNull();
});
it('NaN は null', () => {
expect(parseTaskId('abc')).toBeNull();
expect(parseTaskId('')).toBeNull();
});
});
describe('validateCreateTaskBody', () => {
it('正常な body を受け入れる', () => {
const result = validateCreateTaskBody({ body: 'hello' });
expect(result.valid).toBe(true);
});
it('body 空はエラー', () => {
const result = validateCreateTaskBody({ body: '' });
expect(result.valid).toBe(false);
});
it('body 未設定はエラー', () => {
const result = validateCreateTaskBody({});
expect(result.valid).toBe(false);
});
it('body が 100000 文字超はエラー', () => {
const result = validateCreateTaskBody({ body: 'x'.repeat(100001) });
expect(result.valid).toBe(false);
});
it('不正な profile はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', profile: 'invalid' });
expect(result.valid).toBe(false);
});
it('正常な profile は受け入れる', () => {
for (const p of ['auto', 'fast', 'quality']) {
const result = validateCreateTaskBody({ body: 'test', profile: p });
expect(result.valid).toBe(true);
}
});
it('不正な outputFormat はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', outputFormat: 'xml' });
expect(result.valid).toBe(false);
});
it('不正な askPolicy はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', askPolicy: 'medium' });
expect(result.valid).toBe(false);
});
it('不正な priority はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', priority: 'critical' });
expect(result.valid).toBe(false);
});
it('title が 200 文字超はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', title: 'x'.repeat(201) });
expect(result.valid).toBe(false);
});
it('undefined のオプションフィールドは受け入れる', () => {
const result = validateCreateTaskBody({ body: 'test' });
expect(result.valid).toBe(true);
});
});
describe('validateCommentBody', () => {
it('正常な body を受け入れる', () => {
const result = validateCommentBody({ body: 'hello' });
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.body).toBe('hello');
expect(result.author).toBe('user');
}
});
it('body 空はエラー', () => {
const result = validateCommentBody({ body: '' });
expect(result.valid).toBe(false);
});
it('body が 100000 文字超はエラー', () => {
const result = validateCommentBody({ body: 'x'.repeat(100001) });
expect(result.valid).toBe(false);
});
it('author を指定できる', () => {
const result = validateCommentBody({ body: 'hi', author: 'bot' });
expect(result.valid).toBe(true);
if (result.valid) expect(result.author).toBe('bot');
});
it('author 未指定は user がデフォルト', () => {
const result = validateCommentBody({ body: 'hi' });
expect(result.valid).toBe(true);
if (result.valid) expect(result.author).toBe('user');
});
});
+142
View File
@@ -0,0 +1,142 @@
const VALID_PROFILES = ['auto', 'fast', 'quality'] as const;
const VALID_OUTPUT_FORMATS = ['text', 'markdown', 'json'] as const;
const VALID_ASK_POLICIES = ['low', 'high'] as const;
const VALID_PRIORITIES = ['low', 'medium', 'high'] as const;
const MAX_BODY_LENGTH = 100_000;
const MAX_TITLE_LENGTH = 200;
const MAX_COMMENT_LENGTH = 100_000;
export function parseTaskId(raw: string): number | null {
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return null;
return n;
}
export interface ValidatedCreateTask {
body: string;
title?: string;
piece?: string;
profile?: typeof VALID_PROFILES[number];
outputFormat?: typeof VALID_OUTPUT_FORMATS[number];
askPolicy?: typeof VALID_ASK_POLICIES[number];
priority?: typeof VALID_PRIORITIES[number];
attachments?: Array<{ name: string; contentBase64: string }>;
}
type ValidationResult =
| { valid: true; data: ValidatedCreateTask }
| { valid: false; error: string };
export function validateCreateTaskBody(raw: unknown): ValidationResult {
if (!raw || typeof raw !== 'object') {
return { valid: false, error: 'Request body must be an object' };
}
const obj = raw as Record<string, unknown>;
if (typeof obj.body !== 'string' || obj.body.trim().length === 0) {
return { valid: false, error: 'body is required' };
}
if (obj.body.length > MAX_BODY_LENGTH) {
return { valid: false, error: `body must be ${MAX_BODY_LENGTH} characters or less` };
}
if (obj.title !== undefined && obj.title !== null) {
if (typeof obj.title !== 'string') {
return { valid: false, error: 'title must be a string' };
}
if (obj.title.length > MAX_TITLE_LENGTH) {
return { valid: false, error: `title must be ${MAX_TITLE_LENGTH} characters or less` };
}
}
if (obj.profile !== undefined && obj.profile !== null) {
if (!(VALID_PROFILES as readonly string[]).includes(String(obj.profile))) {
return { valid: false, error: `profile must be one of: ${VALID_PROFILES.join(', ')}` };
}
}
if (obj.outputFormat !== undefined && obj.outputFormat !== null) {
if (!(VALID_OUTPUT_FORMATS as readonly string[]).includes(String(obj.outputFormat))) {
return { valid: false, error: `outputFormat must be one of: ${VALID_OUTPUT_FORMATS.join(', ')}` };
}
}
if (obj.askPolicy !== undefined && obj.askPolicy !== null) {
if (!(VALID_ASK_POLICIES as readonly string[]).includes(String(obj.askPolicy))) {
return { valid: false, error: `askPolicy must be one of: ${VALID_ASK_POLICIES.join(', ')}` };
}
}
if (obj.priority !== undefined && obj.priority !== null) {
if (!(VALID_PRIORITIES as readonly string[]).includes(String(obj.priority))) {
return { valid: false, error: `priority must be one of: ${VALID_PRIORITIES.join(', ')}` };
}
}
return {
valid: true,
data: {
body: obj.body as string,
title: obj.title as string | undefined,
piece: obj.piece as string | undefined,
profile: obj.profile as ValidatedCreateTask['profile'],
outputFormat: obj.outputFormat as ValidatedCreateTask['outputFormat'],
askPolicy: obj.askPolicy as ValidatedCreateTask['askPolicy'],
priority: obj.priority as ValidatedCreateTask['priority'],
attachments: obj.attachments as ValidatedCreateTask['attachments'],
},
};
}
export function validateCommentBody(raw: unknown): { valid: true; body: string; author: string; attachments?: Array<{ name: string; contentBase64: string }> } | { valid: false; error: string } {
if (!raw || typeof raw !== 'object') {
return { valid: false, error: 'Request body must be an object' };
}
const obj = raw as Record<string, unknown>;
const body = String(obj.body ?? '').trim();
if (!body) {
return { valid: false, error: 'body is required' };
}
if (body.length > MAX_COMMENT_LENGTH) {
return { valid: false, error: `body must be ${MAX_COMMENT_LENGTH} characters or less` };
}
const author = String(obj.author ?? 'user').trim() || 'user';
const attachments = Array.isArray(obj.attachments) ? obj.attachments as Array<{ name: string; contentBase64: string }> : undefined;
return { valid: true, body, author, attachments };
}
export type ValidatedFeedback = {
rating: 'good' | 'bad';
tags: string[];
comment: string | null;
};
type FeedbackValidationResult =
| { valid: true; data: ValidatedFeedback }
| { valid: false; error: string };
export function validateFeedbackBody(raw: unknown): FeedbackValidationResult {
if (!raw || typeof raw !== 'object') {
return { valid: false, error: 'Request body must be an object' };
}
const obj = raw as Record<string, unknown>;
if (obj.rating !== 'good' && obj.rating !== 'bad') {
return { valid: false, error: "rating must be 'good' or 'bad'" };
}
if (!Array.isArray(obj.tags) || obj.tags.some((t: unknown) => typeof t !== 'string')) {
return { valid: false, error: 'tags must be an array of strings' };
}
if (obj.tags.length > 10) {
return { valid: false, error: 'tags must have at most 10 items' };
}
const comment = obj.comment != null ? String(obj.comment) : null;
if (comment && comment.length > 1000) {
return { valid: false, error: 'comment must be at most 1000 characters' };
}
return {
valid: true,
data: { rating: obj.rating, tags: obj.tags as string[], comment },
};
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { buildVisibilityWhere, canUserSeeTask } from './visibility.js';
function makeUser(overrides: Partial<Express.User> = {}): Express.User {
return {
id: 'user-1', email: '[email protected]', name: 'u', avatarUrl: null,
role: 'user', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
...overrides,
};
}
describe('buildVisibilityWhere', () => {
it('admin sees everything (1=1)', () => {
const w = buildVisibilityWhere(makeUser({ role: 'admin' }), 'lt');
expect(w.clause).toBe('1=1');
expect(w.params).toEqual([]);
});
it('user with no orgs: owner or public only', () => {
const w = buildVisibilityWhere(makeUser(), 'lt');
expect(w.clause).toContain('lt.owner_id = ?');
expect(w.clause).toContain("lt.visibility = 'public'");
expect(w.clause).toContain('IN (NULL)'); // empty orgs → never matches
expect(w.params).toEqual(['user-1']);
});
it('user with orgs: owner or public or same-org', () => {
const w = buildVisibilityWhere(makeUser({ orgIds: ['10', '20'] }), 'lt');
expect(w.clause).toMatch(/lt\.visibility_scope_org_id IN \(\?,\?\)/);
expect(w.params).toEqual(['user-1', '10', '20']);
});
it('respects custom table alias', () => {
const w = buildVisibilityWhere(makeUser(), 'j');
expect(w.clause).toContain('j.owner_id');
expect(w.clause).not.toContain('lt.');
});
});
describe('canUserSeeTask', () => {
const adminUser = makeUser({ role: 'admin' });
const aliceNoOrg = makeUser({ id: 'alice' });
const bobOrg10 = makeUser({ id: 'bob', orgIds: ['10'] });
it('admin sees private tasks of others', () => {
const t = { ownerId: 'someone-else', visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(adminUser, t)).toBe(true);
});
it('owner sees own private task', () => {
const t = { ownerId: 'alice', visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(true);
});
it('non-owner cannot see another user\'s private task', () => {
const t = { ownerId: 'someone-else', visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(false);
});
it('public task is visible to anyone', () => {
const t = { ownerId: 'someone-else', visibility: 'public' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(true);
});
it('org task: same org member can see', () => {
const t = { ownerId: 'someone-else', visibility: 'org' as const, visibilityScopeOrgId: '10' };
expect(canUserSeeTask(bobOrg10, t)).toBe(true);
});
it('org task: different org member cannot see', () => {
const t = { ownerId: 'someone-else', visibility: 'org' as const, visibilityScopeOrgId: '99' };
expect(canUserSeeTask(bobOrg10, t)).toBe(false);
});
it('org task with null scope: only owner can see', () => {
const t = { ownerId: 'alice', visibility: 'org' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(true);
expect(canUserSeeTask(bobOrg10, t)).toBe(false);
});
it('owner with null ownerId: not matched (null !== null check skipped)', () => {
const t = { ownerId: null, visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(false);
});
});
+48
View File
@@ -0,0 +1,48 @@
export interface VisibilityWhere {
clause: string;
params: unknown[];
}
export function buildVisibilityWhere(user: Express.User, tableAlias: string): VisibilityWhere {
if (user.role === 'admin') {
return { clause: '1=1', params: [] };
}
const orgPlaceholders = user.orgIds.length > 0
? user.orgIds.map(() => '?').join(',')
: 'NULL';
return {
clause: `(
${tableAlias}.owner_id = ?
OR ${tableAlias}.visibility = 'public'
OR (${tableAlias}.visibility = 'org' AND ${tableAlias}.visibility_scope_org_id IN (${orgPlaceholders}))
)`.replace(/\s+/g, ' ').trim(),
params: [user.id, ...user.orgIds],
};
}
export function canEditEntity(user: Express.User, entity: { ownerId: string | null }): boolean {
return user.role === 'admin' || entity.ownerId === user.id;
}
/**
* 指定 user が指定タスクを閲覧できるか判定する。SQL ではなくロード済みオブジェクト
* に対して使う。`buildVisibilityWhere` と同じセマンティクスをコード上で再現する。
*
* - admin: 常に true
* - owner: 常に true
* - public: 常に true
* - org: user.orgIds に visibilityScopeOrgId が含まれているかで判定
* - private: owner 以外は false
*/
export function canUserSeeTask(
user: Express.User,
task: { ownerId: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null },
): boolean {
if (user.role === 'admin') return true;
if (task.ownerId !== null && task.ownerId === user.id) return true;
if (task.visibility === 'public') return true;
if (task.visibility === 'org' && task.visibilityScopeOrgId !== null) {
return user.orgIds.includes(task.visibilityScopeOrgId);
}
return false;
}
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect } from 'vitest';
import { parse } from 'yaml';
import { patchYaml, diff } from './yaml-patch.js';
// Representative piece with all the formatting we want to preserve.
const ORIGINAL = [
'name: sample',
'description: |',
' Multi-line',
' description.',
'max_movements: 10',
'initial_movement: start',
'',
'movements:',
' - name: start',
' edit: false',
' persona: analyst',
' instruction: |',
' Do the thing.',
' Keep newlines.',
' allowed_tools: [Read, Glob]',
' default_next: execute',
' rules:',
' - condition: ok',
' next: execute',
'',
' - name: execute',
' edit: true',
' persona: worker',
' instruction: |',
' Work work work.',
' allowed_tools: [Read, Write, Edit]',
' default_next: verify',
' rules:',
' - condition: done',
' next: verify',
'',
' - name: verify',
' edit: false',
' persona: reviewer',
' instruction: |',
' Review it.',
' allowed_tools: [Read]',
' default_next: COMPLETE',
' rules:',
' - condition: good',
' next: COMPLETE',
'',
].join('\n');
describe('diff', () => {
it('returns no ops when values are equal', () => {
expect(diff({ a: 1, b: [1, 2] }, { a: 1, b: [1, 2] })).toEqual([]);
});
it('emits a single set for a changed leaf', () => {
const ops = diff({ a: 1, b: 2 }, { a: 1, b: 3 });
expect(ops).toEqual([{ kind: 'set', path: ['b'], value: 3 }]);
});
it('emits a delete for a removed key', () => {
const ops = diff({ a: 1, b: 2 }, { a: 1 });
expect(ops).toEqual([{ kind: 'delete', path: ['b'] }]);
});
it('emits a set for a new key', () => {
const ops = diff({ a: 1 }, { a: 1, b: 2 });
expect(ops).toEqual([{ kind: 'set', path: ['b'], value: 2 }]);
});
it('recurses element-wise when array lengths match', () => {
const ops = diff({ xs: [1, 2, 3] }, { xs: [1, 2, 4] });
expect(ops).toEqual([{ kind: 'set', path: ['xs', 2], value: 4 }]);
});
it('replaces whole array when lengths differ', () => {
const ops = diff({ xs: [1, 2, 3] }, { xs: [1, 2, 3, 4] });
expect(ops).toEqual([{ kind: 'set', path: ['xs'], value: [1, 2, 3, 4] }]);
});
});
describe('patchYaml', () => {
it('preserves `|` literal block style when editing instruction text', () => {
const body = parse(ORIGINAL);
body.movements[0].instruction = 'Do the updated thing.\nStill multiline.\n';
const patched = patchYaml(ORIGINAL, body);
expect(patched).toContain('instruction: |');
expect(patched).not.toMatch(/instruction:\s*>/);
// round-trips to the intended value
expect(parse(patched).movements[0].instruction).toBe(
'Do the updated thing.\nStill multiline.\n',
);
});
it('does not reformat unrelated inline allowed_tools array', () => {
const body = parse(ORIGINAL);
body.movements[0].instruction = 'tweaked';
const patched = patchYaml(ORIGINAL, body);
// yaml v2 may normalize bracket spacing (`[x]` -> `[ x ]`), but the key
// invariant is that flow-style stays flow (no multiline block expansion).
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Write,\s*Edit\s*\]/);
expect(patched).toMatch(/allowed_tools: \[\s*Read\s*\]/);
// And crucially it must NOT have been expanded to block style:
expect(patched).not.toMatch(/allowed_tools:\s*\n\s*-\s*Read\s*\n\s*-\s*Write/);
});
it('preserves blank lines between movements', () => {
const body = parse(ORIGINAL);
body.movements[1].persona = 'coder';
const patched = patchYaml(ORIGINAL, body);
// Each movement in the original is separated by a blank line. Check that
// the sequence `\n\n - name:` still appears between them.
const blankLineBeforeMovements = patched.match(/\n\n - name:/g);
expect(blankLineBeforeMovements).not.toBeNull();
expect(blankLineBeforeMovements!.length).toBeGreaterThanOrEqual(2);
});
it('supports adding a brand-new movement', () => {
const body = parse(ORIGINAL);
body.movements.push({
name: 'extra',
edit: false,
persona: 'helper',
instruction: 'added step',
allowed_tools: ['Read'],
default_next: 'COMPLETE',
rules: [{ condition: 'x', next: 'COMPLETE' }],
});
const patched = patchYaml(ORIGINAL, body);
const reparsed = parse(patched);
expect(reparsed.movements).toHaveLength(4);
expect(reparsed.movements[3].name).toBe('extra');
// other movements still have their literal-block instructions intact
expect(patched).toContain('instruction: |');
});
it('supports deleting a movement', () => {
const body = parse(ORIGINAL);
body.movements.splice(1, 1); // drop `execute`
const patched = patchYaml(ORIGINAL, body);
const reparsed = parse(patched);
expect(reparsed.movements).toHaveLength(2);
expect(reparsed.movements.map((m: any) => m.name)).toEqual(['start', 'verify']);
});
it('renaming default_next is a minimal targeted change', () => {
const body = parse(ORIGINAL);
body.movements[0].default_next = 'verify';
const patched = patchYaml(ORIGINAL, body);
expect(parse(patched).movements[0].default_next).toBe('verify');
// untouched: instruction block style, inline arrays, blank lines
expect(patched).toContain('instruction: |');
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Glob\s*\]/);
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Write,\s*Edit\s*\]/);
expect(patched).toMatch(/\n\n - name: execute/);
});
it('falls back to stringify when original yaml is malformed', () => {
const broken = 'name: x\n bad-indent: [\n';
const body = { name: 'x', description: 'ok' };
const out = patchYaml(broken, body);
// Fallback should still produce parseable output with the new body.
const parsed = parse(out);
expect(parsed.name).toBe('x');
expect(parsed.description).toBe('ok');
});
it('semantic content matches body after patching', () => {
const body = parse(ORIGINAL);
body.description = 'new description';
body.movements[2].instruction = 'reviewed differently';
body.movements[2].allowed_tools = ['Read', 'Glob'];
const patched = patchYaml(ORIGINAL, body);
expect(parse(patched)).toEqual(body);
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* YAML source-preserving patch helper.
*
* Problem: `yaml.stringify(obj)` completely re-serializes a document, destroying
* the source formatting (block literal vs folded, inline vs block arrays, blank
* lines, comments, key order). This changes `instruction: |` to `instruction: >`,
* which actually alters runtime behavior because folded style collapses newlines.
*
* Solution: parseDocument(originalText) -> deep-diff doc.toJS() vs newBody ->
* apply only differing paths via Document#setIn / Document#deleteIn. Untouched
* regions preserve their original source exactly.
*
* Newly-added subtrees go through Document#createNode with the existing
* lineWidth: 120 convention.
*/
import { parseDocument, stringify, type Document } from 'yaml';
import { logger } from '../logger.js';
const LINE_WIDTH = 120;
type Path = (string | number)[];
/**
* Recursively walk `prev` and `next` (both plain JS values), collecting paths
* where the two differ. For differing paths we emit either a set (with the new
* value) or a delete.
*
* Rules:
* - For objects (plain dicts) we compare by key. Keys removed in `next` become
* deletes; keys added in `next` become sets; common keys recurse.
* - For arrays of equal length we recurse element-by-element by index. This
* lets us do minimal edits inside one movement without re-serializing the
* entire `movements:` sequence (which would flatten its inline arrays and
* block-literal instructions).
* - For arrays of differing length we replace the whole array at that path.
* Element-wise alignment across inserts/deletes is ambiguous without a
* stable identity field, so we bail to wholesale replacement and accept
* the one-time formatting loss for the mutated sequence.
* - For primitives (string/number/bool/null) we compare via strict equality.
*/
export type DiffOp =
| { kind: 'set'; path: Path; value: unknown }
| { kind: 'delete'; path: Path };
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const k of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k])) return false;
}
return true;
}
return false;
}
export function diff(prev: unknown, next: unknown, path: Path = []): DiffOp[] {
if (deepEqual(prev, next)) return [];
// If the shapes differ (object<->array<->scalar) replace wholesale.
const prevIsObj = isPlainObject(prev);
const nextIsObj = isPlainObject(next);
const prevIsArr = Array.isArray(prev);
const nextIsArr = Array.isArray(next);
if (prevIsObj && nextIsObj) {
const ops: DiffOp[] = [];
const prevKeys = new Set(Object.keys(prev as Record<string, unknown>));
const nextKeys = new Set(Object.keys(next as Record<string, unknown>));
// deletions
for (const k of prevKeys) {
if (!nextKeys.has(k)) {
ops.push({ kind: 'delete', path: [...path, k] });
}
}
// additions + recursive diffs
for (const k of nextKeys) {
const nextVal = (next as Record<string, unknown>)[k];
if (!prevKeys.has(k)) {
ops.push({ kind: 'set', path: [...path, k], value: nextVal });
} else {
const prevVal = (prev as Record<string, unknown>)[k];
ops.push(...diff(prevVal, nextVal, [...path, k]));
}
}
return ops;
}
if (prevIsArr && nextIsArr) {
const prevArr = prev as unknown[];
const nextArr = next as unknown[];
if (prevArr.length !== nextArr.length) {
// Length change: bail to wholesale replacement. We can't reliably align
// items across insertions/deletions without a stable identity field.
return [{ kind: 'set', path, value: next }];
}
// Equal length: recurse element-wise so untouched items keep formatting.
const ops: DiffOp[] = [];
for (let i = 0; i < prevArr.length; i++) {
ops.push(...diff(prevArr[i], nextArr[i], [...path, i]));
}
return ops;
}
// Shape change or primitive mismatch: replace.
return [{ kind: 'set', path, value: next }];
}
/**
* Apply a list of diff ops to a Document in place. New subtrees are wrapped via
* doc.createNode so they follow our stringify options (lineWidth etc.).
*/
export function applyOps(doc: Document, ops: DiffOp[]): void {
for (const op of ops) {
if (op.kind === 'delete') {
doc.deleteIn(op.path);
} else {
// createNode respects schema + options; we don't pass extra options
// because per-field block style for untouched content comes from the
// original Document and we only call createNode for NEW subtrees.
const node = doc.createNode(op.value);
doc.setIn(op.path, node);
}
}
}
/**
* Re-serialize `body` onto the formatting of `originalText`, preserving block
* styles / inline arrays / blank lines / comments for untouched regions.
*
* If the original text fails to parse cleanly (errors array non-empty) we fall
* back to a plain stringify and log a warning.
*/
export function patchYaml(originalText: string, body: unknown): string {
let doc: Document;
try {
doc = parseDocument(originalText);
} catch (e) {
logger.warn(`[yaml-patch] parseDocument threw, falling back to stringify err=${e}`);
return stringify(body, { lineWidth: LINE_WIDTH });
}
if (doc.errors && doc.errors.length > 0) {
logger.warn(
`[yaml-patch] original document has parse errors, falling back to stringify count=${doc.errors.length}`,
);
return stringify(body, { lineWidth: LINE_WIDTH });
}
const prev = doc.toJS();
const ops = diff(prev, body);
applyOps(doc, ops);
return doc.toString({ lineWidth: LINE_WIDTH });
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, afterEach } from 'vitest';
import { loadConfig } from './config.js';
import { writeFileSync, unlinkSync, mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
describe('AuthConfig loading', () => {
const tmpConfig = join(process.cwd(), '_test_config_auth.yaml');
afterEach(() => {
try { unlinkSync(tmpConfig); } catch { /* ignore */ }
});
it('returns undefined auth when auth section is absent', () => {
writeFileSync(tmpConfig, 'language: ja\nprovider:\n model: test\n base_url: http://localhost\n');
const config = loadConfig(tmpConfig);
expect(config.auth).toBeUndefined();
});
it('parses auth section with snake_case keys', () => {
writeFileSync(tmpConfig, `
language: ja
provider:
model: test
base_url: http://localhost
auth:
session_secret: "test-secret"
session_max_age: 3600000
admin_emails:
- [email protected]
providers:
google:
client_id: "gid"
client_secret: "gsecret"
callback_url: "http://localhost/auth/google/callback"
`);
const config = loadConfig(tmpConfig);
expect(config.auth).toBeDefined();
expect(config.auth!.sessionSecret).toBe('test-secret');
expect(config.auth!.sessionMaxAge).toBe(3600000);
expect(config.auth!.adminEmails).toEqual(['[email protected]']);
expect(config.auth!.providers.google.clientId).toBe('gid');
});
it('parses auth.primary_provider from YAML', () => {
const tmp = mkdtempSync(join(tmpdir(), 'config-primary-'));
const path = join(tmp, 'config.yaml');
writeFileSync(path, `
auth:
session_secret: s
admin_emails: [[email protected]]
primary_provider: gitea
providers:
gitea:
client_id: cid
client_secret: cs
callback_url: http://cb
base_url: http://gitea.local
`.trim());
try {
const cfg = loadConfig(path);
expect(cfg.auth?.primaryProvider).toBe('gitea');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
+275
View File
@@ -0,0 +1,275 @@
// src/config-manager.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, writeFileSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { ConfigManager } from './config-manager.js';
describe('ConfigManager', () => {
let tempDir: string;
let configPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'config-manager-'));
configPath = join(tempDir, 'config.yaml');
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: gpu1',
' endpoint: http://gpu1.example/v1',
' roles: [auto, fast]',
'worktree_dir: /tmp/ws',
].join('\n'));
});
it('loads config from file', () => {
const cm = new ConfigManager(configPath);
const cfg = cm.getConfig();
expect(cfg.provider.model).toBe('test-model');
expect(cfg.provider.workers).toHaveLength(1);
});
it('returns masked config for API', () => {
writeFileSync(configPath, [
'provider:',
' model: test-model',
'tools:',
' x_auth_token: secret123',
' x_ct0: secret456',
' searxng_url: http://search.example',
].join('\n'));
const cm = new ConfigManager(configPath);
const { config: apiConfig } = cm.getConfigForApi();
expect((apiConfig as any).tools.xAuthToken).toBe('********');
expect((apiConfig as any).tools.xCt0).toBe('********');
expect((apiConfig as any).tools.searxngUrl).toBe('http://search.example');
});
it('returns etag based on file mtime', () => {
const cm = new ConfigManager(configPath);
const { etag } = cm.getConfigForApi();
expect(typeof etag).toBe('string');
expect(etag.length).toBeGreaterThan(0);
});
it('updates config and writes back to YAML', () => {
const cm = new ConfigManager(configPath);
// v2 contract: updates target llm.* instead of provider.*. The
// normalizer mirrors llm.workers back into provider.workers in-memory
// so legacy compat readers stay coherent.
const result = cm.updateConfig({
llm: {
workers: [{
id: 'gpu1',
connectionType: 'direct',
endpoint: 'http://gpu1.example/v1',
model: 'new-model',
roles: ['auto', 'fast'],
maxConcurrency: 1,
enabled: true,
}],
},
});
expect(result.ok).toBe(true);
const raw = readFileSync(configPath, 'utf-8');
expect(raw).toContain('new-model');
expect(raw).toContain('config_version: 2');
// v2 write contract: no legacy provider block on disk
expect(raw).not.toMatch(/^provider:/m);
// Downstream still reads provider.workers via the normalizer's reverse
// backfill — the model should reach both blocks.
expect(cm.getConfig().llm?.workers[0]?.model).toBe('new-model');
expect(cm.getConfig().provider.workers[0]?.model).toBe('new-model');
});
it('rejects invalid config (unparseable YAML file)', () => {
const cm = new ConfigManager(configPath);
// Corrupt the file, then try to reload — loadConfig will fall back to defaults
// but we test updateConfig with a value that causes writeFileSync to fail
// Since loadConfig doesn't validate model:'', we test with a truly broken scenario:
// overwrite the file with invalid content and try reloadFromFile
writeFileSync(configPath, ':::invalid yaml:::');
// loadConfig won't throw — it logs a warning and returns defaults.
// Instead, test that updateConfig rejects when the written config can't be re-loaded.
// Actually, loadConfig never throws. Let's just verify that writing garbage YAML
// and reloading still works (returns defaults).
cm.reloadFromFile();
// After reload from garbage YAML, config falls back to defaults
expect(cm.getConfig().provider.model).toBe('qwen3:32b'); // default model
});
it('emits config-changed on update', () => {
const cm = new ConfigManager(configPath);
let emitted = false;
cm.onConfigChanged(() => { emitted = true; });
cm.updateConfig({ provider: { model: 'changed' } });
expect(emitted).toBe(true);
});
it('preserves masked fields on update', () => {
writeFileSync(configPath, [
'provider:',
' model: test-model',
'tools:',
' x_auth_token: real-secret',
].join('\n'));
const cm = new ConfigManager(configPath);
cm.updateConfig({ tools: { xAuthToken: '********', searxngUrl: 'http://new.example' } });
const cfg = cm.getConfig();
expect(cfg.tools?.xAuthToken).toBe('real-secret');
expect(cfg.tools?.searxngUrl).toBe('http://new.example');
});
it('rejects update with stale etag', () => {
const cm = new ConfigManager(configPath);
const result = cm.updateConfig({ provider: { model: 'x' } }, 'stale-etag');
expect(result.ok).toBe(false);
expect((result as any).conflict).toBe(true);
});
it('reloads from file', () => {
const cm = new ConfigManager(configPath);
writeFileSync(configPath, [
'provider:',
' model: externally-changed',
].join('\n'));
cm.reloadFromFile();
expect(cm.getConfig().provider.model).toBe('externally-changed');
});
it('masks llm.workers[].apiKey in API response', () => {
// Source YAML may still be v1 (`provider.workers`) during the compat
// window. The normalizer populates llm.workers from provider.workers,
// and getConfigForApi masks the llm side (the v2 contract surface).
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: gpu1',
' endpoint: http://gpu1.example/v1',
' api_key: sk-real-secret-for-gpu1',
' - id: gpu2',
' endpoint: http://gpu2.example/v1',
' api_key: sk-real-secret-for-gpu2',
' - id: gpu3',
' endpoint: http://gpu3.example/v1',
].join('\n'));
const cm = new ConfigManager(configPath);
const { config: apiConfig } = cm.getConfigForApi();
// v2 contract: provider block stripped from API response
expect((apiConfig as any).provider).toBeUndefined();
const workers = (apiConfig as any).llm.workers as any[];
expect(workers[0].apiKey).toBe('********');
expect(workers[1].apiKey).toBe('********');
expect(workers[2].apiKey).toBeUndefined(); // not set in YAML
// non-sensitive fields stay visible
expect(workers[0].endpoint).toBe('http://gpu1.example/v1');
});
it('preserves llm.workers[].apiKey across v2 update by id match', () => {
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: gpu1',
' endpoint: http://gpu1.example/v1',
' api_key: original-secret-1',
' - id: gpu2',
' endpoint: http://gpu2.example/v1',
' api_key: original-secret-2',
].join('\n'));
const cm = new ConfigManager(configPath);
// UI receives masked, edits endpoint of gpu1 (in different order), then PUTs back
cm.updateConfig({
llm: {
workers: [
{ id: 'gpu2', connectionType: 'direct', endpoint: 'http://gpu2.example/v1', model: 'test-model', roles: ['auto'], maxConcurrency: 1, enabled: true, apiKey: '********' },
{ id: 'gpu1', connectionType: 'direct', endpoint: 'http://gpu1-new.example/v1', model: 'test-model', roles: ['auto'], maxConcurrency: 1, enabled: true, apiKey: '********' },
],
},
});
const cfg = cm.getConfig();
const byId = Object.fromEntries((cfg.llm?.workers ?? []).map((w: any) => [w.id, w]));
expect((byId.gpu1 as any).apiKey).toBe('original-secret-1');
expect((byId.gpu1 as any).endpoint).toBe('http://gpu1-new.example/v1');
expect((byId.gpu2 as any).apiKey).toBe('original-secret-2');
});
it('drops mask when worker had no prior apiKey (v2 path)', () => {
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: gpu1',
' endpoint: http://gpu1.example/v1',
].join('\n'));
const cm = new ConfigManager(configPath);
// Buggy UI sends MASKED for a worker that had no prior key — should
// not silently write "********" into the YAML.
cm.updateConfig({
llm: {
workers: [{
id: 'gpu1', connectionType: 'direct', endpoint: 'http://gpu1.example/v1',
model: 'test-model', roles: ['auto'], maxConcurrency: 1, enabled: true, apiKey: '********',
}],
},
});
const cfg = cm.getConfig();
expect((cfg.llm?.workers[0] as any).apiKey).toBeUndefined();
});
it('masks gateway.backends[].apiKey in API response', () => {
writeFileSync(configPath, [
'provider:',
' model: test-model',
'gateway:',
' backends:',
' - id: backend-a',
' endpoint: http://gpu-a.example/v1',
' model: qwen3:8b',
' max_slots: 4',
' api_key: backend-secret-a',
].join('\n'));
const cm = new ConfigManager(configPath);
const { config: apiConfig } = cm.getConfigForApi();
const backends = (apiConfig as any).gateway.backends as any[];
expect(backends[0].apiKey).toBe('********');
expect(backends[0].endpoint).toBe('http://gpu-a.example/v1');
expect(backends[0].maxSlots).toBe(4);
});
it('preserves gateway.backends[].apiKey across update by id', () => {
writeFileSync(configPath, [
'provider:',
' model: test-model',
'gateway:',
' backends:',
' - id: backend-a',
' endpoint: http://gpu-a.example/v1',
' model: qwen3:8b',
' max_slots: 4',
' api_key: original-backend-secret',
].join('\n'));
const cm = new ConfigManager(configPath);
cm.updateConfig({
gateway: {
backends: [
{
id: 'backend-a',
endpoint: 'http://gpu-a-new.example/v1',
model: 'qwen3:8b',
maxSlots: 8,
apiKey: '********',
},
],
},
});
const cfg = cm.getConfig() as any;
expect(cfg.gateway.backends[0].apiKey).toBe('original-backend-secret');
expect(cfg.gateway.backends[0].endpoint).toBe('http://gpu-a-new.example/v1');
expect(cfg.gateway.backends[0].maxSlots).toBe(8);
});
});
+312
View File
@@ -0,0 +1,312 @@
// src/config-manager.ts
import { EventEmitter } from 'events';
import { readFileSync, writeFileSync, statSync } from 'fs';
import { stringify } from 'yaml';
import { loadConfig, toSnakeKeys, type AppConfig } from './config.js';
import { createHash } from 'crypto';
import { logger } from './logger.js';
const MASKED = '********';
const SENSITIVE_PATHS = ['tools.xAuthToken', 'tools.xCt0'];
/**
* Keys stripped from `getConfigForApi` output. The v2 contract (design doc
* 2026-05-21) says GET emits ONLY the new shape: `config_version: 2` +
* `llm.*` + `storage.*` + `gateway.*` + every other top-level key that
* doesn't have a v2 supersession. The legacy `provider` block and the flat
* storage keys live on disk during the compat window but must not appear
* in API responses — UI / external callers should treat the v2 shape as
* the canonical contract.
*/
const V2_STRIPPED_TOP_LEVEL_KEYS = [
'provider',
'worktreeDir',
'customPiecesDir',
'userFolderRoot',
] as const;
/**
* Keys inside `tools.*` that were promoted into `storage.*` in v2. Stripped
* from GET output so the UI doesn't see them under both blocks.
*/
const V2_STRIPPED_TOOLS_KEYS = ['taskUploadMaxSizeMb', 'trashRetentionDays'] as const;
interface UpdateSuccess { ok: true }
interface UpdateFailure { ok: false; errors?: unknown; conflict?: boolean; message?: string }
type UpdateResult = UpdateSuccess | UpdateFailure;
export class ConfigManager {
private currentConfig: AppConfig;
private configPath: string;
private emitter = new EventEmitter();
constructor(configPath: string) {
this.configPath = configPath;
this.currentConfig = loadConfig(configPath);
}
getConfig(): AppConfig {
return this.currentConfig;
}
getConfigForApi(): { config: Record<string, unknown>; etag: string; overriddenByEnv: Record<string, boolean> } {
const config = JSON.parse(JSON.stringify(this.currentConfig)) as Record<string, unknown>;
for (const path of SENSITIVE_PATHS) {
const parts = path.split('.');
let obj: any = config;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj?.[parts[i]];
if (!obj) break;
}
if (obj && parts[parts.length - 1] in obj) {
obj[parts[parts.length - 1]] = MASKED;
}
}
// Mask all API keys in knowledgeNamespaces
const namespaces = (config as any).tools?.knowledgeNamespaces;
if (namespaces && typeof namespaces === 'object') {
for (const ns of Object.values(namespaces)) {
if (ns && typeof ns === 'object' && 'apiKey' in ns) {
(ns as any).apiKey = MASKED;
}
}
}
// Mask api keys in llm.workers[] and gateway.backends[]. Both
// arrays carry per-endpoint bearer tokens (worker apiKey is used for
// AAO Gateway / LiteLLM bearer auth, backend apiKey is forwarded to
// the upstream LLM server). The mask preserves array order and
// indices so the UI can keep editing other fields without losing the
// row's identity.
const llmWorkers = (config as any).llm?.workers;
if (Array.isArray(llmWorkers)) {
for (const w of llmWorkers) {
if (w && typeof w === 'object' && typeof (w as any).apiKey === 'string' && (w as any).apiKey !== '') {
(w as any).apiKey = MASKED;
}
}
}
const gwBackends = (config as any).gateway?.backends;
if (Array.isArray(gwBackends)) {
for (const b of gwBackends) {
if (b && typeof b === 'object' && typeof (b as any).apiKey === 'string' && (b as any).apiKey !== '') {
(b as any).apiKey = MASKED;
}
}
}
// v2 contract: stamp config_version=2 + strip the legacy keys that v2
// supersedes. The normalizer keeps the legacy `provider` block alive
// for in-process downstream readers (worker.ts etc.), but the API
// surface MUST present a single source of truth so the UI / external
// callers can rely on the v2 layout exclusively.
(config as any).configVersion = 2;
for (const key of V2_STRIPPED_TOP_LEVEL_KEYS) {
delete (config as any)[key];
}
if ((config as any).tools && typeof (config as any).tools === 'object') {
for (const key of V2_STRIPPED_TOOLS_KEYS) {
delete (config as any).tools[key];
}
}
// Flag fields overridden by environment variables. Paths reflect the
// v2 shape so the UI's overriddenByEnv lookup matches its bind paths.
const overriddenByEnv: Record<string, boolean> = {};
if (process.env['OLLAMA_BASE_URL']) overriddenByEnv['llm.workers[0].endpoint'] = true;
if (process.env['OLLAMA_MODEL']) overriddenByEnv['llm.workers[0].model'] = true;
if (process.env['WORKTREE_DIR']) overriddenByEnv['storage.worktreeDir'] = true;
if (process.env['CONCURRENCY']) overriddenByEnv['concurrency'] = true;
if (process.env['DB_PATH']) overriddenByEnv['dbPath'] = true;
return { config, etag: this.computeEtag(), overriddenByEnv };
}
updateConfig(partial: Record<string, unknown>, etag?: string): UpdateResult {
const prevCustomPiecesDir = this.currentConfig.customPiecesDir;
if (etag && etag !== this.computeEtag()) {
return { ok: false, conflict: true, message: 'Config was modified externally' };
}
// Preserve masked sensitive fields
const merged = this.mergeWithMaskPreservation(partial);
// v2 write contract: stamp config_version=2 and strip the legacy
// top-level keys that v2 supersedes. The legacy `provider` block was
// already mirrored into `llm.*` by the normalizer (Step 1); writing
// both blocks back would create a drift hazard where on-disk
// `provider.workers` and `llm.workers` go out of sync the next time
// someone hand-edits the YAML. v2 emits ONLY the v2 shape — the
// normalizer rebuilds the in-process `provider` mirror on reload.
(merged as any).configVersion = 2;
for (const key of V2_STRIPPED_TOP_LEVEL_KEYS) {
delete (merged as any)[key];
}
if ((merged as any).tools && typeof (merged as any).tools === 'object') {
for (const key of V2_STRIPPED_TOOLS_KEYS) {
delete (merged as any).tools[key];
}
}
// Convert to snake_case and generate YAML string
const snakeConfig = toSnakeKeys(merged) as Record<string, unknown>;
const yamlStr = stringify(snakeConfig, { lineWidth: 120 });
// Validate BEFORE writing: backup, write, validate, rollback on failure
const backupContent = readFileSync(this.configPath, 'utf-8');
try {
writeFileSync(this.configPath, yamlStr, 'utf-8');
logger.info(`[config-manager] config written to ${this.configPath}`);
this.currentConfig = loadConfig(this.configPath);
} catch (e) {
// Restore original file on validation failure
writeFileSync(this.configPath, backupContent, 'utf-8');
logger.warn(`[config-manager] config update failed, reverted: ${e}`);
return { ok: false, errors: e, message: 'Invalid config — changes reverted' };
}
if (prevCustomPiecesDir !== this.currentConfig.customPiecesDir) {
logger.info(`[config-manager] customPiecesDir changed old=${prevCustomPiecesDir ?? 'none'} new=${this.currentConfig.customPiecesDir ?? 'none'}`);
}
this.emitter.emit('config-changed', this.currentConfig);
return { ok: true };
}
reloadFromFile(): AppConfig {
logger.info(`[config-manager] reloading config from ${this.configPath}`);
this.currentConfig = loadConfig(this.configPath);
this.emitter.emit('config-changed', this.currentConfig);
return this.currentConfig;
}
onConfigChanged(callback: (config: AppConfig) => void): void {
this.emitter.on('config-changed', callback);
}
private computeEtag(): string {
try {
const stat = statSync(this.configPath);
return createHash('md5').update(`${stat.mtimeMs}-${stat.size}`).digest('hex').slice(0, 16);
} catch {
return 'unknown';
}
}
private mergeWithMaskPreservation(partial: Record<string, unknown>): Record<string, unknown> {
const current = JSON.parse(JSON.stringify(this.currentConfig));
const merged = deepMergeConfig(current, partial);
// Restore masked values
for (const path of SENSITIVE_PATHS) {
const parts = path.split('.');
let mergedObj: any = merged;
let currentObj: any = current;
for (let i = 0; i < parts.length - 1; i++) {
mergedObj = mergedObj?.[parts[i]];
currentObj = currentObj?.[parts[i]];
if (!mergedObj || !currentObj) break;
}
const lastKey = parts[parts.length - 1];
if (mergedObj && mergedObj[lastKey] === MASKED && currentObj) {
mergedObj[lastKey] = currentObj[lastKey];
}
}
// Restore masked knowledgeNamespaces API keys
const mergedNs = (merged as any).tools?.knowledgeNamespaces;
const currentNs = current.tools?.knowledgeNamespaces;
if (mergedNs && currentNs) {
for (const [name, val] of Object.entries(mergedNs)) {
if ((val as any)?.apiKey === MASKED && currentNs[name]) {
(val as any).apiKey = currentNs[name].apiKey;
}
}
}
// Restore masked provider.workers[].apiKey by matching on worker id
// (preferred) or array index (fallback). Matching on id survives
// reordering, which is needed because the UI may sort rows before
// saving even if the operator didn't touch the api key.
//
// NOTE: The v2 API contract drops `provider.*` from PUT bodies, so this
// branch only runs when callers still send legacy v1-shaped payloads
// (Step 2 keeps mask-preservation here as a belt-and-suspenders measure
// — the bridge layer rejects them with 400 first).
const mergedWorkers = (merged as any).provider?.workers;
const currentWorkers = current.provider?.workers;
if (Array.isArray(mergedWorkers) && Array.isArray(currentWorkers)) {
mergedWorkers.forEach((mw: any, idx: number) => {
if (!mw || typeof mw !== 'object' || mw.apiKey !== MASKED) return;
const match = (mw.id ? currentWorkers.find((cw: any) => cw?.id === mw.id) : undefined)
?? currentWorkers[idx];
if (match && typeof match.apiKey === 'string') {
mw.apiKey = match.apiKey;
} else {
// existing key was undefined — drop the mask so it stays undefined
delete mw.apiKey;
}
});
}
// Same id-or-index strategy for the v2 `llm.workers[].apiKey`. Sources
// for the "current" key are checked in priority order: llm.workers (v2),
// then provider.workers (legacy mirror) — whichever has a matching id
// wins. This lets v2 PUTs preserve secrets even if the operator's prior
// YAML was authored in v1 shape.
const mergedLlmWorkers = (merged as any).llm?.workers;
const currentLlmWorkers = current.llm?.workers;
if (Array.isArray(mergedLlmWorkers)) {
mergedLlmWorkers.forEach((mw: any, idx: number) => {
if (!mw || typeof mw !== 'object' || mw.apiKey !== MASKED) return;
const fromLlm = Array.isArray(currentLlmWorkers)
? (mw.id ? currentLlmWorkers.find((cw: any) => cw?.id === mw.id) : undefined) ?? currentLlmWorkers[idx]
: undefined;
const fromProvider = Array.isArray(currentWorkers)
? (mw.id ? currentWorkers.find((cw: any) => cw?.id === mw.id) : undefined)
: undefined;
const matched = fromLlm ?? fromProvider;
if (matched && typeof (matched as any).apiKey === 'string') {
mw.apiKey = (matched as any).apiKey;
} else {
delete mw.apiKey;
}
});
}
// Restore masked gateway.backends[].apiKey using the same id-or-index
// strategy as workers above.
const mergedBackends = (merged as any).gateway?.backends;
const currentBackends = current.gateway?.backends;
if (Array.isArray(mergedBackends) && Array.isArray(currentBackends)) {
mergedBackends.forEach((mb: any, idx: number) => {
if (!mb || typeof mb !== 'object' || mb.apiKey !== MASKED) return;
const match = (mb.id ? currentBackends.find((cb: any) => cb?.id === mb.id) : undefined)
?? currentBackends[idx];
if (match && typeof match.apiKey === 'string') {
mb.apiKey = match.apiKey;
} else {
delete mb.apiKey;
}
});
}
return merged;
}
}
function deepMergeConfig(base: any, override: any): any {
if (override === null || override === undefined) return base;
if (typeof override !== 'object' || Array.isArray(override)) return override;
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
if (typeof value === 'object' && value !== null && !Array.isArray(value) && typeof result[key] === 'object') {
result[key] = deepMergeConfig(result[key], value);
} else {
result[key] = value;
}
}
return result;
}
+547
View File
@@ -0,0 +1,547 @@
/**
* Tests for the v1 → v2 config normalizer (Step 1 of the 2026-05-21
* Settings UI / Config Restructure design).
*
* Coverage matrix:
* - explicit v2 input → pass through (no warning, no migration)
* - explicit `config_version: 99` → fatal
* - v1 provider.workers[].proxy = true → connection_type aao_gateway
* - v1 provider.workers[].proxy = false/missing → connection_type direct
* - provider.model fills missing worker.model
* - both empty → empty model + warning (not fatal)
* - storage.* mirrors of worktree_dir / custom_pieces_dir / user_folder_root /
* tools.task_upload_max_size_mb / tools.trash_retention_days
* - `${VAR}` references preserved verbatim (not coerced into literals)
* - fixture-driven snapshots for the four documented production shapes
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { parse as parseYaml } from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import {
normalizeConfig,
UnsupportedConfigVersionError,
} from './config-normalize.js';
import { toSnakeKeys } from './config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURES_DIR = join(__dirname, '__fixtures__', 'config-migration');
/**
* Tiny helper: parse a YAML string and pre-camelCase keys the way loadConfig
* does. The normalizer expects camelCased keys (post-transformKeys).
*/
function loadYaml(yamlText: string): unknown {
return transformToCamel(parseYaml(yamlText));
}
function loadFixture(name: string): unknown {
return loadYaml(readFileSync(join(FIXTURES_DIR, name), 'utf-8'));
}
/** snake_case → camelCase recursive (mirror of config.ts transformKeys). */
function transformToCamel(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(transformToCamel);
if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()),
transformToCamel(v),
]),
);
}
return obj;
}
describe('normalizeConfig — version handling', () => {
it('v2 input passes through with config_version=2', () => {
const out = normalizeConfig({
configVersion: 2,
llm: {
workers: [
{
id: 'w1',
connectionType: 'direct',
endpoint: 'http://localhost:11434/v1',
model: 'qwen3:32b',
roles: ['auto'],
maxConcurrency: 1,
enabled: true,
},
],
},
});
expect(out.configVersion).toBe(2);
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]).toMatchObject({
id: 'w1',
connectionType: 'direct',
model: 'qwen3:32b',
});
});
it('missing config_version treated as v1', () => {
const out = normalizeConfig({
provider: {
baseUrl: 'http://localhost:11434/v1',
model: 'qwen3:32b',
workers: [],
},
});
expect(out.configVersion).toBe(2);
// v1 → defaulted single worker from baseUrl
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]?.endpoint).toBe('http://localhost:11434/v1');
});
it('config_version: 99 throws UnsupportedConfigVersionError', () => {
expect(() => normalizeConfig({ configVersion: 99 })).toThrowError(UnsupportedConfigVersionError);
});
it('config_version: "2" (string) throws (typo guard)', () => {
expect(() => normalizeConfig({ configVersion: '2' })).toThrowError(UnsupportedConfigVersionError);
});
it('config_version: 0 throws', () => {
expect(() => normalizeConfig({ configVersion: 0 })).toThrowError(UnsupportedConfigVersionError);
});
it('null / non-object input is treated as empty config', () => {
const out = normalizeConfig(null);
expect(out.configVersion).toBe(2);
expect(out.llm?.workers).toEqual([]);
});
});
describe('normalizeConfig — v1 provider → v2 llm', () => {
it('proxy: true → connection_type: aao_gateway', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
workers: [
{
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
proxy: true,
apiKey: 'tok-abc',
},
],
},
});
expect(out.llm?.workers[0]).toMatchObject({
id: 'team-pool',
connectionType: 'aao_gateway',
endpoint: 'http://litellm:4000/v1',
apiKey: 'tok-abc',
});
});
it('proxy: false → connection_type: direct', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
workers: [
{ id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: false },
],
},
});
expect(out.llm?.workers[0]?.connectionType).toBe('direct');
});
it('proxy omitted → connection_type: direct', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }],
},
});
expect(out.llm?.workers[0]?.connectionType).toBe('direct');
});
it('worker.model empty + provider.model set → worker.model inherits', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:32b',
workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }],
},
});
expect(out.llm?.workers[0]?.model).toBe('qwen3:32b');
});
it('worker.model explicit overrides provider.model', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:32b',
workers: [
{ id: 'gpu1', endpoint: 'http://gpu1/v1', model: 'qwen3:14b' },
],
},
});
expect(out.llm?.workers[0]?.model).toBe('qwen3:14b');
});
it('worker.model empty + provider.model empty → empty string, no throw', () => {
expect(() =>
normalizeConfig({
provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] },
}),
).not.toThrow();
const out = normalizeConfig({
provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] },
});
expect(out.llm?.workers[0]?.model).toBe('');
});
it('base_url with no workers → single default worker', () => {
const out = normalizeConfig({
provider: {
baseUrl: 'http://localhost:11434/v1',
model: 'qwen3:32b',
workers: [],
},
});
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]).toMatchObject({
id: 'default',
endpoint: 'http://localhost:11434/v1',
model: 'qwen3:32b',
connectionType: 'direct',
});
});
it('provider.timeout_minutes / retry / metrics → llm.*', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
baseUrl: 'http://localhost:11434/v1',
timeoutMinutes: 20,
retry: {
maxAttempts: 5,
backoffMs: [1000, 2000],
retryableStatus: [429, 503],
},
metrics: { enabled: true, prefix: 'aao_worker' },
},
});
expect(out.llm?.timeoutMinutes).toBe(20);
expect(out.llm?.retry).toEqual({
maxAttempts: 5,
backoffMs: [1000, 2000],
retryableStatus: [429, 503],
});
expect(out.llm?.metrics).toEqual({ enabled: true, prefix: 'aao_worker' });
});
it('profiles: [...] → roles: [...] on v2 worker', () => {
const out = normalizeConfig({
provider: {
model: 'm',
workers: [
// Use camelCased keys (post-transformKeys)
{ id: 'g', endpoint: 'http://g/v1', profiles: ['fast'] },
],
},
});
expect(out.llm?.workers[0]?.roles).toEqual(['fast']);
});
it('roles defaults to [auto, fast, quality] when neither roles nor profiles set', () => {
const out = normalizeConfig({
provider: {
model: 'm',
workers: [{ id: 'g', endpoint: 'http://g/v1' }],
},
});
expect(out.llm?.workers[0]?.roles).toEqual(['auto', 'fast', 'quality']);
});
});
describe('normalizeConfig — storage migration', () => {
it('top-level worktree_dir → storage.worktreeDir', () => {
const out = normalizeConfig({
worktreeDir: '/data/workspaces',
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage?.worktreeDir).toBe('/data/workspaces');
});
it('tools.task_upload_max_size_mb → storage.taskUploadMaxSizeMb', () => {
const out = normalizeConfig({
tools: { taskUploadMaxSizeMb: 100 },
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage?.taskUploadMaxSizeMb).toBe(100);
});
it('tools.trash_retention_days → storage.trashRetentionDays', () => {
const out = normalizeConfig({
tools: { trashRetentionDays: 7 },
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage?.trashRetentionDays).toBe(7);
});
it('all storage keys round-trip together', () => {
const out = normalizeConfig({
worktreeDir: '/w',
customPiecesDir: './cp',
userFolderRoot: './users',
tools: { taskUploadMaxSizeMb: 50, trashRetentionDays: 30 },
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage).toEqual({
worktreeDir: '/w',
customPiecesDir: './cp',
userFolderRoot: './users',
taskUploadMaxSizeMb: 50,
trashRetentionDays: 30,
});
});
it('existing storage.* wins over legacy flat keys', () => {
const out = normalizeConfig({
worktreeDir: '/old',
storage: { worktreeDir: '/new' },
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage?.worktreeDir).toBe('/new');
});
});
describe('normalizeConfig — env reference preservation', () => {
it('${VAR} in worker api_key is preserved verbatim', () => {
const out = normalizeConfig({
provider: {
model: 'm',
workers: [
{
id: 'team',
endpoint: 'http://t/v1',
proxy: true,
apiKey: '${TEAM_KEY}',
},
],
},
});
expect(out.llm?.workers[0]?.apiKey).toBe('${TEAM_KEY}');
});
it('${VAR} in provider.metrics.bearer_token is preserved', () => {
const out = normalizeConfig({
provider: {
model: 'm',
baseUrl: 'http://x/v1',
metrics: { bearerToken: '${BEARER}' },
},
});
expect(out.llm?.metrics?.bearerToken).toBe('${BEARER}');
});
it('env: prefix is preserved (legacy syntax)', () => {
const out = normalizeConfig({
provider: {
model: 'm',
baseUrl: 'http://x/v1',
metrics: { bearerToken: 'env:LEGACY_BEARER' },
},
});
expect(out.llm?.metrics?.bearerToken).toBe('env:LEGACY_BEARER');
});
});
describe('normalizeConfig — fixtures', () => {
it('v1-single-ollama.yaml normalizes to expected v2 shape', () => {
const raw = loadFixture('v1-single-ollama.yaml');
const out = normalizeConfig(raw);
expect(out.configVersion).toBe(2);
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]).toMatchObject({
id: 'default',
connectionType: 'direct',
endpoint: 'http://localhost:11434/v1',
model: 'qwen3:32b',
enabled: true,
maxConcurrency: 1,
});
expect(out.llm?.timeoutMinutes).toBe(10);
expect(out.llm?.retry).toEqual({
maxAttempts: 3,
backoffMs: [2000, 5000, 15000],
retryableStatus: [429, 500, 502, 503, 504],
});
expect(out.storage).toEqual({
worktreeDir: '/var/lib/agent-orchestrator/workspaces',
customPiecesDir: './custom-pieces',
userFolderRoot: './data/users',
taskUploadMaxSizeMb: 50,
trashRetentionDays: 30,
});
});
it('v1-multi-worker-with-proxy.yaml maps proxy:true to aao_gateway + keeps ${} refs', () => {
const raw = loadFixture('v1-multi-worker-with-proxy.yaml');
const out = normalizeConfig(raw);
expect(out.configVersion).toBe(2);
expect(out.llm?.timeoutMinutes).toBe(15);
expect(out.llm?.workers).toHaveLength(4);
const byId = new Map(out.llm!.workers.map(w => [w.id, w]));
expect(byId.get('gpu1')).toMatchObject({
connectionType: 'direct',
model: 'qwen3:32b', // inherits from provider.model
roles: ['auto', 'fast'],
maxConcurrency: 2,
});
expect(byId.get('gpu2')).toMatchObject({
connectionType: 'direct',
model: 'qwen3:14b', // worker override
vlm: true,
});
expect(byId.get('team-pool')).toMatchObject({
connectionType: 'aao_gateway',
apiKey: '${TEAM_A_LITELLM_KEY}', // env ref preserved literally
model: 'qwen3:8b',
maxConcurrency: 4,
roles: ['quality'],
});
expect(byId.get('gpu-reflection')).toMatchObject({
connectionType: 'direct',
roles: ['reflection'],
});
});
it('v1-gateway-server-with-keys.yaml preserves gateway.* untouched + builds llm from provider', () => {
const raw = loadFixture('v1-gateway-server-with-keys.yaml');
const out = normalizeConfig(raw);
expect(out.configVersion).toBe(2);
// provider → llm path
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]).toMatchObject({
id: 'default',
endpoint: 'http://localhost:11434/v1',
model: 'qwen3:8b',
connectionType: 'direct',
});
expect(out.llm?.metrics?.bearerToken).toBe('${AAO_WORKER_METRICS_BEARER_TOKEN}');
// gateway.* should pass through untouched.
const gateway = (out as unknown as { gateway?: Record<string, unknown> }).gateway;
expect(gateway).toBeDefined();
expect(gateway).toMatchObject({
enabled: true,
listenPort: 4000,
});
expect(Array.isArray(gateway?.backends)).toBe(true);
expect((gateway?.backends as unknown[])).toHaveLength(2);
const vkeys = gateway?.virtualKeys as Array<Record<string, unknown>>;
expect(vkeys[0]?.key).toBe('${TEAM_A_GATEWAY_KEY}'); // env ref kept
});
it('v1-mcp-and-ssh.yaml mirrors user_folder_root into storage + preserves mcp/ssh', () => {
const raw = loadFixture('v1-mcp-and-ssh.yaml');
const out = normalizeConfig(raw);
expect(out.configVersion).toBe(2);
expect(out.storage?.userFolderRoot).toBe('/opt/aao/data/users');
expect(out.storage?.taskUploadMaxSizeMb).toBe(100);
// mcp + ssh blocks should pass through unchanged
expect(out.mcp).toMatchObject({ enabled: true, callTimeoutSeconds: 30 });
expect(out.ssh).toMatchObject({
enabled: true,
masterKeyPath: './data/secrets/ssh-master.key',
});
// ${VAR} inside tools.knowledge_namespaces stays literal
const tools = (out as unknown as { tools?: { knowledgeNamespaces?: Record<string, { apiKey: string }> } }).tools;
expect(tools?.knowledgeNamespaces?.eng?.apiKey).toBe('${DKS_ENG_KEY}');
expect(tools?.knowledgeNamespaces?.ops?.apiKey).toBe('${DKS_OPS_KEY}');
});
});
describe('normalizeConfig — backwards compat with loadConfig', () => {
it('toSnakeKeys round-trip on normalizer output stays consumable by YAML stringifier', () => {
// Sanity guard: the v2 blocks survive snake_case conversion (used by
// /api/config write path in later steps).
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
workers: [
{ id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: true, apiKey: '${K}' },
],
},
});
const snake = toSnakeKeys({
config_version: out.configVersion,
llm: out.llm,
storage: out.storage,
}) as Record<string, unknown>;
expect((snake.llm as Record<string, unknown>).workers).toBeDefined();
const workers = (snake.llm as Record<string, unknown>).workers as Array<Record<string, unknown>>;
expect(workers[0]).toMatchObject({
id: 'gpu1',
connection_type: 'aao_gateway',
api_key: '${K}',
});
});
it('mirrors storage.* back into top-level flat keys for legacy readers (2026-05-21 hotfix)', () => {
// Production aao broke when v2-only config.yaml dropped top-level
// `worktree_dir`. worker-bootstrap.ts:153/172 reads
// `config.worktreeDir` and got the default `/var/lib/...` path,
// which isn't writable on the typical deploy → EACCES on mkdir.
// The normalizer now mirrors storage.* into the legacy top-level
// keys so the compat-window readers keep working.
const out = normalizeConfig({
configVersion: 2,
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] },
storage: {
worktreeDir: '/home/user/data/agent-workspaces',
customPiecesDir: '/home/user/data/pieces',
userFolderRoot: '/home/user/data/users',
taskUploadMaxSizeMb: 100,
trashRetentionDays: 45,
},
});
expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces');
expect(out.customPiecesDir).toBe('/home/user/data/pieces');
expect(out.userFolderRoot).toBe('/home/user/data/users');
expect(out.tools?.taskUploadMaxSizeMb).toBe(100);
expect(out.tools?.trashRetentionDays).toBe(45);
});
it('storage.* explicit value wins over top-level value (#369 precedence fix)', () => {
// After hotfix #369: when the source v2 input has an explicit
// `storage.worktreeDir`, that value is authoritative and overrides
// any pre-existing top-level value — because the top-level is
// almost always the legacy default (`/var/lib/...`) merged in by
// loadConfig before the normalizer runs. The env override (#369)
// is re-applied AFTER normalizeConfig in loadConfig so a runtime
// WORKTREE_DIR still wins; that contract isn't tested here.
const out = normalizeConfig({
configVersion: 2,
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] },
worktreeDir: '/var/lib/maestro/workspaces', // simulates merged-in default
storage: { worktreeDir: '/home/user/data/agent-workspaces' },
});
expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces');
});
it('v1 top-level worktreeDir survives when no storage block was authored', () => {
// Legacy path: a v1 file with `worktree_dir` but no `storage` block
// synthesizes storage.worktreeDir from the top-level. The mirror is
// a no-op (top-level was already set) and the user's value is
// preserved on both sides.
const out = normalizeConfig({
// configVersion omitted → v1 path
provider: { workers: [{ id: 'w1', endpoint: 'http://x/v1', model: 'm' }] },
worktreeDir: '/home/op/explicit-v1-value',
});
expect(out.worktreeDir).toBe('/home/op/explicit-v1-value');
expect(out.storage?.worktreeDir).toBe('/home/op/explicit-v1-value');
});
});
+505
View File
@@ -0,0 +1,505 @@
/**
* Config normalization layer (Step 1 of the 2026-05-21 Settings UI / Config
* Restructure design).
*
* Inputs come from two shapes:
*
* - **v2** (`config_version: 2`): use as-is. No migration, no warnings.
* - **v1** (`config_version` missing or `1`): migrate the legacy
* `provider.*` + flat storage keys into the new `llm.*` and `storage.*`
* blocks. Log a single warning pointing operators at
* `scripts/migrate-config.sh`.
*
* Any other `config_version` value is a typo guard — we throw at load time
* rather than silently treat it as v1. (Future v3 etc. will need to extend
* this allowlist when they ship.)
*
* Invariants:
* - The normalizer NEVER touches secret string syntax. `${VAR}` /
* `env:VAR` references stay verbatim so YAML round-trips lossless.
* - The normalizer NEVER hard-fails on missing models. A v1 file with
* `provider.workers[*].model` blank AND `provider.model` blank produces
* `llm.workers[*].model: ''` + a logger.warn. The startup fatal would
* take down every single-Ollama deployment that hadn't yet migrated.
* - The legacy `provider` block stays populated even on v2 input. This
* keeps downstream readers (worker.ts, openai-compat, etc.) working
* without touching them in this PR.
*
* UI / API code is OUT OF SCOPE for this step. We only add internal types
* + a function that's wired into `loadConfig`.
*/
import { logger } from './logger.js';
// Type-only imports break the otherwise circular dependency between
// config.ts ↔ config-normalize.ts (config.ts calls normalizeConfig at
// load time; the normalizer needs the shape definitions). Keeping these
// `import type` ensures the bundled output has no runtime cycle.
import type {
AppConfig,
LlmConfig,
LlmWorkerDef,
ProviderConfig,
ProviderRetryConfig,
StorageConfig,
ToolsConfig,
WorkerDef,
} from './config.js';
/** Schema versions this normalizer understands. */
const SUPPORTED_VERSIONS = new Set<number>([1, 2]);
/** Default "model is unset" sentinel — empty string is intentional, see header. */
const EMPTY_MODEL = '';
/**
* Thrown when `config_version` is set to a value the loader doesn't
* understand. Fatal by design: silently treating "version 99" as v1 would
* mask config drift between AAO releases.
*/
export class UnsupportedConfigVersionError extends Error {
constructor(public readonly version: unknown) {
super(
`config_version=${JSON.stringify(version)} is not supported by this AAO ` +
`release (known versions: ${[...SUPPORTED_VERSIONS].sort().join(', ')}). ` +
`Update config.yaml or run scripts/migrate-config.sh.`,
);
this.name = 'UnsupportedConfigVersionError';
}
}
/**
* Normalize a freshly-parsed (camelCased) config object. Returns an
* AppConfig with the v2 blocks (`llm`, `storage`, `configVersion: 2`) always
* populated. Legacy `provider` / `worktreeDir` / etc. stay populated for the
* compat window.
*
* The caller is responsible for the YAML parse + `transformKeys` snake→camel
* step. This function is pure (no fs, no env), which makes the migration
* tests fixture-driven.
*/
export function normalizeConfig(raw: unknown): AppConfig {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
// Treat null / non-object input as "empty v1 file" — downstream
// defaults in loadConfig will fill in everything.
raw = {};
}
const input = raw as Record<string, unknown>;
// 1. Resolve schema version (default = 1, the legacy shape).
const version = resolveConfigVersion(input.configVersion);
// 2. For v1 inputs we mutate a shallow copy so the caller's object isn't
// surprised. Deep clones are intentionally avoided — we just rebind
// the top-level keys we touch.
const out: Record<string, unknown> = { ...input };
if (version === 1) {
migrateV1InPlace(out);
logger.warn(
'[config] migrated from v1; run scripts/migrate-config.sh to persist the new layout',
);
} else {
// v2: copy any optional flat keys into the v2 blocks if the user
// wrote a mix (e.g. set `llm` but kept legacy `worktree_dir`).
backfillV2Blocks(out);
}
out.configVersion = 2;
return out as unknown as AppConfig;
}
function resolveConfigVersion(raw: unknown): 1 | 2 {
if (raw === undefined || raw === null) return 1;
if (typeof raw !== 'number' || !Number.isInteger(raw)) {
throw new UnsupportedConfigVersionError(raw);
}
if (!SUPPORTED_VERSIONS.has(raw)) {
throw new UnsupportedConfigVersionError(raw);
}
return raw as 1 | 2;
}
/**
* v1 → v2 migration. Mutates `out` in place:
* - `provider.*` is mirrored into `llm.*` (unless `llm` already present)
* - `worktree_dir` / `custom_pieces_dir` / `user_folder_root` /
* `tools.task_upload_max_size_mb` / `tools.trash_retention_days`
* are mirrored into `storage.*`
* - legacy keys are PRESERVED so downstream readers keep working.
*/
function migrateV1InPlace(out: Record<string, unknown>): void {
const provider = (out.provider ?? {}) as ProviderConfig;
const existingLlm = out.llm as Partial<LlmConfig> | undefined;
const llm = existingLlm
? coerceLlm(existingLlm, provider)
: llmFromProvider(provider);
out.llm = llm;
out.storage = buildStorage(out);
}
/**
* v2 backfill for hand-edited mixed configs. If `llm.workers` is missing but
* `provider.workers` exists, mirror them in. Same for storage flat keys.
*
* Also performs the *reverse* backfill: when a v2-only file is loaded (no
* `provider.workers`, only `llm.workers`), we mirror `llm.workers` back into
* the legacy `provider.workers` block so downstream readers (worker.ts /
* openai-compat / WorkerManager — all still reading `config.provider.*` in
* the compat window) keep working without per-call rewiring. Without this,
* any deployment that goes through `PUT /api/config` (which emits v2-only
* YAML) would silently fall back to the default single-worker built by
* loadConfig, losing the user's actual worker list.
*/
function backfillV2Blocks(out: Record<string, unknown>): void {
const llm = (out.llm ?? {}) as Partial<LlmConfig>;
const provider = (out.provider ?? {}) as ProviderConfig;
if (!Array.isArray(llm.workers) || llm.workers.length === 0) {
if (Array.isArray(provider.workers) && provider.workers.length > 0) {
llm.workers = provider.workers.map(w =>
workerFromProvider(w, provider.model),
);
} else if (provider.baseUrl) {
llm.workers = [
defaultWorker(provider.baseUrl, provider.model ?? EMPTY_MODEL),
];
} else {
llm.workers = [];
}
} else {
// Normalize each entry (fills missing roles / concurrency / etc.).
// Cast through unknown so partial v2 worker shapes from YAML are
// accepted; normalizeLlmWorker re-checks all fields.
llm.workers = (llm.workers as unknown as Array<Partial<LlmWorkerDef> & Record<string, unknown>>)
.map(w => normalizeLlmWorker(w));
}
llm.timeoutMinutes = llm.timeoutMinutes ?? provider.timeoutMinutes;
llm.retry = llm.retry ?? provider.retry;
llm.metrics = llm.metrics ?? provider.metrics;
out.llm = llm as LlmConfig;
// Reverse backfill: ensure provider.workers mirrors llm.workers so the
// legacy compat readers see the same set the v2 block declares. This is
// a no-op when both blocks were authored consistently (e.g. v1 file with
// matching shapes). It DOES NOT touch downstream code — only the in-memory
// AppConfig shape that downstream code reads.
syncProviderFromLlm(out, llm as LlmConfig);
out.storage = buildStorage(out);
}
/**
* Mirror `llm.workers` back into `provider.workers` for v2-only files. We
* only overwrite when the source file effectively had no provider.workers
* (either undefined, empty, or a single auto-generated default worker that
* loadConfig synthesized from the default baseUrl).
*
* Field mapping is the inverse of `workerFromProvider`:
* connectionType: 'aao_gateway' → proxy: true
* connectionType: 'direct' → proxy: false (omitted)
*
* The legacy fields (`profiles`, `vlm`, `apiKey`, `proxyType`) are mirrored
* as-is to satisfy WorkerDef's optional shape.
*/
function syncProviderFromLlm(out: Record<string, unknown>, llm: LlmConfig): void {
if (!Array.isArray(llm.workers) || llm.workers.length === 0) return;
const existingProvider = (out.provider ?? {}) as ProviderConfig;
const existingWorkers = Array.isArray(existingProvider.workers) ? existingProvider.workers : [];
// Heuristic: if the existing provider.workers already match llm.workers by
// id count, leave them alone — the v1 path already populated them.
const llmIds = new Set(llm.workers.map(w => w.id));
const allExistingMatch = existingWorkers.length > 0
&& existingWorkers.every(w => typeof w.id === 'string' && llmIds.has(w.id))
&& existingWorkers.length === llm.workers.length;
if (allExistingMatch) return;
const mirroredWorkers: WorkerDef[] = llm.workers.map(w => {
const def: WorkerDef = {
id: w.id,
endpoint: w.endpoint,
enabled: w.enabled,
maxConcurrency: w.maxConcurrency,
roles: w.roles,
};
if (w.model !== undefined && w.model !== '') def.model = w.model;
if (w.apiKey !== undefined) def.apiKey = w.apiKey;
if (w.vlm !== undefined) def.vlm = w.vlm;
if (w.healthcheckIntervalSeconds !== undefined) {
def.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
}
if (w.connectionType === 'aao_gateway') {
def.proxy = true;
def.proxyType = 'litellm';
}
return def;
});
out.provider = {
...existingProvider,
workers: mirroredWorkers,
timeoutMinutes: existingProvider.timeoutMinutes ?? llm.timeoutMinutes,
retry: existingProvider.retry ?? llm.retry,
metrics: existingProvider.metrics ?? llm.metrics,
} as ProviderConfig;
}
/**
* Build the `llm` block from a v1 `provider` block.
*/
function llmFromProvider(provider: ProviderConfig): LlmConfig {
const workers = (provider.workers ?? []).map(w =>
workerFromProvider(w, provider.model),
);
if (workers.length === 0 && provider.baseUrl) {
workers.push(
defaultWorker(provider.baseUrl, provider.model ?? EMPTY_MODEL),
);
}
return {
timeoutMinutes: provider.timeoutMinutes,
retry: provider.retry ? cloneRetry(provider.retry) : undefined,
metrics: provider.metrics ? { ...provider.metrics } : undefined,
workers,
};
}
/**
* Existing-llm path: trust user's `llm.workers` but normalize each entry,
* and fill in retry/metrics from provider if llm side is blank.
*/
function coerceLlm(llm: Partial<LlmConfig>, provider: ProviderConfig): LlmConfig {
const workers = Array.isArray(llm.workers)
? (llm.workers as unknown as Array<Partial<LlmWorkerDef> & Record<string, unknown>>)
.map(w => normalizeLlmWorker(w))
: [];
return {
timeoutMinutes: llm.timeoutMinutes ?? provider.timeoutMinutes,
retry: llm.retry ?? (provider.retry ? cloneRetry(provider.retry) : undefined),
metrics: llm.metrics ?? (provider.metrics ? { ...provider.metrics } : undefined),
workers,
};
}
function cloneRetry(r: ProviderRetryConfig): ProviderRetryConfig {
return {
maxAttempts: r.maxAttempts,
backoffMs: [...r.backoffMs],
retryableStatus: [...r.retryableStatus],
};
}
/**
* Map a v1 worker (with `proxy: boolean`) to a v2 worker (with
* `connection_type`). Fills in model from `provider.model` ONLY when the
* worker has no model of its own. If both are empty, the worker still ships
* with `model: ''` and we log a single warning per worker — see header for
* rationale.
*/
function workerFromProvider(w: WorkerDef, providerModel: string | undefined): LlmWorkerDef {
const connectionType: 'direct' | 'aao_gateway' =
w.proxy === true ? 'aao_gateway' : 'direct';
const ownModel = typeof w.model === 'string' && w.model.length > 0 ? w.model : undefined;
const fallback = typeof providerModel === 'string' && providerModel.length > 0 ? providerModel : undefined;
const model = ownModel ?? fallback ?? EMPTY_MODEL;
if (model === EMPTY_MODEL) {
logger.warn(
`[config] worker '${w.id ?? '?'}' has no model and provider.model is empty; ` +
`LLM calls will fail until model is set in Settings → LLM Workers`,
);
}
const roles = pickRoles(w);
const worker: LlmWorkerDef = {
id: w.id,
connectionType,
endpoint: w.endpoint,
model,
roles,
maxConcurrency: Math.max(1, w.maxConcurrency ?? 1),
enabled: w.enabled !== false,
};
if (w.apiKey !== undefined) worker.apiKey = w.apiKey;
if (w.vlm !== undefined) worker.vlm = w.vlm;
if (w.healthcheckIntervalSeconds !== undefined) {
worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
}
return worker;
}
/**
* Normalize a hand-written v2 worker entry: fill missing roles/concurrency
* defaults, validate connectionType is recognized.
*/
function normalizeLlmWorker(w: Partial<LlmWorkerDef> & Record<string, unknown>): LlmWorkerDef {
const id = typeof w.id === 'string' ? w.id : '';
const endpoint = typeof w.endpoint === 'string' ? w.endpoint : '';
const rawType = w.connectionType;
const connectionType: 'direct' | 'aao_gateway' =
rawType === 'aao_gateway' ? 'aao_gateway' : 'direct';
const model = typeof w.model === 'string' ? w.model : EMPTY_MODEL;
if (model === EMPTY_MODEL) {
logger.warn(
`[config] llm.workers[${id || '?'}].model is empty; LLM calls will fail ` +
`until model is set in Settings → LLM Workers`,
);
}
// Tolerate v1-style `profiles` if a user pastes a v1 worker into the
// v2 `llm.workers` block by mistake — translate to roles.
const rolesFromV1 =
'profiles' in w && Array.isArray((w as { profiles?: unknown }).profiles)
? ((w as { profiles?: string[] }).profiles ?? [])
: undefined;
const rolesRaw = Array.isArray(w.roles) ? w.roles : rolesFromV1;
const roles =
rolesRaw && rolesRaw.length > 0
? Array.from(new Set(rolesRaw.filter((r): r is string => typeof r === 'string')))
: ['auto', 'fast', 'quality'];
const worker: LlmWorkerDef = {
id,
connectionType,
endpoint,
model,
roles,
maxConcurrency: Math.max(1, typeof w.maxConcurrency === 'number' ? w.maxConcurrency : 1),
enabled: w.enabled !== false,
};
if (typeof w.apiKey === 'string') worker.apiKey = w.apiKey;
if (typeof w.vlm === 'boolean') worker.vlm = w.vlm;
if (typeof w.healthcheckIntervalSeconds === 'number') {
worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
}
return worker;
}
function defaultWorker(endpoint: string, model: string): LlmWorkerDef {
if (model === EMPTY_MODEL) {
logger.warn(
`[config] default worker has no model; LLM calls will fail until model is set`,
);
}
return {
id: 'default',
connectionType: 'direct',
endpoint,
model,
roles: ['auto', 'fast', 'quality'],
maxConcurrency: 1,
enabled: true,
};
}
function pickRoles(w: WorkerDef): string[] {
if (Array.isArray(w.roles) && w.roles.length > 0) {
return Array.from(new Set(w.roles));
}
if (Array.isArray(w.profiles) && w.profiles.length > 0) {
return Array.from(new Set(w.profiles));
}
return ['auto', 'fast', 'quality'];
}
/**
* Pull storage-related keys from the (possibly mixed) raw object into a v2
* Storage block. Reads from `out.storage` first so a user that already
* authored v2 keeps their values; flat legacy keys only fill blanks.
*
* Side effect (REVERSE backfill): mirrors the resolved storage values
* back into the legacy flat top-level keys (`worktreeDir`, etc.) and
* back into `tools.*` for upload/trash. Without this, a v2-only file
* (no top-level `worktreeDir`) loads with `config.worktreeDir`
* undefined, and downstream callers (worker-bootstrap.ts:153/172 pass
* `config.worktreeDir` to Scheduler + workers; ConfigManager defaults
* fill in `/var/lib/maestro/workspaces` which is not
* writable on typical deploys) explode with EACCES on mkdir. The same
* pattern as `syncProviderFromLlm` — keep the legacy compat readers
* working while the downstream code gradually moves to `storage.*`.
* Discovered when production aao stopped processing jobs after the
* 2026-05-21 config restructure landed.
*/
function buildStorage(out: Record<string, unknown>): StorageConfig {
const existing = (out.storage ?? {}) as Partial<StorageConfig>;
const tools = (out.tools ?? {}) as Partial<ToolsConfig>;
const storage: StorageConfig = {};
if (existing.worktreeDir !== undefined) storage.worktreeDir = existing.worktreeDir;
else if (typeof out.worktreeDir === 'string') storage.worktreeDir = out.worktreeDir as string;
if (existing.customPiecesDir !== undefined) storage.customPiecesDir = existing.customPiecesDir;
else if (typeof out.customPiecesDir === 'string') storage.customPiecesDir = out.customPiecesDir as string;
if (existing.userFolderRoot !== undefined) storage.userFolderRoot = existing.userFolderRoot;
else if (typeof out.userFolderRoot === 'string') storage.userFolderRoot = out.userFolderRoot as string;
if (existing.taskUploadMaxSizeMb !== undefined) storage.taskUploadMaxSizeMb = existing.taskUploadMaxSizeMb;
else if (typeof tools.taskUploadMaxSizeMb === 'number') storage.taskUploadMaxSizeMb = tools.taskUploadMaxSizeMb;
if (existing.trashRetentionDays !== undefined) storage.trashRetentionDays = existing.trashRetentionDays;
else if (typeof tools.trashRetentionDays === 'number') storage.trashRetentionDays = tools.trashRetentionDays;
// Reverse backfill into the legacy flat keys so downstream readers
// that haven't migrated to `config.storage.*` keep finding the values
// where they expect.
//
// Precedence: when `storage.X` was set on the *input* to the normalizer
// (i.e. the user authored a v2 storage block), that value is the
// authoritative source of truth and unconditionally overrides any
// top-level value — because the top-level value at this point is
// almost certainly the merged-in legacy default
// (`/var/lib/maestro/workspaces`) which is unwritable on
// typical deploys. A user that wants to override storage.* with an
// env var (WORKTREE_DIR etc) should apply that override *after* this
// normalizer runs; loadConfig() in src/config.ts does exactly that
// for WORKTREE_DIR (Step 1 hotfix #369).
//
// When `storage.X` was synthesized from a legacy top-level key (v1
// input), the top-level was already set and the mirror is a no-op.
if (existing.worktreeDir !== undefined && storage.worktreeDir !== undefined) {
out.worktreeDir = storage.worktreeDir;
} else if (storage.worktreeDir !== undefined && (out.worktreeDir === undefined || out.worktreeDir === '')) {
out.worktreeDir = storage.worktreeDir;
}
if (existing.customPiecesDir !== undefined && storage.customPiecesDir !== undefined) {
out.customPiecesDir = storage.customPiecesDir;
} else if (storage.customPiecesDir !== undefined && (out.customPiecesDir === undefined || out.customPiecesDir === '')) {
out.customPiecesDir = storage.customPiecesDir;
}
if (existing.userFolderRoot !== undefined && storage.userFolderRoot !== undefined) {
out.userFolderRoot = storage.userFolderRoot;
} else if (storage.userFolderRoot !== undefined && (out.userFolderRoot === undefined || out.userFolderRoot === '')) {
out.userFolderRoot = storage.userFolderRoot;
}
// tools.* upload / trash: only mirror when not already set on the
// tools block. The settings UI surfaces these on the Media & Documents
// sub-form which binds directly to `tools.*`, so keeping that path
// alive is the cheapest fix for the v2 read path.
if (storage.taskUploadMaxSizeMb !== undefined) {
const toolsObj = (out.tools ?? {}) as Record<string, unknown>;
if (toolsObj.taskUploadMaxSizeMb === undefined) {
toolsObj.taskUploadMaxSizeMb = storage.taskUploadMaxSizeMb;
out.tools = toolsObj;
}
}
if (storage.trashRetentionDays !== undefined) {
const toolsObj = (out.tools ?? {}) as Record<string, unknown>;
if (toolsObj.trashRetentionDays === undefined) {
toolsObj.trashRetentionDays = storage.trashRetentionDays;
out.tools = toolsObj;
}
}
return storage;
}
+589
View File
@@ -0,0 +1,589 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { AppConfig, DEFAULT_LLM_RETRY_CONFIG, isExecutionWorker, loadConfig, ReflectionConfig, toSnakeKeys, validateConfig } from './config.js';
describe('toSnakeKeys', () => {
it('converts camelCase keys to snake_case', () => {
expect(toSnakeKeys({ baseUrl: 'http://x', maxAttempts: 3 }))
.toEqual({ base_url: 'http://x', max_attempts: 3 });
});
it('handles nested objects', () => {
expect(toSnakeKeys({ provider: { backoffMs: [100, 200] } }))
.toEqual({ provider: { backoff_ms: [100, 200] } });
});
it('preserves arrays of primitives', () => {
expect(toSnakeKeys({ roles: ['auto', 'fast'] }))
.toEqual({ roles: ['auto', 'fast'] });
});
it('handles arrays of objects', () => {
expect(toSnakeKeys({ workers: [{ maxConcurrency: 2 }] }))
.toEqual({ workers: [{ max_concurrency: 2 }] });
});
it('returns primitives as-is', () => {
expect(toSnakeKeys('hello')).toBe('hello');
expect(toSnakeKeys(42)).toBe(42);
expect(toSnakeKeys(null)).toBe(null);
});
});
describe('loadConfig provider.retry', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
it('loads provider.retry from YAML', () => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-'));
const configPath = join(tempDir, 'config.yaml');
writeFileSync(configPath, [
'provider:',
' model: test-model',
' retry:',
' max_attempts: 5',
' backoff_ms:',
' - 200',
' - 400',
' retryable_status:',
' - 429',
' - 503',
].join('\n'));
const config = loadConfig(configPath);
expect(config.provider.retry).toEqual({
maxAttempts: 5,
backoffMs: [200, 400],
retryableStatus: [429, 503],
});
});
it('uses the default provider.retry when not configured', () => {
const config = loadConfig(join(tmpdir(), 'missing-config.yaml'));
expect(config.provider.retry).toEqual(DEFAULT_LLM_RETRY_CONFIG);
});
it('converts deprecated profiles to roles via shim', () => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-'));
const configPath = join(tempDir, 'config.yaml');
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: gpu-fast',
' endpoint: http://fast.example/v1',
' profiles: [fast]',
].join('\n'));
const config = loadConfig(configPath);
expect(config.provider.workers[0]).toEqual(expect.objectContaining({
enabled: true,
roles: ['fast'],
}));
expect(config.provider.workers[0]!.profiles).toBeUndefined();
});
it('uses roles directly when specified', () => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-'));
const configPath = join(tempDir, 'config.yaml');
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: gpu1',
' endpoint: http://gpu1.example/v1',
' roles: [fast, title]',
].join('\n'));
const config = loadConfig(configPath);
expect(config.provider.workers[0]).toEqual(expect.objectContaining({
roles: ['fast', 'title'],
}));
});
it('defaults proxy=false when omitted', () => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-'));
const configPath = join(tempDir, 'config.yaml');
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: gpu1',
' endpoint: http://gpu1.example/v1',
].join('\n'));
const config = loadConfig(configPath);
expect(config.provider.workers[0]!.proxy).toBe(false);
expect(config.provider.workers[0]!.proxyType).toBeUndefined();
});
it('accepts proxy=true with default proxyType=litellm', () => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-'));
const configPath = join(tempDir, 'config.yaml');
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
' api_key: tok-abc',
].join('\n'));
const config = loadConfig(configPath);
expect(config.provider.workers[0]).toEqual(expect.objectContaining({
id: 'team-pool',
proxy: true,
proxyType: 'litellm',
apiKey: 'tok-abc',
}));
});
it('preserves proxyType when explicitly set', () => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-'));
const configPath = join(tempDir, 'config.yaml');
writeFileSync(configPath, [
'provider:',
' model: test-model',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
' proxy_type: litellm',
].join('\n'));
const config = loadConfig(configPath);
expect(config.provider.workers[0]!.proxyType).toBe('litellm');
});
});
const DEFAULT_REFLECTION_FOR_TEST: ReflectionConfig = {
enabled: false,
workerRequired: true,
maxMemoryChangesPerJob: 3,
maxEntryBodyBytes: 8192,
pieceEditCooldownHours: 24,
snapshotRetentionDays: 90,
activityLogMaxBytes: 4096,
abstainRateFloor: 0.3,
perUserDailyBudgetTokens: 200_000,
snapshotMaxBytesPerUser: 100 * 1024 * 1024,
snapshotMaxBytesPerEntry: 1 * 1024 * 1024,
storeLlmRaw: false,
};
function makeValidConfig(): AppConfig {
return {
provider: {
model: 'qwen3:32b',
workers: [{ id: 'default', endpoint: 'http://localhost:11434/v1' }],
retry: {
maxAttempts: 3,
backoffMs: [2000, 5000, 15000],
retryableStatus: [429, 500, 502, 503, 504],
},
},
worktreeDir: '/var/lib/maestro/workspaces',
concurrency: 1,
maxMovements: 30,
retry: {
maxAttempts: 3,
backoffSeconds: [60, 300, 900],
},
ask: {
maxPerJob: 2,
},
subtasks: {
maxDepth: 2,
maxPerParent: 10,
},
safety: {
maxIterations: 200,
maxRevisits: 3,
},
reflection: { ...DEFAULT_REFLECTION_FOR_TEST },
};
}
describe('validateConfig', () => {
it('valid default config passes with no errors', () => {
const config = makeValidConfig();
expect(validateConfig(config)).toHaveLength(0);
});
it('provider.model may be empty string (no error)', () => {
const config = makeValidConfig();
config.provider.model = '';
const errors = validateConfig(config);
expect(errors.some(e => e.includes('provider.model'))).toBe(false);
});
it('provider.model may be omitted (no error)', () => {
const config = makeValidConfig();
config.provider.model = undefined;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('provider.model'))).toBe(false);
});
it('invalid concurrency (0) produces error', () => {
const config = makeValidConfig();
config.concurrency = 0;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('concurrency'))).toBe(true);
});
it('invalid concurrency (-1) produces error', () => {
const config = makeValidConfig();
config.concurrency = -1;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('concurrency'))).toBe(true);
});
it('invalid concurrency (float) produces error', () => {
const config = makeValidConfig();
config.concurrency = 1.5;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('concurrency'))).toBe(true);
});
it('invalid maxMovements (0) produces error', () => {
const config = makeValidConfig();
config.maxMovements = 0;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('maxMovements'))).toBe(true);
});
it('invalid ask.maxPerJob (-1) produces error', () => {
const config = makeValidConfig();
config.ask.maxPerJob = -1;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('ask.maxPerJob'))).toBe(true);
});
it('ask.maxPerJob (0) is valid', () => {
const config = makeValidConfig();
config.ask.maxPerJob = 0;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('ask.maxPerJob'))).toBe(false);
});
it('invalid subtasks.maxDepth (-1) produces error', () => {
const config = makeValidConfig();
config.subtasks.maxDepth = -1;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('subtasks.maxDepth'))).toBe(true);
});
it('subtasks.maxDepth (0) is valid', () => {
const config = makeValidConfig();
config.subtasks.maxDepth = 0;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('subtasks.maxDepth'))).toBe(false);
});
it('invalid retry.maxAttempts (0) produces error', () => {
const config = makeValidConfig();
config.retry.maxAttempts = 0;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('retry.maxAttempts'))).toBe(true);
});
it('invalid retry.backoffSeconds (empty array) produces error', () => {
const config = makeValidConfig();
config.retry.backoffSeconds = [];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('retry.backoffSeconds'))).toBe(true);
});
it('worker with empty id produces error', () => {
const config = makeValidConfig();
config.provider.workers = [{ id: '', endpoint: 'http://localhost:11434/v1' }];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('empty id'))).toBe(true);
});
it('worker with empty endpoint produces error', () => {
const config = makeValidConfig();
config.provider.workers = [{ id: 'w1', endpoint: '' }];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('endpoint'))).toBe(true);
});
it('empty workers array produces error', () => {
const config = makeValidConfig();
config.provider.workers = [];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('provider.workers'))).toBe(true);
});
it('rejects non-boolean proxy field (string "true")', () => {
// YAML `proxy: "true"` (quoted) parses to a string. Without this
// check the worker would silently run in direct mode but still ship
// its apiKey as a Bearer token — credential-leak footgun.
const config = makeValidConfig();
config.provider.workers = [
{
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
// @ts-expect-error — intentionally invalid value for validator test
proxy: 'true',
},
];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('proxy must be boolean'))).toBe(true);
});
it('rejects non-boolean proxy field (number 1)', () => {
const config = makeValidConfig();
config.provider.workers = [
{
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
// @ts-expect-error — intentionally invalid value for validator test
proxy: 1,
},
];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('proxy must be boolean'))).toBe(true);
});
it('rejects non-boolean proxy field (null)', () => {
const config = makeValidConfig();
config.provider.workers = [
{
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
// @ts-expect-error — intentionally invalid value for validator test
proxy: null,
},
];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('proxy must be boolean'))).toBe(true);
});
it('proxy=true (boolean) and proxy=false (boolean) are both accepted', () => {
const config = makeValidConfig();
config.provider.workers = [
{ id: 't', endpoint: 'http://litellm:4000/v1', proxy: true },
{ id: 'd', endpoint: 'http://gpu:11434/v1', proxy: false },
];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('proxy'))).toBe(false);
});
it('rejects unsupported proxy_type value', () => {
const config = makeValidConfig();
config.provider.workers = [
{
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
proxy: true,
// @ts-expect-error — intentionally invalid value for validator test
proxyType: 'openrouter',
},
];
const errors = validateConfig(config);
expect(errors.some(e => e.includes('proxy_type'))).toBe(true);
});
it('safety.maxIterations (0) produces error', () => {
const config = makeValidConfig();
config.safety = { maxIterations: 0 };
const errors = validateConfig(config);
expect(errors.some(e => e.includes('safety.maxIterations'))).toBe(true);
});
it('safety.maxRevisits (-1) produces error', () => {
const config = makeValidConfig();
config.safety = { maxRevisits: -1 };
const errors = validateConfig(config);
expect(errors.some(e => e.includes('safety.maxRevisits'))).toBe(true);
});
it('undefined safety passes validation', () => {
const config = makeValidConfig();
config.safety = undefined;
const errors = validateConfig(config);
expect(errors.some(e => e.includes('safety'))).toBe(false);
});
it('safety.promptGuardRatio in [0.5, 0.95] is valid', () => {
const config = makeValidConfig();
for (const r of [0.5, 0.7, 0.8, 0.9, 0.95]) {
config.safety = { promptGuardRatio: r };
const errors = validateConfig(config);
expect(errors.some(e => e.includes('promptGuardRatio'))).toBe(false);
}
});
it('safety.promptGuardRatio out of range produces error', () => {
const config = makeValidConfig();
for (const r of [0.49, 0.96, 1.2, -0.1, 0]) {
config.safety = { promptGuardRatio: r };
const errors = validateConfig(config);
expect(errors.some(e => e.includes('promptGuardRatio'))).toBe(true);
}
});
it('safety.promptGuardRatio non-number produces error', () => {
const config = makeValidConfig();
config.safety = { promptGuardRatio: 'high' as unknown as number };
const errors = validateConfig(config);
expect(errors.some(e => e.includes('promptGuardRatio'))).toBe(true);
});
it('safety.historySummarization with valid fields passes', () => {
const config = makeValidConfig();
config.safety = {
historySummarization: { enabled: true, tailTurns: 2, preserveRecentBudget: 8000 },
};
expect(validateConfig(config)).toHaveLength(0);
});
it('safety.historySummarization.tailTurns negative produces error', () => {
const config = makeValidConfig();
config.safety = { historySummarization: { tailTurns: -1 } };
const errors = validateConfig(config);
expect(errors.some(e => e.includes('historySummarization.tailTurns'))).toBe(true);
});
it('safety.historySummarization.preserveRecentBudget zero produces error', () => {
const config = makeValidConfig();
config.safety = { historySummarization: { preserveRecentBudget: 0 } };
const errors = validateConfig(config);
expect(errors.some(e => e.includes('historySummarization.preserveRecentBudget'))).toBe(true);
});
it('invalid provider.retry.maxAttempts (0) produces error', () => {
const config = makeValidConfig();
config.provider.retry = { maxAttempts: 0, backoffMs: [1000], retryableStatus: [500] };
const errors = validateConfig(config);
expect(errors.some(e => e.includes('provider.retry.maxAttempts'))).toBe(true);
});
it('valid config with all optional fields set passes', () => {
const config = makeValidConfig();
config.safety = { maxIterations: 100, maxRevisits: 5 };
config.provider.retry = { maxAttempts: 5, backoffMs: [1000, 3000], retryableStatus: [429, 500] };
config.ask.maxPerJob = 0;
config.subtasks.maxDepth = 0;
expect(validateConfig(config)).toHaveLength(0);
});
describe('provider.metrics.prefix length cap (Phase 3b post-review)', () => {
it('rejects 1-character prefix (under length 2)', () => {
const config = makeValidConfig();
config.provider.metrics = { prefix: 'a' };
const errors = validateConfig(config);
expect(errors.some(e => /prefix length must be 2-64/.test(e))).toBe(true);
});
it('rejects 65-character prefix (over length 64)', () => {
const config = makeValidConfig();
config.provider.metrics = { prefix: 'a'.repeat(65) };
const errors = validateConfig(config);
expect(errors.some(e => /prefix length must be 2-64/.test(e))).toBe(true);
});
it('accepts 64-character prefix', () => {
const config = makeValidConfig();
config.provider.metrics = { prefix: 'a'.repeat(64) };
const errors = validateConfig(config);
expect(errors.filter(e => /prefix length/.test(e))).toEqual([]);
});
it('accepts 2-character prefix', () => {
const config = makeValidConfig();
config.provider.metrics = { prefix: 'ab' };
const errors = validateConfig(config);
expect(errors.filter(e => /prefix length/.test(e))).toEqual([]);
});
});
});
describe('reflection role', () => {
it('treats reflection-only worker as execution worker', () => {
expect(isExecutionWorker({
id: 'r1',
endpoint: 'http://localhost:11434/v1',
model: 'm',
roles: ['reflection'],
maxConcurrency: 1,
} as any)).toBe(true);
});
});
describe('safety.bashSandbox', () => {
it('defaults to "auto" when unset', () => {
// loadConfig falls back to defaults when the file does not exist
const cfg = loadConfig(join(tmpdir(), 'missing-bash-sandbox-config.yaml'));
expect(cfg.safety?.bashSandbox).toBe('auto');
});
it('accepts auto|always|off', () => {
for (const v of ['auto', 'always', 'off'] as const) {
const config = makeValidConfig();
config.safety = { bashSandbox: v };
expect(validateConfig(config)).toHaveLength(0);
}
});
it('rejects invalid value', () => {
const config = makeValidConfig();
config.safety = { bashSandbox: 'loose' as unknown as 'auto' | 'always' | 'off' };
const errors = validateConfig(config);
expect(errors.some(e => /bashSandbox must be one of/.test(e))).toBe(true);
});
});
describe('reflection config section', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
it('has default reflection block when not specified', () => {
const cfg = loadConfig(join(tmpdir(), 'missing-config.yaml'));
expect(cfg.reflection.enabled).toBe(false);
expect(cfg.reflection.maxMemoryChangesPerJob).toBe(3);
expect(cfg.reflection.snapshotRetentionDays).toBe(90);
});
it('user overrides merge with defaults', () => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-config-'));
const configPath = join(tempDir, 'config.yaml');
writeFileSync(configPath, [
'provider:',
' model: test-model',
'reflection:',
' enabled: true',
' max_memory_changes_per_job: 5',
].join('\n'));
const cfg = loadConfig(configPath);
expect(cfg.reflection.enabled).toBe(true);
expect(cfg.reflection.maxMemoryChangesPerJob).toBe(5);
expect(cfg.reflection.snapshotRetentionDays).toBe(90);
});
});
+893
View File
@@ -0,0 +1,893 @@
import { readFileSync, existsSync } from 'fs';
import { parse as parseYaml } from 'yaml';
import { logger } from './logger.js';
import { normalizeConfig } from './config-normalize.js';
import type { McpRuntimeConfig } from './mcp/config.js';
import type { SshRuntimeConfig } from './ssh/config.js';
export interface AskConfig {
maxPerJob: number; // default: 2
}
export interface SubtasksConfig {
maxDepth: number; // default: 2 (0 = no decomposition)
maxPerParent: number; // default: 10 (max subtasks a single job can spawn)
}
export interface ToolsConfig {
searxngUrl?: string; // default: http://searxng:8080
visionModel?: string; // default: qwen2-vl:8b-instruct
visionBaseUrl?: string; // default: same as provider.baseUrl
visionTimeout?: number; // seconds, default: 60
visionMaxTokens?: number; // default: 1024
webfetchTimeout?: number; // seconds, default: 30
websearchTimeout?: number; // seconds, default: 15
webfetchAllowedHosts?: string[]; // private IPs to explicitly allow
xCliCommand?: string[] | string;
xTimeout?: number;
xAuthToken?: string;
xCt0?: string;
xProxy?: string;
xChromeProfile?: string;
/** X 投稿に紐づく画像/動画を自動 DL するか。'auto' (default) で media[] が空でなければ DL。'never' で全くやらない */
xDownloadMedia?: 'auto' | 'never';
/** 動画の扱い。'thumbnail' (default) で poster だけ、'full' で variants から最高画質 mp4 も DL、'never' でスキップ */
xDownloadVideo?: 'thumbnail' | 'full' | 'never';
/** 1 メディア当たりの DL サイズ上限 (MB)。default 25 */
xMediaMaxMb?: number;
/**
* 1 メディアあたりの fetch hard timeout 秒。default 15。
* pbs.twimg.com / video.twimg.com の応答が止まったまま fetch が無限に
* ぶら下がるケース (CDN 障害, 大容量動画) を防ぐためのセーフティ。
*/
xMediaFetchTimeoutSeconds?: number;
ocrModel?: string; // GLM-OCR 用モデル名 (default: glm-ocr)
/** @deprecated Use AppConfig.searchFilter instead */
searchFilter?: SearchFilterConfig;
browserPageTimeout?: number; // ms, default: 60000
browserActionTimeout?: number; // ms, default: 30000
googleMapsApiKey?: string; // Google Maps API キー(省略時は Nominatim/OSRM を使用)
mapsTimeout?: number; // 地図 API タイムアウト秒 (default: 30)
amazonAffiliateTag?: string; // Amazon アソシエイトタグ
keepaApiKey?: string; // Keepa API キー(省略時はグラフ画像リンクのみ)
speechServerUrl?: string; // 音声認識サーバー URL (例: http://localhost:8000/v1)
speechTimeout?: number; // タイムアウト秒 (default: 300)
speechLanguage?: string; // デフォルト言語 (default: ja)
knowledgeServiceUrl?: string; // DKS サーバー URL (例: http://dks-server:8100)
knowledgeNamespaces?: Record<string, { apiKey: string }>; // namespace ごとの API キー
officeExcelMaxSizeMb?: number; // ReadExcel の最大ファイルサイズ (default: 10)
officeDocxMaxSizeMb?: number; // ReadDocx の最大ファイルサイズ (default: 10)
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50)
officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
/**
* Max request body size (MB) for the POST /api/local/tasks and
* /api/local/tasks/:id/comments endpoints (includes base64-encoded
* attachments). Default: 50.
*
* Because attachments are base64-encoded in the JSON body, the effective
* raw file size is roughly value × 0.75 (e.g. 50 MB body ≈ 37 MB raw).
* Hot-reloadable: applies to the next request.
*/
taskUploadMaxSizeMb?: number;
/**
* Allow RunUserScript to execute user-authored scripts.
* Default: false (opt-in required).
* Plain-runtime scripts now run under Node's Permissions Model
* (--permission), which blocks child_process, worker threads, and FS access
* outside tmpdir. browser-macros still run with full Node.js capabilities
* because Playwright needs them — only enable for trusted users.
*/
userScriptsEnabled?: boolean;
/**
* Optional per-user allowlist for RunUserScript / scheduled script tasks.
* When undefined (default), `userScriptsEnabled` alone controls access.
* When set to a list of user ids, only those users may run scripts even if
* the global gate is true — useful for granular rollout in shared installs.
*/
userScriptsAllowUserids?: string[];
/**
* How many days files in `data/users/{userId}/trash/` are retained before
* being auto-deleted by the daily trash sweep. Default: 30. Use 0 to disable
* retention (every sweep deletes everything in trash).
*/
trashRetentionDays?: number;
}
export interface RetryConfig {
maxAttempts: number;
backoffSeconds: number[];
}
function cloneRetryConfig(config: RetryConfig): RetryConfig {
return {
maxAttempts: config.maxAttempts,
backoffSeconds: [...config.backoffSeconds],
};
}
export const DEFAULT_PROVIDER_RETRY_CONFIG: RetryConfig = {
maxAttempts: 3,
backoffSeconds: [1, 3, 10],
};
export interface ProviderRetryConfig {
maxAttempts: number;
backoffMs: number[];
retryableStatus: number[];
}
function cloneProviderRetryConfig(config: ProviderRetryConfig): ProviderRetryConfig {
return {
maxAttempts: config.maxAttempts,
backoffMs: [...config.backoffMs],
retryableStatus: [...config.retryableStatus],
};
}
export const DEFAULT_LLM_RETRY_CONFIG: ProviderRetryConfig = {
maxAttempts: 3,
backoffMs: [2000, 5000, 15000],
retryableStatus: [429, 500, 502, 503, 504],
};
export interface WorkerDef {
id: string;
endpoint: string;
model?: string; // falls back to provider.model if omitted
enabled?: boolean;
maxConcurrency?: number;
vlm?: boolean; // true: ReadImage uses this worker's own model instead of dedicated VLM endpoint
roles?: string[];
/** @deprecated Use roles instead. Kept for backward compat shim. */
profiles?: string[];
healthcheckIntervalSeconds?: number;
/**
* When true, this worker's endpoint points at an LLM gateway / proxy
* (e.g. LiteLLM Proxy) that fans out to a pool of physical backends.
* The AAO uses the proxy's response headers (e.g. `x-litellm-model-id`)
* to identify which physical backend handled each LLM call, so that
* features like Pet ↔ backend mapping work through the proxy.
* Default: false (direct worker).
* See docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md.
*/
proxy?: boolean;
/**
* When proxy=true, identifies which proxy implementation the endpoint
* speaks. Only 'litellm' is implemented in v1; the field exists for
* future expansion. Defaults to 'litellm' when proxy=true and unset.
*/
proxyType?: 'litellm';
/**
* Optional bearer / API key used when calling this worker's endpoint
* (e.g. team-scoped LiteLLM token). When omitted the request is sent
* without an Authorization header.
*/
apiKey?: string;
}
export interface ProviderConfig {
baseUrl?: string; // DEPRECATED: for backward compat only
model?: string; // default model (optional — if omitted, model field is excluded from LLM requests)
workers: WorkerDef[];
retry?: ProviderRetryConfig; // per-call HTTP retry for LLM requests
timeoutMinutes?: number; // LLM request timeout in minutes (default: 10)
/**
* Phase 3b: Prometheus `/metrics` exporter settings for worker mode.
* Defaults to enabled with `aao_worker_` prefix. Set
* `metrics.enabled: false` to skip the endpoint + all counter
* emission.
*/
metrics?: ProviderMetricsConfig;
}
/**
* Phase 3b — worker-mode Prometheus exporter knobs. Placement under
* `provider` reflects that the LLM-call counters are the dominant
* worker metric set; future relocation to its own root section is
* acceptable as long as the legacy provider path keeps reading.
*
* Auth hardening (Phase 3b post-review):
* - `bearerToken`: when set, `/metrics` requires `Authorization:
* Bearer <token>`. Wins over the IP allowlist — operators use this
* when Prometheus scrapes from arbitrary subnets.
* - `allowedHosts`: client-IP allowlist applied when no bearer is
* set. Default is localhost-only (`['127.0.0.1', '::1',
* 'localhost']`). Include `0.0.0.0` to disable IP checks entirely
* (only safe when a reverse proxy / firewall handles ACLs).
*
* Both fields can be overridden via env (`$AAO_WORKER_METRICS_BEARER_TOKEN`
* etc.) — see env-override block in this file.
*/
export interface ProviderMetricsConfig {
enabled?: boolean;
prefix?: string;
bearerToken?: string;
allowedHosts?: string[];
}
export interface ContextConfig {
limitTokens?: number;
thresholds?: Array<{
ratio: number;
action: 'warn' | 'prompt' | 'force_transition';
}>;
}
export interface HistorySummarizationConfig {
enabled?: boolean; // default true
tailTurns?: number; // default 2 (assistant+tool turns to always preserve)
preserveRecentBudget?: number; // default 8000 tokens
}
export interface SafetyConfig {
maxIterations?: number;
maxRevisits?: number;
/**
* Fraction of the model context budget that the prompt is allowed to fill
* before guardPromptBeforeSend triggers compaction/summarization.
* Range: 0.50.95. Default: 0.8.
*/
promptGuardRatio?: number;
historySummarization?: HistorySummarizationConfig;
/**
* When true, Bash tool runs without command whitelist or path-scope
* restrictions. Instead, each command is executed inside a bwrap sandbox
* that bind-mounts only the task workspace (rw) and system dirs (ro).
* Requires bwrap with user-namespace support. Default: false.
*/
bashUnrestricted?: boolean;
/**
* Bash サンドボックス機構の選択。
* - 'auto'(既定): bwrap があれば sandboxed、無ければ hardened-whitelist
* - 'always': sandboxed を強制。bwrap 不在なら起動時 fail-closed
* - 'off': 旧来の素 exec(後方互換・デバッグ用、非推奨)
*/
bashSandbox?: 'auto' | 'always' | 'off';
}
export interface SkillsConfig {
maxPerUser?: number; // default: 50
maxSkillSizeKb?: number; // default: 64
maxTotalSizeMb?: number; // default: 5
maxSystemSkills?: number; // default: 100
maxIndexChars?: number; // default: 2000
}
export interface SearchFilterConfig {
blockedPatterns?: string[];
autoBlock?: {
privateIp?: boolean;
internalDomain?: boolean;
email?: boolean;
phone?: boolean;
};
}
export interface BrowserConfig {
vncBasePort?: number; // default 5900
sessionDataDir?: string; // default './data/browser-sessions'
maxSessions?: number; // default 5 (CAPTCHA Pool は別枠でカウントしない)
captchaSolve?: 'skip' | 'novnc'; // default 'skip'
maxCaptchaPages?: number; // default 5
/** Task Session が job 完了から何秒アイドルしたら GC するか (default 300) */
taskSessionIdleTtl?: number;
/** Playwright の channel ('chromium' default / 'chrome' で本物の Chrome を使う) */
channel?: 'chromium' | 'chrome' | 'msedge';
/** 任意の実行ファイルパス指定 (channel と排他、上級者向け) */
executablePath?: string;
}
export interface AuthProviderConfig {
clientId: string;
clientSecret: string;
callbackUrl: string;
baseUrl?: string; // Gitea 用
}
export type PrimaryAuthProvider = 'google' | 'gitea';
export interface AuthConfig {
sessionSecret: string;
sessionMaxAge: number; // ミリ秒
secureCookie: boolean;
adminEmails: string[];
primaryProvider?: PrimaryAuthProvider;
providers: {
google?: AuthProviderConfig;
gitea?: AuthProviderConfig;
};
}
export interface BrandingConfig {
appName?: string; // TopBar / document.title に表示
primaryColor?: string; // Tailwind の brand-primary に反映 (hex / rgb)
loginPageTitle?: string; // ログイン画面の見出し
/** Public URL (eg. "/branding/logo-abc123.svg") — サーバーの data/branding/ 配下を /branding/ で配信 */
logoUrl?: string;
/** Public URL (eg. "/branding/favicon-abc123.png") — 未指定時は UI の同梱 favicon を使用 */
faviconUrl?: string;
/** TopBar 下部など UI で表示する小さな注記文字列 */
footerText?: string;
}
export interface ReflectionConfig {
enabled: boolean;
workerRequired: boolean;
maxMemoryChangesPerJob: number;
maxEntryBodyBytes: number;
pieceEditCooldownHours: number;
snapshotRetentionDays: number;
activityLogMaxBytes: number;
abstainRateFloor: number;
perUserDailyBudgetTokens: number;
snapshotMaxBytesPerUser: number;
snapshotMaxBytesPerEntry: number;
storeLlmRaw: boolean;
}
export interface NotesInjectConfig {
perNoteMaxKb: number; // default: 8
totalMaxKb: number; // default: 32
overBudgetStrategy: 'truncate_last' | 'skip_remaining' | 'degrade_to_search'; // default: 'skip_remaining'
}
export interface NotesConfig {
inject?: Partial<NotesInjectConfig>;
}
export const DEFAULT_NOTES_INJECT: NotesInjectConfig = {
perNoteMaxKb: 8,
totalMaxKb: 32,
overBudgetStrategy: 'skip_remaining',
};
/**
* v2 LLM worker definition. Mirrors the v1 `WorkerDef` but:
* - replaces ambiguous `proxy: boolean` with explicit
* `connection_type: 'direct' | 'aao_gateway'` (settings doc 2026-05-21)
* - drops the implicit fallback to `provider.model` — each worker carries
* its own model string (may be empty if neither source supplied one)
*
* Kept structurally parallel to `WorkerDef` so existing runtime code that
* reads `endpoint` / `model` / `roles` etc. works without per-field
* rewiring; the only new required-ish field is `connectionType`.
*/
export interface LlmWorkerDef {
id: string;
/** Explicit connection mode; replaces v1 `proxy: boolean`. */
connectionType: 'direct' | 'aao_gateway';
endpoint: string;
/** Model string. Empty allowed (paired with startup warning) for migration safety. */
model: string;
/** Optional bearer / API key. Required in UI when connectionType === 'aao_gateway'. */
apiKey?: string;
roles: string[];
maxConcurrency: number;
enabled: boolean;
vlm?: boolean;
healthcheckIntervalSeconds?: number;
}
/**
* v2 LLM block. Sibling of the legacy `provider`; both will coexist for one
* release. Generated by normalizer from either v2 input (`llm` key present)
* or v1 input (`provider.*` keys).
*/
export interface LlmConfig {
timeoutMinutes?: number;
retry?: ProviderRetryConfig;
metrics?: ProviderMetricsConfig;
workers: LlmWorkerDef[];
}
/**
* v2 Storage block. Groups path / quota / retention knobs that were
* scattered across `worktree_dir`, `custom_pieces_dir`, `user_folder_root`,
* `tools.task_upload_max_size_mb`, and `tools.trash_retention_days`.
*/
export interface StorageConfig {
worktreeDir?: string;
customPiecesDir?: string;
userFolderRoot?: string;
taskUploadMaxSizeMb?: number;
trashRetentionDays?: number;
}
export interface PushNotificationsConfig {
/** Master switch for Web Push (V2). Default false until operators opt in. */
enabled?: boolean;
/** RFC 8292 VAPID subject. Operations URL preferred over generic mailto:. */
vapidSubject?: string;
/** Path to the current VAPID keypair file. Default ./data/secrets/vapid.json */
vapidCurrentPath?: string;
/** Directory for retired VAPID keys. Default ./data/secrets/vapid-history */
vapidHistoryDir?: string;
/** Maximum push payload size in bytes (before encryption overhead). */
payloadMaxBytes?: number;
/** Max concurrent sends from the queue. */
queueConcurrency?: number;
/** Per-send timeout (ms). */
perSendTimeoutMs?: number;
}
export interface NotificationsConfig {
push?: PushNotificationsConfig;
}
export interface AppConfig {
/**
* Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1`
* means a v1 file that the normalizer must migrate. Other values fatal
* out at load time (typo guard).
*/
configVersion?: number;
provider: ProviderConfig;
/**
* v2 LLM block. Always populated post-normalization. Downstream code may
* read either `provider.*` (legacy) or `llm.*` (preferred); both are kept
* coherent by the normalizer during the compat window.
*/
llm?: LlmConfig;
worktreeDir: string;
concurrency: number;
maxMovements: number;
retry: RetryConfig;
tools?: ToolsConfig;
ask: AskConfig;
subtasks: SubtasksConfig;
context?: ContextConfig;
safety?: SafetyConfig;
skills?: SkillsConfig;
searchFilter?: SearchFilterConfig;
browser?: BrowserConfig;
customPiecesDir?: string;
auth?: AuthConfig;
branding?: BrandingConfig;
reflection: ReflectionConfig;
secrets?: {
masterKeyPath?: string; // default './data/secrets/master.key'
mcpKeyPath?: string; // default './data/secrets/mcp.key'
};
userFolderRoot?: string; // default './data/users'
/**
* v2 Storage block. Populated by normalizer alongside the legacy top-level
* `worktreeDir` / `customPiecesDir` / `userFolderRoot` so callers can
* migrate at their own pace.
*/
storage?: StorageConfig;
mcp?: Partial<McpRuntimeConfig>;
ssh?: Partial<SshRuntimeConfig>;
notes?: NotesConfig;
notifications?: NotificationsConfig;
}
const DEFAULT_REFLECTION: ReflectionConfig = {
enabled: false,
workerRequired: true,
maxMemoryChangesPerJob: 3,
maxEntryBodyBytes: 8192,
pieceEditCooldownHours: 24,
snapshotRetentionDays: 90,
activityLogMaxBytes: 4096,
abstainRateFloor: 0.3,
perUserDailyBudgetTokens: 200_000,
snapshotMaxBytesPerUser: 100 * 1024 * 1024,
snapshotMaxBytesPerEntry: 1 * 1024 * 1024,
storeLlmRaw: false,
};
// デフォルト設定
const defaults: AppConfig = {
provider: {
baseUrl: 'http://localhost:11434/v1',
model: 'qwen3:32b',
workers: [],
retry: cloneProviderRetryConfig(DEFAULT_LLM_RETRY_CONFIG),
},
worktreeDir: '/var/lib/maestro/workspaces',
concurrency: 1,
maxMovements: 30,
retry: {
maxAttempts: 3,
backoffSeconds: [60, 300, 900],
},
ask: {
maxPerJob: 2,
},
subtasks: {
maxDepth: 2,
maxPerParent: 10,
},
tools: {
searxngUrl: 'http://searxng:8080',
visionModel: 'qwen2-vl:8b-instruct',
visionTimeout: 60,
visionMaxTokens: 1024,
webfetchTimeout: 30,
websearchTimeout: 15,
webfetchAllowedHosts: [],
xCliCommand: ['twitter'],
xTimeout: 90,
trashRetentionDays: 30,
},
context: {
thresholds: [
{ ratio: 0.7, action: 'warn' as const },
{ ratio: 0.85, action: 'prompt' as const },
{ ratio: 0.95, action: 'force_transition' as const },
],
},
safety: {
maxIterations: 200,
maxRevisits: 3,
bashSandbox: 'auto',
},
reflection: { ...DEFAULT_REFLECTION },
};
// YAML のスネークケースキーをキャメルケースに変換するユーティリティ
function toCamel(s: string): string {
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
}
function transformKeys(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(transformKeys);
if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
toCamel(k),
transformKeys(v),
])
);
}
return obj;
}
function toSnake(str: string): string {
return str.replace(/[A-Z]/g, (ch) => '_' + ch.toLowerCase());
}
export function toSnakeKeys(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(toSnakeKeys);
if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
toSnake(k),
toSnakeKeys(v),
])
);
}
return obj;
}
function deepMerge<T>(base: T, override: Partial<T>): T {
const result = { ...base };
for (const key of Object.keys(override) as Array<keyof T>) {
const overrideVal = override[key];
const baseVal = base[key];
if (
overrideVal !== null &&
typeof overrideVal === 'object' &&
!Array.isArray(overrideVal) &&
baseVal !== null &&
typeof baseVal === 'object' &&
!Array.isArray(baseVal)
) {
result[key] = deepMerge(baseVal, overrideVal as Partial<typeof baseVal>);
} else if (overrideVal !== undefined) {
result[key] = overrideVal as T[keyof T];
}
}
return result;
}
// roles が設定されていて実行ロールを一つも含まない場合、そのワーカーはジョブ実行しない
// 例: roles: ['title'] のみのワーカーはタイトル生成専用でポーリングをスキップする
const EXECUTION_ROLES = new Set(['auto', 'fast', 'quality', 'worker', 'reflection']);
export function isExecutionWorker(worker: WorkerDef): boolean {
if (!worker.roles || worker.roles.length === 0) return true;
return worker.roles.some(r => EXECUTION_ROLES.has(r));
}
/**
* 後方互換 shim: profiles が指定されていれば roles に変換する。
*/
function normalizeWorkerDefs(workers: WorkerDef[]): WorkerDef[] {
return workers.map((worker) => {
// roles が明示されていればそれを使う。なければ profiles から変換(shim
let roles: string[];
if (worker.roles && worker.roles.length > 0) {
roles = worker.roles;
} else if (worker.profiles && worker.profiles.length > 0) {
logger.info(`Config: worker ${worker.id}: converting deprecated 'profiles' to 'roles'`);
roles = [...worker.profiles];
} else {
// デフォルト: 全実行ロール
roles = ['auto', 'fast', 'quality'];
}
roles = Array.from(new Set(roles));
// Normalize proxy fields: if proxy=true and proxyType is unset, default
// to 'litellm' (currently the only supported proxy implementation).
//
// Preserve a non-boolean `proxy` AS-IS (do not coerce to false) so
// validateConfig() can reject it instead of silently downgrading the
// worker to direct mode. If we coerced here, a YAML typo like
// `proxy: "true"` (quoted string) would make the worker behave as
// direct while still shipping its apiKey as a Bearer token to the
// upstream — a credential-leak footgun. validateConfig flags this.
let proxy: boolean;
if (worker.proxy === undefined || typeof worker.proxy === 'boolean') {
proxy = worker.proxy === true;
} else {
// Leave the bad value in place so validateConfig can complain.
proxy = worker.proxy as unknown as boolean;
}
const proxyType = proxy === true ? (worker.proxyType ?? 'litellm') : undefined;
return {
...worker,
enabled: worker.enabled !== false,
maxConcurrency: Math.max(1, worker.maxConcurrency ?? 1),
roles,
// deprecated profiles フィールドを削除して混乱を防ぐ
profiles: undefined,
proxy,
proxyType,
};
});
}
export function loadConfig(configPath: string = 'config.yaml'): AppConfig {
let fileConfig: Partial<AppConfig> = {};
if (existsSync(configPath)) {
try {
const raw = readFileSync(configPath, 'utf-8');
const parsed = parseYaml(raw) as unknown;
fileConfig = transformKeys(parsed) as Partial<AppConfig>;
logger.info(`Config: loaded from ${configPath}`);
} catch (err) {
logger.warn(`Config: failed to parse ${configPath}: ${err}`);
}
} else {
logger.warn(`Config: ${configPath} not found, using defaults`);
}
// ファイル設定でデフォルトを上書き
let config = deepMerge(defaults, fileConfig);
// 環境変数上書き
if (process.env['OLLAMA_BASE_URL']) {
config = deepMerge(config, {
provider: { ...config.provider, baseUrl: process.env['OLLAMA_BASE_URL'] },
});
}
if (process.env['OLLAMA_MODEL']) {
config = deepMerge(config, {
provider: { ...config.provider, model: process.env['OLLAMA_MODEL'] },
});
}
// NOTE: WORKTREE_DIR env override is intentionally applied *after*
// normalizeConfig (below), not here, so an env value beats a
// storage.worktree_dir mirror written by the normalizer. See the
// post-normalize block at end of loadConfig for the actual override.
if (process.env['CONCURRENCY']) {
const n = parseInt(process.env['CONCURRENCY'], 10);
if (!isNaN(n)) config.concurrency = n;
}
// Phase 3b post-review: env overrides for worker metrics auth. Lets
// operators inject a bearer token without committing it to config.yaml.
if (process.env['AAO_WORKER_METRICS_BEARER_TOKEN']) {
config.provider = config.provider ?? { workers: [] };
config.provider.metrics = {
...(config.provider.metrics ?? {}),
bearerToken: process.env['AAO_WORKER_METRICS_BEARER_TOKEN'],
};
}
if (process.env['AAO_WORKER_METRICS_ALLOWED_HOSTS']) {
const list = process.env['AAO_WORKER_METRICS_ALLOWED_HOSTS']!
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
if (list.length > 0) {
config.provider = config.provider ?? { workers: [] };
config.provider.metrics = {
...(config.provider.metrics ?? {}),
allowedHosts: list,
};
}
}
// workers 自動生成: 明示的な workers 設定がない場合は baseUrl から生成
if (!config.provider.workers || config.provider.workers.length === 0) {
const endpoint = config.provider.baseUrl ?? 'http://localhost:11434/v1';
config.provider.workers = [{ id: 'default', endpoint }];
}
config.provider.workers = normalizeWorkerDefs(config.provider.workers);
logger.info(`Config: workers=${config.provider.workers.map(w => w.id).join(',')}`);
// v2 normalization (Step 1 of 2026-05-21 Settings UI / Config restructure).
//
// Runs after legacy provider workers are populated + env overrides applied
// so the v2 `llm.workers` block reflects the *effective* runtime config.
// The normalizer itself is pure and only writes to `llm` / `storage` /
// `configVersion` — the legacy `provider` block stays intact for callers
// that haven't migrated yet. The migration-warning path is suppressed at
// this site because we ran defaults+env merge first; we only want the
// warning to fire when the *source file* was v1.
if (fileConfig.configVersion === undefined && Object.keys(fileConfig).length > 0) {
// Source file was v1 (no config_version key but had content) — let
// normalizeConfig surface the migration log via its standard path.
config = normalizeConfig({ ...config, configVersion: undefined });
} else {
// Either v2 source, or empty file — silent normalization.
config = normalizeConfig({ ...config, configVersion: 2 });
}
// Post-normalize env overrides for top-level path settings.
//
// Runs *after* normalizeConfig so an explicit env value beats both
// the mirrored-from-storage value and the legacy default. Without
// this order, a deploy that sets `storage.worktree_dir` in YAML but
// also exports `WORKTREE_DIR=/tmp/override` would silently keep the
// YAML value — env was meant to be the highest precedence layer.
if (process.env['WORKTREE_DIR']) {
config.worktreeDir = process.env['WORKTREE_DIR'];
if (config.storage) {
config.storage.worktreeDir = process.env['WORKTREE_DIR'];
}
}
const errors = validateConfig(config);
for (const err of errors) {
logger.warn(`Config validation: ${err}`);
}
return config;
}
export function validateConfig(config: AppConfig): string[] {
const errors: string[] = [];
if (!Number.isInteger(config.concurrency) || config.concurrency <= 0) {
errors.push('concurrency must be a positive integer');
}
if (!Number.isInteger(config.maxMovements) || config.maxMovements <= 0) {
errors.push('maxMovements must be a positive integer');
}
if (!Number.isInteger(config.ask.maxPerJob) || config.ask.maxPerJob < 0) {
errors.push('ask.maxPerJob must be a non-negative integer');
}
if (!Number.isInteger(config.subtasks.maxDepth) || config.subtasks.maxDepth < 0) {
errors.push('subtasks.maxDepth must be a non-negative integer');
}
if (!Number.isInteger(config.subtasks.maxPerParent) || config.subtasks.maxPerParent < 1) {
errors.push('subtasks.maxPerParent must be a positive integer');
}
if (!Number.isInteger(config.retry.maxAttempts) || config.retry.maxAttempts <= 0) {
errors.push('retry.maxAttempts must be a positive integer');
}
if (!Array.isArray(config.retry.backoffSeconds) || config.retry.backoffSeconds.length === 0) {
errors.push('retry.backoffSeconds must be a non-empty array');
}
if (!Array.isArray(config.provider.workers) || config.provider.workers.length === 0) {
errors.push('provider.workers must be a non-empty array');
} else {
for (const worker of config.provider.workers) {
if (!worker.id || typeof worker.id !== 'string' || worker.id.trim() === '') {
errors.push(`provider.workers: worker has empty id`);
}
if (!worker.endpoint || typeof worker.endpoint !== 'string' || worker.endpoint.trim() === '') {
errors.push(`provider.workers[${worker.id ?? '?'}]: endpoint must be non-empty`);
}
if (worker.proxyType !== undefined && worker.proxyType !== 'litellm') {
errors.push(`provider.workers[${worker.id ?? '?'}]: proxy_type must be 'litellm' (got '${String(worker.proxyType)}')`);
}
// Reject non-boolean `proxy`. YAML coerces `proxy: "true"` (quoted) to a
// string, and `worker.proxy === true` then evaluates false — so the
// worker silently runs in direct mode while still shipping its apiKey
// as a Bearer token. That's a credential-leak footgun. Force admins to
// write `proxy: true` (bare boolean) explicitly.
if (worker.proxy !== undefined && typeof worker.proxy !== 'boolean') {
errors.push(`provider.workers[${worker.id ?? '?'}]: proxy must be boolean, got '${typeof worker.proxy}' (${JSON.stringify(worker.proxy)})`);
}
}
}
if (config.safety !== undefined) {
if (config.safety.maxIterations !== undefined) {
if (!Number.isInteger(config.safety.maxIterations) || config.safety.maxIterations <= 0) {
errors.push('safety.maxIterations must be a positive integer if defined');
}
}
if (config.safety.maxRevisits !== undefined) {
if (!Number.isInteger(config.safety.maxRevisits) || config.safety.maxRevisits <= 0) {
errors.push('safety.maxRevisits must be a positive integer if defined');
}
}
if (config.safety.promptGuardRatio !== undefined) {
const r = config.safety.promptGuardRatio;
if (typeof r !== 'number' || !Number.isFinite(r) || r < 0.5 || r > 0.95) {
errors.push('safety.promptGuardRatio must be a number in [0.5, 0.95] if defined');
}
}
if (config.safety.historySummarization !== undefined) {
const hs = config.safety.historySummarization;
if (hs.tailTurns !== undefined) {
if (!Number.isInteger(hs.tailTurns) || hs.tailTurns < 0) {
errors.push('safety.historySummarization.tailTurns must be a non-negative integer if defined');
}
}
if (hs.preserveRecentBudget !== undefined) {
if (!Number.isInteger(hs.preserveRecentBudget) || hs.preserveRecentBudget <= 0) {
errors.push('safety.historySummarization.preserveRecentBudget must be a positive integer if defined');
}
}
}
if (config.safety.bashUnrestricted !== undefined && typeof config.safety.bashUnrestricted !== 'boolean') {
errors.push('safety.bashUnrestricted must be a boolean if defined');
}
if (config.safety.bashSandbox !== undefined &&
!['auto', 'always', 'off'].includes(config.safety.bashSandbox)) {
errors.push("safety.bashSandbox must be one of 'auto' | 'always' | 'off' if defined");
}
}
if (config.provider.retry !== undefined) {
if (!Number.isInteger(config.provider.retry.maxAttempts) || config.provider.retry.maxAttempts <= 0) {
errors.push('provider.retry.maxAttempts must be a positive integer if defined');
}
}
if (config.provider.metrics?.prefix !== undefined) {
const p = config.provider.metrics.prefix;
if (typeof p !== 'string' || !/^[a-z][a-z0-9_]*$/.test(p)) {
errors.push(
`provider.metrics.prefix must match /^[a-z][a-z0-9_]*$/ (got ${JSON.stringify(p)})`,
);
} else if (p.length < 2 || p.length > 64) {
// The regex alone accepts a single character or a 10 000-char
// string. Both create Grafana / dashboard auto-complete pain —
// cap to a sane range.
errors.push(
`provider.metrics.prefix length must be 2-64 chars (got ${p.length})`,
);
}
}
if (config.provider.metrics?.bearerToken !== undefined) {
const t = config.provider.metrics.bearerToken;
if (typeof t !== 'string' || t.length === 0) {
errors.push('provider.metrics.bearer_token must be a non-empty string if defined');
}
}
if (config.provider.metrics?.allowedHosts !== undefined) {
const h = config.provider.metrics.allowedHosts;
if (!Array.isArray(h) || h.some(v => typeof v !== 'string' || v.length === 0)) {
errors.push('provider.metrics.allowed_hosts must be an array of non-empty strings');
}
}
return errors;
}
let cachedProviderRetryConfig: ProviderRetryConfig | null = null;
export function getDefaultProviderRetryConfig(): ProviderRetryConfig {
if (cachedProviderRetryConfig === null) {
cachedProviderRetryConfig = cloneProviderRetryConfig(loadConfig().provider.retry ?? DEFAULT_LLM_RETRY_CONFIG);
}
return cloneProviderRetryConfig(cachedProviderRetryConfig);
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
initMasterKey, generateUserDek, encryptUserDek, decryptUserDek,
encryptStateBlob, decryptStateBlob,
} from './sessions.js';
describe('envelope encryption', () => {
let dir: string;
let masterKeyPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'crypto-test-'));
masterKeyPath = join(dir, 'master.key');
});
it('initMasterKey writes a 32-byte file when missing', () => {
const key = initMasterKey(masterKeyPath);
expect(key.length).toBe(32);
const reread = initMasterKey(masterKeyPath);
expect(reread.equals(key)).toBe(true);
rmSync(dir, { recursive: true, force: true });
});
it('round-trips a user DEK through master key envelope', () => {
const master = initMasterKey(masterKeyPath);
const dek = generateUserDek();
const enc = encryptUserDek(master, dek);
const dec = decryptUserDek(master, enc);
expect(dec.equals(dek)).toBe(true);
rmSync(dir, { recursive: true, force: true });
});
it('round-trips a state blob through user DEK', () => {
const dek = generateUserDek();
const payload = JSON.stringify({ cookies: [{ name: 'sid', value: 'abc' }] });
const enc = encryptStateBlob(dek, payload);
const dec = decryptStateBlob(dek, enc);
expect(dec).toBe(payload);
rmSync(dir, { recursive: true, force: true });
});
it('decryptStateBlob throws on tampered ciphertext', () => {
const dek = generateUserDek();
const enc = encryptStateBlob(dek, 'hello');
enc[enc.length - 1] ^= 0xff;
expect(() => decryptStateBlob(dek, enc)).toThrow();
});
});
+61
View File
@@ -0,0 +1,61 @@
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from 'fs';
import { dirname } from 'path';
const ALGO = 'aes-256-gcm';
const IV_LEN = 12;
const TAG_LEN = 16;
/** Read or initialize the master key (32 bytes) at `path`. File mode is forced to 0600. */
export function initMasterKey(path: string): Buffer {
if (existsSync(path)) {
const buf = readFileSync(path);
if (buf.length !== 32) {
throw new Error(`Master key at ${path} is not 32 bytes (got ${buf.length})`);
}
return buf;
}
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const key = randomBytes(32);
writeFileSync(path, key, { mode: 0o600 });
chmodSync(path, 0o600);
return key;
}
export function generateUserDek(): Buffer {
return randomBytes(32);
}
function encrypt(key: Buffer, plaintext: Buffer): Buffer {
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, ciphertext, tag]);
}
function decrypt(key: Buffer, blob: Buffer): Buffer {
if (blob.length < IV_LEN + TAG_LEN) throw new Error('blob too short');
const iv = blob.subarray(0, IV_LEN);
const tag = blob.subarray(blob.length - TAG_LEN);
const ciphertext = blob.subarray(IV_LEN, blob.length - TAG_LEN);
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
export function encryptUserDek(masterKey: Buffer, dek: Buffer): Buffer {
return encrypt(masterKey, dek);
}
export function decryptUserDek(masterKey: Buffer, enc: Buffer): Buffer {
return decrypt(masterKey, enc);
}
export function encryptStateBlob(dek: Buffer, plaintext: string): Buffer {
return encrypt(dek, Buffer.from(plaintext, 'utf-8'));
}
export function decryptStateBlob(dek: Buffer, blob: Buffer): string {
return decrypt(dek, blob).toString('utf-8');
}
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from './repository.js';
describe('browser session persistence migrations', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
it('creates the new tables and columns via Repository constructor', () => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsm-'));
const dbPath = join(tempDir, 'orchestrator.db');
// Repository constructor runs schema.sql + idempotent migrations.
new Repository(dbPath);
const db = new Database(dbPath, { readonly: true });
try {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
const names = new Set(tables.map(t => t.name));
expect(names.has('user_deks')).toBe(true);
expect(names.has('browser_session_profiles')).toBe(true);
expect(names.has('browser_session_audit')).toBe(true);
const localCols = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>;
expect(localCols.some(c => c.name === 'browser_session_profile_id')).toBe(true);
const scheduledCols = db.prepare("PRAGMA table_info('scheduled_tasks')").all() as Array<{ name: string }>;
expect(scheduledCols.some(c => c.name === 'browser_session_profile_id')).toBe(true);
const jobCols = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
expect(jobCols.some(c => c.name === 'browser_session_profile_id')).toBe(true);
} finally {
db.close();
}
});
it('schema.sql alone (without migrations) creates the new tables', () => {
const db = new Database(':memory:');
try {
const schema = readFileSync(new URL('./schema.sql', import.meta.url), 'utf-8');
db.exec(schema);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
const names = new Set(tables.map(t => t.name));
expect(names.has('user_deks')).toBe(true);
expect(names.has('browser_session_profiles')).toBe(true);
expect(names.has('browser_session_audit')).toBe(true);
} finally {
db.close();
}
});
it('rejects invalid status / action / result values', () => {
const tmp = mkdtempSync(join(tmpdir(), 'crg-mig-'));
const dbPath = join(tmp, 'test.db');
new Repository(dbPath);
const db = new Database(dbPath);
expect(() => db.prepare(
`INSERT INTO browser_session_profiles (owner_id, label, start_url, status)
VALUES ('u1','x','https://x.com','garbage')`
).run()).toThrow();
expect(() => db.prepare(
`INSERT INTO browser_session_audit (action, result) VALUES ('garbage','success')`
).run()).toThrow();
expect(() => db.prepare(
`INSERT INTO browser_session_audit (action, result) VALUES ('create','garbage')`
).run()).toThrow();
db.close();
rmSync(tmp, { recursive: true, force: true });
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from './repository.js';
import { BrowserSessionRepo } from './browser-session-repo.js';
describe('BrowserSessionRepo', () => {
let tempDir = '';
let repository: Repository;
let db: Database.Database;
let repo: BrowserSessionRepo;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsr-'));
const dbPath = join(tempDir, 'orchestrator.db');
repository = new Repository(dbPath);
db = repository.getDb();
// Seed a user
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`)
.run('u1', 'u1@test');
repo = new BrowserSessionRepo(db);
});
afterEach(() => {
repository.close();
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
it('upserts and reads a user DEK', () => {
repo.setUserDek('u1', Buffer.from('a'.repeat(48)));
const out = repo.getUserDek('u1');
expect(out?.toString()).toBe('a'.repeat(48));
});
it('creates a profile then loads it by id with owner check', () => {
const id = repo.createProfile({
ownerId: 'u1', label: 'GitHub', startUrl: 'https://github.com',
matchPatterns: ['https://github.com/**'], storageOrigins: ['https://github.com'],
loggedInSelector: 'header [aria-label*="View profile"]',
loginUrlPatterns: ['https://github.com/login**'],
});
const profile = repo.getProfileById(id, 'u1');
expect(profile?.label).toBe('GitHub');
expect(profile?.matchPatterns).toEqual(['https://github.com/**']);
expect(repo.getProfileById(id, 'someone-else')).toBeNull();
});
it('lists only the callers profiles', () => {
repo.createProfile({ ownerId: 'u1', label: 'A', startUrl: 'https://a.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES ('u2','u2@test','active','active',datetime('now'),datetime('now'))`).run();
repo.createProfile({ ownerId: 'u2', label: 'B', startUrl: 'https://b.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
expect(repo.listProfilesByOwner('u1').length).toBe(1);
expect(repo.listProfilesByOwner('u1')[0]!.label).toBe('A');
});
it('saves an encrypted blob and bumps state_version', () => {
const id = repo.createProfile({ ownerId: 'u1', label: 'X', startUrl: 'https://x.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
repo.saveProfileBlob(id, Buffer.from('encrypted'), '1.40.0');
const p = repo.getProfileById(id, 'u1');
expect(p?.encryptedStateBlob?.toString()).toBe('encrypted');
expect(p?.stateVersion).toBe(1);
expect(p?.status).toBe('active');
repo.saveProfileBlob(id, Buffer.from('encrypted2'), '1.40.0');
expect(repo.getProfileById(id, 'u1')?.stateVersion).toBe(2);
});
it('marks profile expired with reason', () => {
const id = repo.createProfile({ ownerId: 'u1', label: 'X', startUrl: 'https://x.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
repo.markProfileStatus(id, 'expired', 'redirected to /login');
const p = repo.getProfileById(id, 'u1');
expect(p?.status).toBe('expired');
expect(p?.lastError).toBe('redirected to /login');
});
it('writes audit entries', () => {
const id = repo.createProfile({ ownerId: 'u1', label: 'X', startUrl: 'https://x.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
repo.audit({ actorUserId: 'u1', ownerId: 'u1', profileId: id, action: 'create', result: 'success' });
const rows = db.prepare('SELECT * FROM browser_session_audit').all() as Array<{ action: string; result: string }>;
expect(rows.length).toBe(1);
expect(rows[0]!.action).toBe('create');
});
});
+173
View File
@@ -0,0 +1,173 @@
import type Database from 'better-sqlite3';
export interface BrowserSessionProfile {
id: number;
ownerId: string;
label: string;
startUrl: string;
matchPatterns: string[];
storageOrigins: string[];
loggedInSelector: string | null;
loginUrlPatterns: string[];
encryptedStateBlob: Buffer | null;
stateVersion: number;
playwrightVersion: string | null;
status: 'pending' | 'active' | 'expired' | 'revoked' | 'error';
lastSavedAt: string | null;
lastUsedAt: string | null;
lastValidatedAt: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateProfileInput {
ownerId: string;
label: string;
startUrl: string;
matchPatterns: string[];
storageOrigins: string[];
loggedInSelector?: string | null;
loginUrlPatterns: string[];
}
export interface AuditInput {
actorUserId?: string | null;
ownerId?: string | null;
profileId?: number | null;
action:
| 'create' | 'save' | 'decrypt' | 'use' | 'delete'
| 'expire' | 'revoke' | 'test' | 'login_start' | 'login_cancel';
taskId?: number | null;
jobId?: string | null;
result: 'success' | 'error';
reason?: string | null;
}
function rowToProfile(row: Record<string, unknown>): BrowserSessionProfile {
return {
id: row['id'] as number,
ownerId: row['owner_id'] as string,
label: row['label'] as string,
startUrl: row['start_url'] as string,
matchPatterns: JSON.parse((row['match_patterns'] as string) || '[]') as string[],
storageOrigins: JSON.parse((row['storage_origins'] as string) || '[]') as string[],
loggedInSelector: (row['logged_in_selector'] as string | null) ?? null,
loginUrlPatterns: JSON.parse((row['login_url_patterns'] as string) || '[]') as string[],
encryptedStateBlob: (row['encrypted_state_blob'] as Buffer | null) ?? null,
stateVersion: (row['state_version'] as number) ?? 0,
playwrightVersion: (row['playwright_version'] as string | null) ?? null,
status: row['status'] as BrowserSessionProfile['status'],
lastSavedAt: (row['last_saved_at'] as string | null) ?? null,
lastUsedAt: (row['last_used_at'] as string | null) ?? null,
lastValidatedAt: (row['last_validated_at'] as string | null) ?? null,
lastError: (row['last_error'] as string | null) ?? null,
createdAt: row['created_at'] as string,
updatedAt: row['updated_at'] as string,
};
}
export class BrowserSessionRepo {
constructor(private readonly db: Database.Database) {}
// ── DEK management ────────────────────────────────────────────────
setUserDek(userId: string, encryptedDek: Buffer): void {
this.db.prepare(`
INSERT INTO user_deks (user_id, encrypted_dek)
VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET encrypted_dek = excluded.encrypted_dek
`).run(userId, encryptedDek);
}
getUserDek(userId: string): Buffer | null {
const row = this.db.prepare('SELECT encrypted_dek FROM user_deks WHERE user_id = ?').get(userId) as { encrypted_dek: Buffer } | undefined;
return row?.encrypted_dek ?? null;
}
// ── Profiles ──────────────────────────────────────────────────────
createProfile(input: CreateProfileInput): number {
const result = this.db.prepare(`
INSERT INTO browser_session_profiles
(owner_id, label, start_url, match_patterns, storage_origins,
logged_in_selector, login_url_patterns, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')
`).run(
input.ownerId,
input.label,
input.startUrl,
JSON.stringify(input.matchPatterns),
JSON.stringify(input.storageOrigins),
input.loggedInSelector ?? null,
JSON.stringify(input.loginUrlPatterns),
);
return Number(result.lastInsertRowid);
}
getProfileById(id: number, ownerId: string): BrowserSessionProfile | null {
const row = this.db.prepare('SELECT * FROM browser_session_profiles WHERE id = ? AND owner_id = ?').get(id, ownerId) as Record<string, unknown> | undefined;
return row ? rowToProfile(row) : null;
}
/** Admin / worker path that does NOT enforce ownership. Caller must check elsewhere. */
getProfileByIdUnsafe(id: number): BrowserSessionProfile | null {
const row = this.db.prepare('SELECT * FROM browser_session_profiles WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? rowToProfile(row) : null;
}
listProfilesByOwner(ownerId: string): BrowserSessionProfile[] {
const rows = this.db.prepare('SELECT * FROM browser_session_profiles WHERE owner_id = ? ORDER BY label ASC').all(ownerId) as Array<Record<string, unknown>>;
return rows.map(rowToProfile);
}
saveProfileBlob(id: number, encrypted: Buffer, playwrightVersion: string): void {
this.db.prepare(`
UPDATE browser_session_profiles
SET encrypted_state_blob = ?,
state_version = state_version + 1,
playwright_version = ?,
status = 'active',
last_saved_at = datetime('now'),
last_validated_at = datetime('now'),
last_error = NULL,
updated_at = datetime('now')
WHERE id = ?
`).run(encrypted, playwrightVersion, id);
}
markProfileStatus(id: number, status: BrowserSessionProfile['status'], reason: string | null = null): void {
this.db.prepare(`
UPDATE browser_session_profiles
SET status = ?,
last_error = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(status, reason, id);
}
touchUsed(id: number): void {
this.db.prepare(`UPDATE browser_session_profiles SET last_used_at = datetime('now') WHERE id = ?`).run(id);
}
deleteProfile(id: number, ownerId: string): boolean {
const result = this.db.prepare('DELETE FROM browser_session_profiles WHERE id = ? AND owner_id = ?').run(id, ownerId);
return result.changes > 0;
}
// ── Audit ─────────────────────────────────────────────────────────
audit(input: AuditInput): void {
this.db.prepare(`
INSERT INTO browser_session_audit
(actor_user_id, profile_id, owner_id, action, task_id, job_id, result, reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.actorUserId ?? null,
input.profileId ?? null,
input.ownerId ?? null,
input.action,
input.taskId ?? null,
input.jobId ?? null,
input.result,
input.reason ?? null,
);
}
}
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from './repository.js';
describe('Repository dashboard widgets', () => {
let tmpDir: string;
let repo: Repository;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'dashboard-repo-test-'));
repo = new Repository(join(tmpDir, 'test.db'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('creates and lists widgets scoped to user_id', async () => {
const a = await repo.createDashboardWidget({ userId: 'u1', slug: 'memo', title: 'Memo', content: 'hello' });
await repo.createDashboardWidget({ userId: 'u2', slug: 'memo', title: 'Memo2', content: 'other-user' });
const widgets = await repo.listDashboardWidgets('u1');
expect(widgets).toHaveLength(1);
expect(widgets[0]!.id).toBe(a.id);
expect(widgets[0]!.slug).toBe('memo');
expect(widgets[0]!.title).toBe('Memo');
expect(widgets[0]!.markdownContent).toBe('hello');
});
it('rejects duplicate slug for same user', async () => {
await repo.createDashboardWidget({ userId: 'u1', slug: 'memo', title: 'a', content: '' });
await expect(
repo.createDashboardWidget({ userId: 'u1', slug: 'memo', title: 'b', content: '' })
).rejects.toThrow();
});
it('updates content and bumps updated_at', async () => {
const w = await repo.createDashboardWidget({ userId: 'u1', slug: 's', title: 't', content: 'old' });
const originalUpdatedAt = w.updatedAt;
await new Promise(r => setTimeout(r, 1100)); // datetime('now') has 1s precision
const updated = await repo.updateDashboardWidget(w.id, 'u1', { content: 'new' });
expect(updated.markdownContent).toBe('new');
expect(updated.updatedAt).not.toBe(originalUpdatedAt);
});
it('updateDashboardWidget rejects updates from other users', async () => {
const w = await repo.createDashboardWidget({ userId: 'u1', slug: 's', title: 't', content: 'orig' });
await expect(
repo.updateDashboardWidget(w.id, 'u2', { content: 'hack' })
).rejects.toThrow(/not found/i);
const list = await repo.listDashboardWidgets('u1');
expect(list[0]!.markdownContent).toBe('orig');
});
it('upserts by (user_id, slug): returns existing if slug already exists', async () => {
const created = await repo.upsertDashboardWidgetBySlug({ userId: 'u1', slug: 'news', title: 'News', content: 'a' });
const second = await repo.upsertDashboardWidgetBySlug({ userId: 'u1', slug: 'news', content: 'b' });
expect(second.id).toBe(created.id);
expect(second.markdownContent).toBe('b');
});
it('deletes widget by id', async () => {
const w = await repo.createDashboardWidget({ userId: 'u1', slug: 's', title: 't', content: '' });
await repo.deleteDashboardWidget(w.id, 'u1');
const list = await repo.listDashboardWidgets('u1');
expect(list).toHaveLength(0);
});
it('reorders widgets by id list', async () => {
const a = await repo.createDashboardWidget({ userId: 'u1', slug: 'a', title: 'A', content: '' });
const b = await repo.createDashboardWidget({ userId: 'u1', slug: 'b', title: 'B', content: '' });
const c = await repo.createDashboardWidget({ userId: 'u1', slug: 'c', title: 'C', content: '' });
await repo.reorderDashboardWidgets('u1', [c.id, a.id, b.id]);
const list = await repo.listDashboardWidgets('u1');
expect(list.map(w => w.slug)).toEqual(['c', 'a', 'b']);
});
it('reorder rejects ids from other users (silent skip + leave order intact)', async () => {
const u1a = await repo.createDashboardWidget({ userId: 'u1', slug: 'a', title: 'A', content: '' });
const u2x = await repo.createDashboardWidget({ userId: 'u2', slug: 'x', title: 'X', content: '' });
await repo.reorderDashboardWidgets('u1', [u2x.id, u1a.id]);
const list = await repo.listDashboardWidgets('u1');
expect(list.map(w => w.id)).toEqual([u1a.id]); // u2x should not appear
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* AAO Gateway Phase 2b migration test.
*
* Coverage:
* - Fresh DB: new columns exist, gateway_key_usage table exists
* - Idempotency: runMigrations called twice produces the same shape
* - Pre-Phase-2b shape: drops the new columns, runs migrate, columns are
* re-added as nullable INTEGER
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runMigrations } from './migrate.js';
function getColumnNames(db: Database.Database, table: string): string[] {
return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map(c => c.name);
}
function getColumnInfo(db: Database.Database, table: string, col: string): { notnull: number; dflt_value: unknown; type: string } | undefined {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string; notnull: number; dflt_value: unknown; type: string }>;
return rows.find(r => r.name === col);
}
describe('Phase 2b migration: gateway_virtual_keys columns + gateway_key_usage table', () => {
let dir: string;
let dbPath: string;
let db: Database.Database;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'gw-2b-mig-'));
dbPath = join(dir, 'db.sqlite');
db = new Database(dbPath);
db.pragma('foreign_keys = ON');
// Seed an existing Phase 2a-shaped gateway_virtual_keys (no Phase 2b
// columns) to simulate a deployed pre-2b DB.
db.exec(`
CREATE TABLE IF NOT EXISTS gateway_virtual_keys (
id TEXT PRIMARY KEY,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
team TEXT NOT NULL,
allowed_models TEXT,
source TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL,
created_by TEXT,
revoked_at TEXT,
revoked_by TEXT,
last_used_at TEXT
);
`);
});
afterEach(() => {
db.close();
rmSync(dir, { recursive: true, force: true });
});
it('adds tokens_budget + rate_limit_rpm columns to a Phase 2a-shaped DB', () => {
expect(getColumnNames(db, 'gateway_virtual_keys')).not.toContain('tokens_budget');
expect(getColumnNames(db, 'gateway_virtual_keys')).not.toContain('rate_limit_rpm');
runMigrations(db);
const cols = getColumnNames(db, 'gateway_virtual_keys');
expect(cols).toContain('tokens_budget');
expect(cols).toContain('rate_limit_rpm');
// Nullable (no NOT NULL constraint) so existing rows survive.
const tb = getColumnInfo(db, 'gateway_virtual_keys', 'tokens_budget');
expect(tb?.notnull).toBe(0);
expect(tb?.type.toUpperCase()).toBe('INTEGER');
const rl = getColumnInfo(db, 'gateway_virtual_keys', 'rate_limit_rpm');
expect(rl?.notnull).toBe(0);
expect(rl?.type.toUpperCase()).toBe('INTEGER');
});
it('creates gateway_key_usage table with composite PK', () => {
runMigrations(db);
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='gateway_key_usage'")
.all() as Array<{ name: string }>;
expect(tables).toHaveLength(1);
const cols = getColumnNames(db, 'gateway_key_usage');
expect(cols).toEqual(
expect.arrayContaining(['key_id', 'period_start', 'tokens_in', 'tokens_out', 'requests', 'last_updated_at']),
);
// PRIMARY KEY columns are flagged in pk > 0 in PRAGMA table_info.
const pkInfo = db.prepare(`PRAGMA table_info(gateway_key_usage)`).all() as Array<{ name: string; pk: number }>;
const pkCols = pkInfo.filter(c => c.pk > 0).map(c => c.name).sort();
expect(pkCols).toEqual(['key_id', 'period_start']);
});
it('is idempotent (runMigrations twice has no effect)', () => {
runMigrations(db);
const colsAfter1 = getColumnNames(db, 'gateway_virtual_keys');
runMigrations(db); // should not throw or duplicate columns
const colsAfter2 = getColumnNames(db, 'gateway_virtual_keys');
expect(colsAfter2).toEqual(colsAfter1);
});
});

Some files were not shown because too many files have changed in this diff Show More